commit 7d10320a8220e92b9b04497b4a33f08d7370b1ca Author: yi <100551693+yi1108@users.noreply.github.com> Date: Fri Jun 5 11:29:11 2026 +0800 feat: 灵犀 Studio Web UI 定制版 Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5209e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +node_modules +dist +hermes_data +*.log +.DS_Store diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..18833a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,101 @@ +name: Bug Report +description: File a bug report to help us improve +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: input + id: version + attributes: + label: Hermes Web UI Version + description: What version of Hermes Web UI are you using? + placeholder: e.g., v0.5.8 + validations: + required: true + + - type: input + id: hermes_version + attributes: + label: Hermes Agent Version + description: What version of Hermes Agent are you using? + placeholder: e.g., v0.12.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened + placeholder: | + If applicable, add screenshots to help explain your problem + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / Error Messages + description: Paste any relevant logs or error messages + render: shell + + - type: dropdown + id: environment + attributes: + label: Environment + description: Where are you running Hermes Web UI? + options: + - Docker + - macOS + - Linux + - Windows + - WSL + multiple: true + validations: + required: true + + - type: input + id: node_version + attributes: + label: Node Version + description: What version of Node.js are you using? + placeholder: e.g., v24.14.1 + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context about the problem diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1b61f7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/EKKOLearnAI/hermes-web-ui#readme + about: Please check the documentation first + - name: GitHub Discussions + url: https://github.com/EKKOLearnAI/hermes-web-ui/discussions + about: Use GitHub Discussions for questions that don't fit as issues diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8fcc451 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,76 @@ +name: Feature Request +description: Suggest an idea for this project +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! Please fill out the form below. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem does this feature solve? What pain point does it address? + placeholder: | + I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: What would you like to see implemented? + placeholder: | + I think adding X would be great because... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - Critical - blocking my usage + - High - really need this + - Medium - nice to have + - Low - would be convenient + validations: + required: true + + - type: textarea + id: use_cases + attributes: + label: Use Cases + description: Describe specific use cases where this feature would be helpful + placeholder: | + 1. When I do X... + 2. When I need to Y... + + - type: checkboxes + id: contribution + attributes: + label: Willing to Contribute? + description: Would you be willing to help implement this feature? + options: + - label: Yes, I'd like to submit a PR + required: false + - label: Yes, but I need guidance + required: false + - label: No, I don't have time + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, mockups, or examples about the feature request diff --git a/.github/ISSUE_TEMPLATE/general_issue.md b/.github/ISSUE_TEMPLATE/general_issue.md new file mode 100644 index 0000000..843b94f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general_issue.md @@ -0,0 +1,22 @@ +--- +name: General Issue +about: Use this for issues that don't fit into bug reports or feature requests +title: '[Question]: ' +labels: ['question'] +assignees: '' +--- + +## Please describe your issue + + + +## Context + + + +## Environment (if applicable) + +- Hermes Web UI Version: +- Hermes Agent Version: +- Operating System: +- Node Version: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7a65359 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + - base + +permissions: + contents: read + +concurrency: + group: build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: | + npm ci --ignore-scripts + npm rebuild node-pty + + - name: Check repository harness + run: npm run harness:check + + - name: Test with coverage + run: npm run test:coverage + + - name: Build + run: npm run build diff --git a/.github/workflows/desktop-manual-build.yml b/.github/workflows/desktop-manual-build.yml new file mode 100644 index 0000000..a27f457 --- /dev/null +++ b/.github/workflows/desktop-manual-build.yml @@ -0,0 +1,213 @@ +name: Manual Desktop Build + +on: + workflow_dispatch: + inputs: + target_os: + description: "Desktop target OS" + required: true + type: choice + default: win32 + options: + - win32 + - darwin + - linux + target_arch: + description: "Desktop target architecture" + required: true + type: choice + default: x64 + options: + - x64 + - arm64 + release_tag: + description: "Optional release tag to attach artifacts to" + required: false + type: string + runtime_release_tag: + description: "Optional runtime release tag embedded into the desktop app" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: desktop-manual-${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + label: ${{ steps.target.outputs.label }} + runner: ${{ steps.target.outputs.runner }} + target_os: ${{ steps.target.outputs.target_os }} + target_arch: ${{ steps.target.outputs.target_arch }} + electron_target: ${{ steps.target.outputs.electron_target }} + artifact_name: ${{ steps.target.outputs.artifact_name }} + artifact_files: ${{ steps.target.outputs.artifact_files }} + steps: + - name: Select requested target + id: target + shell: bash + run: | + write_common_outputs() { + { + echo "label=$1" + echo "runner=$2" + echo "target_os=${{ github.event.inputs.target_os }}" + echo "target_arch=${{ github.event.inputs.target_arch }}" + echo "electron_target=$3" + echo "artifact_name=$4" + echo "artifact_files<> "$GITHUB_OUTPUT" + } + + case "${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}" in + win32-x64) + write_common_outputs "Windows x64" "windows-latest" "--win nsis --x64" "desktop-win32-x64" \ + "packages/desktop/release/*.exe" \ + "packages/desktop/release/*.exe.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + darwin-arm64) + write_common_outputs "macOS arm64" "macos-14" "--mac dmg zip --arm64" "desktop-darwin-arm64" \ + "packages/desktop/release/*.dmg" \ + "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/*.zip" \ + "packages/desktop/release/*.zip.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + darwin-x64) + write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg zip --x64" "desktop-darwin-x64" \ + "packages/desktop/release/*.dmg" \ + "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/*.zip" \ + "packages/desktop/release/*.zip.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + linux-x64) + write_common_outputs "Linux x64" "ubuntu-22.04" "--linux AppImage deb --x64" "desktop-linux-x64" \ + "packages/desktop/release/*.AppImage" \ + "packages/desktop/release/*.deb" \ + "packages/desktop/release/latest*.yml" + ;; + linux-arm64) + write_common_outputs "Linux arm64" "ubuntu-22.04-arm" "--linux AppImage --arm64" "desktop-linux-arm64" \ + "packages/desktop/release/*.AppImage" \ + "packages/desktop/release/latest*.yml" + ;; + *) + echo "Unsupported desktop target: ${{ github.event.inputs.target_os }} ${{ github.event.inputs.target_arch }}" >&2 + exit 1 + ;; + esac + + desktop: + name: Desktop (${{ needs.validate.outputs.label }}) + needs: validate + runs-on: ${{ needs.validate.outputs.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + packages/desktop/package-lock.json + + - name: Install web UI dependencies + run: | + npm ci --ignore-scripts + npm rebuild node-pty + + - name: Build web UI + run: npm run build + + - name: Keep production web UI dependencies only + run: npm prune --omit=dev --no-audit --no-fund + + - name: Install desktop dependencies + run: npm ci --prefix packages/desktop --no-audit --no-fund + + - name: Write runtime release metadata + shell: bash + env: + RUNTIME_RELEASE_TAG: ${{ github.event.inputs.runtime_release_tag }} + run: npm --prefix packages/desktop run write:runtime-release + + - name: Configure macOS signing + if: needs.validate.outputs.target_os == 'darwin' + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + MAC_APPLE_ID: ${{ secrets.APPLE_ID }} + MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + write_env() { + local name="$1" + local value="$2" + if [ -n "$value" ]; then + { + echo "$name<> "$GITHUB_ENV" + fi + } + + if [ -z "${MAC_CSC_LINK:-}" ]; then + echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV" + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "No macOS signing certificate configured; building unsigned and skipping notarization." + exit 0 + fi + + write_env "CSC_LINK" "$MAC_CSC_LINK" + write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD" + + if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then + write_env "APPLE_ID" "$MAC_APPLE_ID" + write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD" + write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID" + echo "macOS signing and notarization are configured." + else + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization." + fi + + - name: Build desktop artifact + shell: bash + run: | + if [ "${{ needs.validate.outputs.target_os }}" = "darwin" ]; then + ulimit -n 10240 || true + echo "File descriptor limit: $(ulimit -n)" + fi + npm --prefix packages/desktop run dist -- ${{ needs.validate.outputs.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.validate.outputs.artifact_name }} + path: ${{ needs.validate.outputs.artifact_files }} + if-no-files-found: error + retention-days: 7 + + - name: Upload artifacts to release + if: github.event.inputs.release_tag != '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.release_tag }} + fail_on_unmatched_files: true + files: ${{ needs.validate.outputs.artifact_files }} diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..945e604 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,206 @@ +name: Publish Desktop Artifacts to Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to attach artifacts to (e.g. v0.6.5)" + required: true + release: + types: [published] + +permissions: + contents: write + +concurrency: + group: desktop-release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + desktop: + name: Desktop (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - label: macOS arm64 + runner: macos-14 + target_os: darwin + target_arch: arm64 + electron_target: "--mac dmg zip --arm64" + artifact_files: | + packages/desktop/release/*.dmg + packages/desktop/release/*.dmg.blockmap + packages/desktop/release/*.zip + packages/desktop/release/*.zip.blockmap + - label: macOS x64 + runner: macos-15-intel + target_os: darwin + target_arch: x64 + electron_target: "--mac dmg zip --x64" + artifact_files: | + packages/desktop/release/*.dmg + packages/desktop/release/*.dmg.blockmap + packages/desktop/release/*.zip + packages/desktop/release/*.zip.blockmap + - label: Windows x64 + runner: windows-latest + target_os: win32 + target_arch: x64 + electron_target: "--win nsis --x64" + artifact_files: | + packages/desktop/release/*.exe + packages/desktop/release/*.exe.blockmap + packages/desktop/release/latest*.yml + - label: Linux x64 + runner: ubuntu-22.04 + target_os: linux + target_arch: x64 + electron_target: "--linux AppImage deb --x64" + artifact_files: | + packages/desktop/release/*.AppImage + packages/desktop/release/*.deb + packages/desktop/release/latest*.yml + - label: Linux arm64 + runner: ubuntu-22.04-arm + target_os: linux + target_arch: arm64 + electron_target: "--linux AppImage --arm64" + artifact_files: | + packages/desktop/release/*.AppImage + packages/desktop/release/latest*.yml + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.event.inputs.tag }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + packages/desktop/package-lock.json + + - name: Install web UI dependencies + run: | + npm ci --ignore-scripts + npm rebuild node-pty + + - name: Build web UI + run: npm run build + + - name: Keep production web UI dependencies only + run: npm prune --omit=dev --no-audit --no-fund + + - name: Install desktop dependencies + run: npm ci --prefix packages/desktop --no-audit --no-fund + + - name: Write runtime release metadata + shell: bash + env: + HERMES_DESKTOP_RUNTIME_RELEASE_TAG: ${{ vars.HERMES_DESKTOP_RUNTIME_RELEASE_TAG }} + run: npm --prefix packages/desktop run write:runtime-release + + - name: Configure macOS signing + if: matrix.target_os == 'darwin' + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + MAC_APPLE_ID: ${{ secrets.APPLE_ID }} + MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + write_env() { + local name="$1" + local value="$2" + if [ -n "$value" ]; then + { + echo "$name<> "$GITHUB_ENV" + fi + } + + if [ -z "${MAC_CSC_LINK:-}" ]; then + echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV" + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "No macOS signing certificate configured; building unsigned and skipping notarization." + exit 0 + fi + + write_env "CSC_LINK" "$MAC_CSC_LINK" + write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD" + + if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then + write_env "APPLE_ID" "$MAC_APPLE_ID" + write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD" + write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID" + echo "macOS signing and notarization are configured." + else + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization." + fi + + - name: Build desktop artifact + shell: bash + run: | + if [ "${{ matrix.target_os }}" = "darwin" ]; then + ulimit -n 10240 || true + echo "File descriptor limit: $(ulimit -n)" + fi + npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never + + - name: Upload macOS update manifest artifact + if: matrix.target_os == 'darwin' + uses: actions/upload-artifact@v4 + with: + name: latest-mac-${{ matrix.target_arch }} + path: packages/desktop/release/latest-mac.yml + if-no-files-found: error + retention-days: 1 + + - name: Upload artifacts to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + fail_on_unmatched_files: true + files: ${{ matrix.artifact_files }} + + mac-update-manifest: + name: Merge macOS updater manifest + needs: desktop + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.event.inputs.tag }} + + - name: Download macOS update manifests + uses: actions/download-artifact@v4 + with: + pattern: latest-mac-* + path: /tmp/hermes-mac-manifests + merge-multiple: false + + - name: Merge macOS update manifests + shell: bash + run: | + node packages/desktop/scripts/merge-mac-latest-yml.mjs \ + /tmp/hermes-mac-manifests/latest-mac-arm64/latest-mac.yml \ + /tmp/hermes-mac-manifests/latest-mac-x64/latest-mac.yml \ + > /tmp/latest-mac.yml + + - name: Upload merged macOS updater manifest to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + fail_on_unmatched_files: true + files: /tmp/latest-mac.yml diff --git a/.github/workflows/desktop-runtime.yml b/.github/workflows/desktop-runtime.yml new file mode 100644 index 0000000..6cb194f --- /dev/null +++ b/.github/workflows/desktop-runtime.yml @@ -0,0 +1,125 @@ +name: Publish Desktop Runtime to Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to attach runtime assets to" + required: true + release: + types: [published] + +permissions: + contents: write + +concurrency: + group: desktop-runtime-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + runtime: + name: Runtime (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - label: macOS arm64 + runner: macos-14 + target_os: darwin + target_arch: arm64 + - label: macOS x64 + runner: macos-15-intel + target_os: darwin + target_arch: x64 + - label: Windows x64 + runner: windows-latest + target_os: win32 + target_arch: x64 + - label: Linux x64 + runner: ubuntu-22.04 + target_os: linux + target_arch: x64 + - label: Linux arm64 + runner: ubuntu-22.04-arm + target_os: linux + target_arch: arm64 + skip_browser_runtime: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + + - name: Resolve runtime asset names + id: names + shell: bash + env: + TARGET_OS: ${{ matrix.target_os }} + TARGET_ARCH: ${{ matrix.target_arch }} + run: | + echo "asset=$(node packages/desktop/scripts/runtime-asset-name.mjs)" >> "$GITHUB_OUTPUT" + echo "manifest=$(node packages/desktop/scripts/runtime-asset-name.mjs --manifest)" >> "$GITHUB_OUTPUT" + + - name: Check existing release assets + id: check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + ASSET: ${{ steps.names.outputs.asset }} + MANIFEST: ${{ steps.names.outputs.manifest }} + run: | + assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' || true)" + if printf '%s\n' "$assets" | grep -Fx "$ASSET" >/dev/null \ + && printf '%s\n' "$assets" | grep -Fx "$MANIFEST" >/dev/null; then + echo "missing=false" >> "$GITHUB_OUTPUT" + echo "Runtime asset already exists: $ASSET" + else + echo "missing=true" >> "$GITHUB_OUTPUT" + echo "Runtime asset missing: $ASSET or $MANIFEST" + fi + + - name: Setup Node.js + if: steps.check.outputs.missing == 'true' + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: packages/desktop/package-lock.json + + - name: Install uv + if: steps.check.outputs.missing == 'true' + uses: astral-sh/setup-uv@v3 + + - name: Install desktop dependencies + if: steps.check.outputs.missing == 'true' + run: npm ci --prefix packages/desktop --no-audit --no-fund + + - name: Prepare runtime resources + if: steps.check.outputs.missing == 'true' + env: + TARGET_OS: ${{ matrix.target_os }} + TARGET_ARCH: ${{ matrix.target_arch }} + GH_TOKEN: ${{ github.token }} + HERMES_SKIP_BROWSER_RUNTIME: ${{ matrix.skip_browser_runtime || 'false' }} + run: npm --prefix packages/desktop run prepare:runtime + + - name: Package runtime + if: steps.check.outputs.missing == 'true' + env: + TARGET_OS: ${{ matrix.target_os }} + TARGET_ARCH: ${{ matrix.target_arch }} + run: npm --prefix packages/desktop run package:runtime + + - name: Upload runtime assets to release + if: steps.check.outputs.missing == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + fail_on_unmatched_files: true + files: | + packages/desktop/release/runtime/${{ steps.names.outputs.asset }} + packages/desktop/release/runtime/${{ steps.names.outputs.asset }}.sha256 + packages/desktop/release/runtime/${{ steps.names.outputs.manifest }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..fcb57e9 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,46 @@ +name: Build and Push Docker Image to Docker Hub + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:latest + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.sha }} + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.event.release.tag_name || github.ref_name }} diff --git a/.github/workflows/npm-lockfile-check.yml b/.github/workflows/npm-lockfile-check.yml new file mode 100644 index 0000000..823e924 --- /dev/null +++ b/.github/workflows/npm-lockfile-check.yml @@ -0,0 +1,45 @@ +name: NPM Lockfile Check + +on: + push: + branches: + - main + paths: + - package.json + - package-lock.json + - .github/workflows/npm-lockfile-check.yml + pull_request: + branches: + - main + - base + paths: + - package.json + - package-lock.json + - .github/workflows/npm-lockfile-check.yml + +permissions: + contents: read + +concurrency: + group: npm-lockfile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: npm ci --ignore-scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify package-lock.json is in sync + run: npm ci --ignore-scripts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..6dc803b --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,52 @@ +name: Playwright + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: playwright-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: | + npm ci --ignore-scripts + npm rebuild node-pty + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml new file mode 100644 index 0000000..e1791dd --- /dev/null +++ b/.github/workflows/website-deploy.yml @@ -0,0 +1,89 @@ +name: Website + +on: + pull_request: + branches: + - main + - base + paths: + - packages/website/** + - packages/client/src/styles/variables.scss + - package.json + - package-lock.json + - tsconfig.website.json + - vite.config.website.ts + - .github/workflows/website-deploy.yml + workflow_run: + workflows: + - Publish Desktop Artifacts to Release + types: + - completed + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: website-${{ github.event.pull_request.number || github.event.workflow_run.id || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build website + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + if: github.event_name != 'workflow_run' + uses: actions/checkout@v4 + + - name: Checkout desktop release ref + if: github.event_name == 'workflow_run' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Type-check website + run: npx vue-tsc -p tsconfig.website.json --noEmit + + - name: Build website + run: npm run build:website + + - name: Prepare SSH + if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' + env: + WEBSITE_SSH_KEY: ${{ secrets.WEBSITE_SSH_KEY }} + WEBSITE_SSH_KNOWN_HOSTS: ${{ secrets.WEBSITE_SSH_KNOWN_HOSTS }} + run: | + test -n "$WEBSITE_SSH_KEY" + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$WEBSITE_SSH_KEY" > ~/.ssh/website_deploy_key + chmod 600 ~/.ssh/website_deploy_key + if [ -n "$WEBSITE_SSH_KNOWN_HOSTS" ]; then + printf '%s\n' "$WEBSITE_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + fi + + - name: Deploy website + if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' + env: + WEBSITE_SSH_USER: ${{ secrets.WEBSITE_SSH_USER }} + WEBSITE_SSH_PORT: ${{ secrets.WEBSITE_SSH_PORT }} + run: | + SSH_USER="${WEBSITE_SSH_USER:-root}" + SSH_PORT="${WEBSITE_SSH_PORT:-22}" + DEPLOY_DIR="/var/www/ekkolearnai.com/current" + SSH_CMD="ssh -i ~/.ssh/website_deploy_key -p ${SSH_PORT} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + $SSH_CMD "$SSH_USER@154.3.33.232" "mkdir -p '$DEPLOY_DIR' && find '$DEPLOY_DIR' -mindepth 1 -maxdepth 1 -exec rm -rf {} +" + tar -C dist/website -czf - . | $SSH_CMD "$SSH_USER@154.3.33.232" "tar -xzf - -C '$DEPLOY_DIR'" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16a7d9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +node_modules +dist +playwright-report +test-results +dist-ssr +coverage +__pycache__/ +*.py[cod] +server/dist +packages/server/dist +*.local +ROADMAP.md +pnpm-lock.yaml +pnpm-workspace.yaml +# Server data +packages/server/data/ +packages/server/node_modules/ +.hermes-web-ui/ +packages/desktop/dist/ +packages/desktop/release/ +packages/desktop/resources/python/ +packages/desktop/node_modules/ + +# Hermes config files (should be in user data directory, not project root) +config.yaml +.env +hermes_data/ +data/ +hermes-dependencies.md +# Large local media (use ThinkingIndicator.vue instead) +packages/client/src/assets/*.gif +packages/client/src/assets/*.mp4 +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.superpowers/ +CLAUDE.md +# Client source map artifacts +packages/client/src/**/*.js +.hermes/ diff --git a/.work/工作流状态.md b/.work/工作流状态.md new file mode 100644 index 0000000..8865f89 --- /dev/null +++ b/.work/工作流状态.md @@ -0,0 +1,83 @@ +# 工作流执行状态 + +## 当前进度 + +- **当前步骤**:步骤 11(提交) +- **状态**:✅ 已完成 +- **开始时间**:2026-06-02 15:08:00 +- **完成时间**:2026-06-02 15:10:00 +- **项目信息**: + - 项目名称:灵犀 (Lingxi) + - 项目目录:C:\Users\L1822\xinmi\hermes-web-ui + - 端口范围:11000, 11001, 11002 + - 状态文档:C:\Users\L1822\xinmi\hermes-web-ui\.work\工作流状态.md + +## 步骤执行记录 + +### 步骤 0:克隆项目 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:31:00 +- **说明**:项目已成功克隆到 C:\Users\L1822\xinmi\hermes-web-ui + +### 步骤 1:项目完整度评估 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:34:00 +- **说明**:项目类型识别为 Web Dashboard/AI UI,用户已选定名称:灵犀 (Lingxi) + +### 步骤 2:项目整体分析 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:37:00 +- **说明**:技术栈识别为 Vue 3 + Koa + SQLite,Monorepo 结构已确认。 + +### 步骤 3:Docker配置生成 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:42:00 +- **说明**:已生成 .env 配置文件,优化了 Docker 配置。 + +### 步骤 4:品牌与界面标准化定制 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:47:00 +- **说明**:已完成品牌替换(灵犀)、新增导航与页脚资源链接。 + +### 步骤 5:核心应用页面建设 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:51:00 +- **说明**:已创建并集成“关于灵犀”页面。 + +### 步骤 6:示例数据 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:54:00 +- **说明**:程序内置初始化逻辑,默认提供 admin 账户。 + +### 步骤 7:CORS配置检查 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 14:57:00 +- **说明**:已确认后端 CORS 配置为 *,并在 .env 中增加相关变量。 + +### 步骤 8:端口检查与分配 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 15:01:00 +- **说明**:已分配 11000 后的连续可用端口,并更新 .env。 + +### 步骤 9:构建与启动 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 15:06:00 +- **说明**:项目构建成功,并在 11000 端口成功启动。 + +### 步骤 10:健康验证 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 15:07:00 +- **说明**:健康检查通过,品牌验证通过。 + +### 步骤 11:提交 +- **状态**:✅ 已完成 +- **完成时间**:2026-06-02 15:10:00 +- **说明**:所有变更已提交至仓库。 + +## 错误记录 + +无 + +## 备注 + +无 diff --git a/.work/错误记录.md b/.work/错误记录.md new file mode 100644 index 0000000..2da12b3 --- /dev/null +++ b/.work/错误记录.md @@ -0,0 +1,5 @@ +# 错误记录 + +## 错误列表 + +(暂无错误) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3afb514 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Agent Map + +This file is a short map for coding agents. Keep detailed guidance in `docs/` +and keep this file small enough to fit into every task context. + +## First Reads + +- `DEVELOPMENT.md` - project commands, coding rules, test rules, and PR shape. +- `ARCHITECTURE.md` - package boundaries, data ownership, and runtime flow. +- `docs/harness/README.md` - how this repository is prepared for agent work. +- `docs/harness/validation.md` - which checks to run for each change type. +- `docs/harness/worktree-runbook.md` - isolated local dev and test setup. +- `docs/harness/pr-review.md` - self-review checklist before pushing. + +## Common Commands + +```bash +npm ci --ignore-scripts +npm run harness:check +npm run test +npm run test:e2e +npm run build +``` + +Use the smallest relevant check while iterating. Before a broad PR, run +`npm run harness:check`, `npm run test:coverage`, `npm run test:e2e`, and +`npm run build`. + +## Code Ownership Map + +- `packages/client/src` - Vue 3 client, stores, routes, i18n, API helpers. +- `packages/server/src` - Koa API, Socket.IO, persistence, Hermes integration. +- `packages/desktop` - Electron wrapper, bundled Python/Hermes runtime, release artifacts. +- `tests/client`, `tests/server`, `tests/shared` - Vitest coverage. +- `tests/e2e` - Playwright browser coverage with mocked backend services. +- `.github/workflows` - CI, release, Docker, and desktop packaging automation. + +## Hard Rules + +- Keep routes thin: put request handling in controllers and reusable behavior in services. +- Keep Web UI state under `HERMES_WEB_UI_HOME` or `HERMES_WEBUI_STATE_DIR`. +- Keep Hermes Agent state separate from Web UI state. +- Register local API routes before proxy catch-all routes. +- Use structured APIs and argument arrays instead of shell string construction. +- Add user-facing strings to every locale file. +- Do not mix unrelated refactors into a bug fix. + +## When The Agent Gets Stuck + +Improve the harness instead of repeating the same prompt. Add missing docs, +tests, logs, scripts, or CI checks so the next agent can see and verify the +constraint directly. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d6a7f5b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,91 @@ +# Architecture + +Hermes Web UI is a TypeScript monorepo that ships a browser dashboard, a Koa +backend, and an Electron desktop distribution around Hermes Agent. + +## Package Boundaries + +| Area | Path | Responsibility | +| --- | --- | --- | +| Client | `packages/client/src` | Vue UI, routing, Pinia stores, API wrappers, i18n, browser-visible state. | +| Server | `packages/server/src` | HTTP API, auth, Socket.IO, SQLite stores, file access, Hermes runtime integration. | +| Desktop | `packages/desktop` | Electron shell, local Web UI server bootstrap, updater, bundled Python/Hermes runtime. | +| Tests | `tests` | Vitest unit/integration tests and Playwright browser tests. | +| CI | `.github/workflows` | Build, e2e, lockfile, Docker, and desktop release automation. | + +## Request Flow + +1. The browser loads the Vite-built client from the Koa server. +2. Client modules call API helpers from `packages/client/src/api`. +3. Server routes in `packages/server/src/routes` wire HTTP paths to controllers. +4. Controllers validate request concerns and delegate reusable behavior to services. +5. Services own side effects: files, SQLite, Hermes profiles, subprocesses, bridges, and credentials. +6. Long-running chat and group-chat flows use Socket.IO namespaces managed by server services. + +Keep each layer narrow. Routes should not grow business logic, and client code +should not duplicate server persistence rules. + +## State And Data Ownership + +- Web UI state defaults to `~/.hermes-web-ui` through `config.appHome`. +- `HERMES_WEB_UI_HOME` and `HERMES_WEBUI_STATE_DIR` override Web UI state location. +- Hermes Agent state lives under Hermes profile directories and must stay distinct from Web UI state. +- Uploads default to `config.uploadDir`, which is derived from the Web UI home unless `UPLOAD_DIR` is set. +- Runtime data directories must also live under the Web UI home, not beside built `dist` assets. +- Profile-scoped Hermes data should use existing profile helpers instead of manually joining paths. + +## Server Structure + +- `routes/` registers HTTP and WebSocket entry points. +- `controllers/` handles request-level behavior. +- `services/` owns reusable IO, domain behavior, external process calls, and integration logic. +- `db/` owns SQLite schemas and stores. +- `middleware/` owns request middleware such as user auth. +- `shared/` contains cross-server constants and helpers. + +Architecture rules: + +- Register local API routes before proxy catch-all routes. +- Keep auth behavior centralized in `packages/server/src/services/auth.ts`. +- Prefer `execFile` or `spawn` with argument arrays over shell command strings. +- Use structured file and YAML/JSON parsers when editing structured data. + +## Client Structure + +- `views/` contains route-level screens. +- `components/` contains reusable UI. +- `stores/` contains Pinia state. +- `api/` contains HTTP clients and should use `packages/client/src/api/client.ts`. +- `i18n/` contains locale messages for user-facing strings. +- `styles/` contains global styling and theme primitives. + +Frontend rules: + +- Use Vue 3 Composition API with ` + + + +
+ + + + diff --git a/packages/client/public/coding-agents/claude-code.svg b/packages/client/public/coding-agents/claude-code.svg new file mode 100644 index 0000000..b4a2131 --- /dev/null +++ b/packages/client/public/coding-agents/claude-code.svg @@ -0,0 +1,7 @@ + + Claude Code + + \ No newline at end of file diff --git a/packages/client/public/coding-agents/codex-openai.png b/packages/client/public/coding-agents/codex-openai.png new file mode 100644 index 0000000..44fc39c Binary files /dev/null and b/packages/client/public/coding-agents/codex-openai.png differ diff --git a/packages/client/public/favicon.ico b/packages/client/public/favicon.ico new file mode 100644 index 0000000..dbc3f56 Binary files /dev/null and b/packages/client/public/favicon.ico differ diff --git a/packages/client/public/fonts/ComicNeue-Bold.ttf b/packages/client/public/fonts/ComicNeue-Bold.ttf new file mode 100644 index 0000000..d3f425f Binary files /dev/null and b/packages/client/public/fonts/ComicNeue-Bold.ttf differ diff --git a/packages/client/public/fonts/ComicNeue-Regular.ttf b/packages/client/public/fonts/ComicNeue-Regular.ttf new file mode 100644 index 0000000..cc41f02 Binary files /dev/null and b/packages/client/public/fonts/ComicNeue-Regular.ttf differ diff --git a/packages/client/public/fonts/Gaegu-Bold.ttf b/packages/client/public/fonts/Gaegu-Bold.ttf new file mode 100644 index 0000000..4e22d24 Binary files /dev/null and b/packages/client/public/fonts/Gaegu-Bold.ttf differ diff --git a/packages/client/public/fonts/Gaegu-Regular.ttf b/packages/client/public/fonts/Gaegu-Regular.ttf new file mode 100644 index 0000000..1e28823 Binary files /dev/null and b/packages/client/public/fonts/Gaegu-Regular.ttf differ diff --git a/packages/client/public/fonts/ZCOOLKuaiLe-Regular.ttf b/packages/client/public/fonts/ZCOOLKuaiLe-Regular.ttf new file mode 100644 index 0000000..3cf6cd9 Binary files /dev/null and b/packages/client/public/fonts/ZCOOLKuaiLe-Regular.ttf differ diff --git a/packages/client/public/fonts/ZenMaruGothic-Bold.ttf b/packages/client/public/fonts/ZenMaruGothic-Bold.ttf new file mode 100644 index 0000000..aeaf890 Binary files /dev/null and b/packages/client/public/fonts/ZenMaruGothic-Bold.ttf differ diff --git a/packages/client/public/fonts/ZenMaruGothic-Regular.ttf b/packages/client/public/fonts/ZenMaruGothic-Regular.ttf new file mode 100644 index 0000000..c622402 Binary files /dev/null and b/packages/client/public/fonts/ZenMaruGothic-Regular.ttf differ diff --git a/packages/client/public/icons.svg b/packages/client/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/packages/client/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/client/public/logo.png b/packages/client/public/logo.png new file mode 100644 index 0000000..451200c Binary files /dev/null and b/packages/client/public/logo.png differ diff --git a/packages/client/public/logo.svg b/packages/client/public/logo.svg new file mode 100644 index 0000000..c6522f3 --- /dev/null +++ b/packages/client/public/logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/client/public/skill-recommendations.en.md b/packages/client/public/skill-recommendations.en.md new file mode 100644 index 0000000..ca92ed1 --- /dev/null +++ b/packages/client/public/skill-recommendations.en.md @@ -0,0 +1,228 @@ +# Recommended Skills + +This page collects useful community skill repositories that can extend Hermes, Claude Code, Codex-style agents, and similar local agent workflows. + +Community skills are third-party code and instructions. Review them before installing, especially when a skill can read API keys, cookies, browser sessions, local files, repositories, shell scripts, package managers, or social media accounts. + +Useful skill recommendations are welcome. If you find a high-quality skill that should be listed here, please submit a pull request on GitHub with the repository link, usage scenario, and any security notes. + +## Maintenance Guidelines + +- Keep this document in English. Update `skill-recommendations.zh.md` separately for the Chinese version. +- Add recommendations under the closest existing category before creating a new category. +- Use the same structure for each item: repository link, focus, good-for scenarios, representative skills or capabilities when available, and notes when there are installation, API, or security concerns. +- Keep descriptions factual and concise. Prefer information confirmed from the repository README, `SKILL.md`, examples, or package metadata. +- Do not paste secrets, private tokens, install commands that auto-execute remote code, or unverifiable marketing claims. +- Put security-sensitive skills in context: mention when they can access credentials, browsers, local files, shells, package managers, external APIs, or social accounts. +- For unsupported locales, the UI falls back to this English document. + +## Security First + +- Treat every third-party skill as untrusted until reviewed. +- Read `SKILL.md`, scripts, hooks, and dependency installers before running. +- Be extra careful with skills that post to social media, access browsers, read credentials, install packages, execute shell commands, or send local files to external APIs. +- Prefer testing new skills in a disposable profile or sandboxed project first. +- Use a security review skill such as SlowMist Agent Security when evaluating unknown repositories, URLs, MCP servers, or skill packages. + +## Official And General-Purpose Skills + +### Anthropic Official Skills + +- Repository: [anthropics/skills](https://github.com/anthropics/skills/tree/main/skills) +- Focus: official reference skills for Claude-style agents. +- Good for: learning the expected skill structure, adapting stable examples, and bootstrapping common workflows. +- Representative skills: `docx`, `pdf`, `pptx`, `xlsx`, `frontend-design`, `webapp-testing`, `skill-creator`, `mcp-builder`, `theme-factory`, `web-artifacts-builder`. +- Notes: a good first source when you want conservative, well-structured examples. + +### Matt Pocock Skills + +- Repository: [mattpocock/skills](https://github.com/mattpocock/skills) +- Focus: engineering and productivity skills from a real development workflow. +- Good for: TypeScript engineering, test-driven work, triage, diagnosis, reviews, prototyping, and product handoff workflows. +- Representative skills: `tdd`, `triage`, `diagnose`, `prototype`, `review`, `to-prd`, `to-issues`, `handoff`, `write-a-skill`. +- Notes: useful when you want agent behavior that is direct, structured, and engineering-oriented. + +## Design, Slides, And Visual Work + +### Frontend Slides + +- Repository: [zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides) +- Focus: creating web-native slide decks with frontend techniques. +- Good for: HTML/CSS slide decks, visual storytelling, and browser-rendered presentations. +- Notes: useful when a deck should be designed as a rich web artifact rather than a traditional office file. + +### Huashu Design + +- Repository: [alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design) +- Focus: HTML-native design work for Claude Code and agent workflows. +- Good for: high-fidelity prototypes, slides, animation concepts, visual review, and export-oriented design flows. +- Notes: includes design philosophy, review heuristics, and presentation-oriented workflows. + +### Guizang PPT Skill + +- Repository: [op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill) +- Focus: polished HTML slide decks with editorial, magazine, and Swiss-style layouts. +- Good for: presentation decks, social covers, image prompts, and visual narrative work. +- Notes: includes a presentation runtime and style-oriented slide generation patterns. + +### HTML PPT Skill + +- Repository: [lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill) +- Focus: HTML PPT Studio for professional HTML presentations. +- Good for: themed slide decks, layout-rich presentations, and animated browser presentations. +- Representative capabilities: multiple themes, layout templates, animation patterns, and HTML presentation scaffolding. + +### PPT Image First + +- Repository: [NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first) +- Focus: image-first presentation generation. +- Good for: decks where the visual direction should lead the content structure. +- Notes: designed for Codex, Claude Code, and OpenCode-style CLI agents. + +### GPT Image To PPT + +- Repository: [JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills) +- Focus: cloning or adapting PowerPoint visual layouts using image generation. +- Good for: recreating a deck style from an existing `.pptx` template while replacing the actual content. +- Notes: useful for template-driven presentations, but review external image generation/API behavior before use. + +### Fireworks Tech Graph + +- Repository: [yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph) +- Focus: technical diagram generation. +- Good for: architecture diagrams, workflow charts, UML-style visuals, AI agent workflow diagrams, and production-ready SVG/PNG outputs. +- Notes: a practical choice when you need diagrams rather than full slide decks. + +### Diagram Skill + +- Repository: [312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md) +- Focus: diagram generation inside a broader Claude skill collection. +- Good for: generating structured diagrams, templates, and visual explanations. +- Notes: this is a direct skill file link, so review the surrounding `references`, `scripts`, and `templates` folders before installing. + +## Writing, Documents, And Knowledge Work + +### Huashu Markdown To HTML + +- Repository: [alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html) +- Focus: Markdown and HTML conversion pipelines. +- Good for: converting files or URLs to Markdown, turning Markdown into polished HTML, and converting HTML back to Markdown. +- Representative tools: MarkItDown, Pandoc, html-to-markdown, and trafilatura-based workflows. +- Notes: useful for content publishing, document cleanup, and HTML presentation pages. + +### Chinese Web Novel Skill + +- Repository: [Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill) +- Focus: Chinese web novel writing workflows. +- Good for: long-form fiction planning, chapter writing, style continuity, and web-novel oriented drafting. +- Representative skill: `webnovel-writing`. + +### Software Copyright Skill + +- Repository: [Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill) +- Focus: preparing Chinese software copyright application materials. +- Good for: generating `.docx` application documents from a local software project. +- Representative skills: `software-copyright-materials`, `docx-toolkit`. +- Notes: this may read local project files. Review file access and document generation behavior before running. + +### Patent Disclosure Skill + +- Repository: [handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill) +- Focus: patent disclosure drafting. +- Good for: extracting patentable points from project documents, novelty checks, desensitized drafting, and self-review loops. +- Notes: may involve web research and sensitive technical documents. Review data handling carefully. + +## Image, Media, And Social Publishing + +### Baoyu Skills + +- Repository: [JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills) +- Focus: image generation, content transformation, publishing, and media workflows. +- Good for: image cards, article illustrations, slide decks, URL-to-Markdown conversion, YouTube transcripts, Markdown-to-HTML, and social posting workflows. +- Representative skills: `baoyu-image-gen`, `baoyu-imagine`, `baoyu-slide-deck`, `baoyu-markdown-to-html`, `baoyu-post-to-x`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-url-to-markdown`, `baoyu-youtube-transcript`, `baoyu-translate`, `baoyu-diagram`, `baoyu-comic`. +- Security note: posting and web-reading skills may access account sessions, cookies, browser state, or external APIs. Review carefully before use. + +### Virtual Couple Travel Vlog + +- Repository: [vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog) +- Focus: travel-vlog style media generation. +- Good for: short-form visual storytelling, character-based travel content, and repeatable media production prompts. +- Notes: this is a subdirectory skill inside a larger skill collection. + +## Research, Web Access, And Content Monitoring + +### Web Access + +- Repository: [eze-is/web-access](https://github.com/eze-is/web-access) +- Focus: giving an agent structured web access through layered routing and browser/CDP workflows. +- Good for: web research, browser-assisted tasks, parallel information gathering, and pages that require interaction. +- Security note: browser access can expose logged-in sessions and local browser state. Audit before enabling. + +### OpenCLI + +- Repository: [jackwener/opencli](https://github.com/jackwener/opencli) +- Focus: converting websites, browser sessions, Electron apps, and local tools into CLI-accessible automation surfaces for humans and AI agents. +- Good for: letting agents operate logged-in Chrome pages, building reusable website adapters, wrapping local binaries, and turning browser workflows into deterministic commands. +- Representative skills: `opencli-browser`, `opencli-adapter-author`, `opencli-autofix`, `opencli-usage`. +- Security note: browser-backed commands can use logged-in sessions and local browser state. Review the extension, daemon, adapters, and any generated commands before enabling in sensitive profiles. + +### Follow Builders + +- Repository: [zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders) +- Focus: monitoring AI builders across X, blogs, and YouTube podcasts. +- Good for: tracking builders rather than influencers, summarizing feeds, and creating digest-style updates. +- Representative data/config files: X feeds, blog feeds, podcast feeds, prompts, and state files. +- Security note: any social or feed automation should be reviewed for account/session access. + +### SlowMist Agent Security + +- Repository: [slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security) +- Focus: security review for AI agents operating with untrusted inputs. +- Good for: checking skills, MCP servers, repositories, URLs, prompts, and crypto/on-chain addresses for security risks. +- Core idea: external input should be considered untrusted until verified. +- Notes: recommended before installing or running unfamiliar community skills. + +## Persona, Thinking, And Advisory Skills + +### Huashu Nuwa Skill + +- Repository: [alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill) +- Focus: distilling a person or viewpoint into a reusable agent skill. +- Good for: advisory-board style thinking, mental models, decision heuristics, and writing in a specific perspective. +- Representative perspectives: Huashu Nuwa, Feynman, Steve Jobs, Elon Musk, Naval Ravikant, Paul Graham, Nassim Taleb. +- Notes: useful for brainstorming and viewpoint simulation, not for factual authority. + +### PUA / Anti-PUA Skills + +- Repository: [tanweai/pua](https://github.com/tanweai/pua) +- Focus: high-agency, confrontational, coaching, or anti-PUA style agent behavior. +- Good for: motivation, critique, resistance to manipulation, and intentionally sharp agent feedback. +- Representative skills: `pua`, `pua-en`, `pua-ja`, `pua-loop`, `mama`, `p7`, `p9`, `p10`, `pro`, `shot`, `yes`. +- Notes: these skills intentionally change tone and interaction style. Review before enabling in shared or user-facing environments. + +### Ex Skill + +- Repository: [therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill) +- Focus: distilling an ex-partner/persona into an AI skill that speaks in that style. +- Good for: persona experiments, emotional roleplay, and style simulation. +- Representative skill: `create-ex`. +- Notes: use carefully. Persona skills can strongly alter tone and emotional framing. + +## Quick Shortlist + +If you only want a practical starter set: + +- [Anthropic Official Skills](https://github.com/anthropics/skills/tree/main/skills) for reference implementations. +- [Matt Pocock Skills](https://github.com/mattpocock/skills) for engineering workflows. +- [Baoyu Skills](https://github.com/JimLiu/baoyu-skills) for image, media, and publishing workflows. +- [Huashu Design](https://github.com/alchaincyf/huashu-design) for high-fidelity HTML-native design. +- [Guizang PPT Skill](https://github.com/op7418/guizang-ppt-skill) or [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill) for browser-based presentations. +- [Huashu Markdown To HTML](https://github.com/alchaincyf/huashu-md-html) for Markdown/HTML document conversion. +- [Web Access](https://github.com/eze-is/web-access) for web research workflows. +- [OpenCLI](https://github.com/jackwener/opencli) for logged-in browser automation and reusable website CLI adapters. +- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph) for technical diagrams. +- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security) for reviewing risky community skills. + +## Original Source List + +This document was compiled from a curated Hermes / Claude skill sharing list and expanded with public GitHub repository metadata. diff --git a/packages/client/public/skill-recommendations.zh.md b/packages/client/public/skill-recommendations.zh.md new file mode 100644 index 0000000..ce30899 --- /dev/null +++ b/packages/client/public/skill-recommendations.zh.md @@ -0,0 +1,228 @@ +# Skills 推荐清单 + +这是一份适合 Hermes、Claude Code、Codex 类本地 Agent 工作流的社区 Skill 推荐清单。它主要用于帮助你发现可安装、可参考或可改造的 Skill 来源。 + +社区 Skill 本质上是第三方指令和代码。安装前请先审计,尤其是会读取 API Key、Cookie、浏览器登录态、本地文件、仓库内容,或者会执行 shell、安装依赖、自动发帖、访问外部 API 的 Skill。 + +欢迎大家推荐各种好用的 Skill。如果你发现值得收录的高质量 Skill,可以到 GitHub 提交 PR,并附上仓库链接、适用场景和必要的安全说明。 + +## 维护规范 + +- 这份文档只维护中文内容;英文版请同步维护 `skill-recommendations.en.md`。 +- 新增推荐时优先放入最接近的现有分类,不要轻易新增大类。 +- 每个条目尽量保持同一结构:仓库链接、方向、适合场景、代表 Skills 或能力、必要备注。 +- 描述要简洁、事实化,优先依据仓库 README、`SKILL.md`、示例或包元数据,不写无法验证的宣传语。 +- 不要写入密钥、私有 token、会自动执行远程代码的安装命令,或无法确认来源的内容。 +- 涉及安全风险的 Skill 要明确说明上下文,例如是否会访问凭据、浏览器、本地文件、shell、包管理器、外部 API 或社交账号。 +- 前端只维护中文和英文两份推荐文档,其他语言统一回退到英文版。 + +## 安全优先 + +- 默认把所有第三方 Skill 当成不可信内容,审计后再启用。 +- 安装前阅读 `SKILL.md`、脚本、hooks、依赖安装逻辑和插件配置。 +- 对会访问浏览器、读取凭据、执行 shell、安装 npm/pip/brew 依赖、自动发帖或上传本地文件的 Skill 保持谨慎。 +- 建议先在一次性 profile 或沙盒项目里测试新 Skill。 +- 可以使用 SlowMist Agent Security 这类安全审计 Skill 来检查陌生仓库、URL、MCP、Skill 包和链上地址。 + +## 官方与通用 Skills + +### Anthropic 官方 Skills + +- 仓库:[anthropics/skills](https://github.com/anthropics/skills/tree/main/skills) +- 方向:Claude 官方参考 Skill。 +- 适合:学习标准 Skill 结构、参考稳定实现、搭建通用工作流。 +- 代表 Skills:`docx`、`pdf`、`pptx`、`xlsx`、`frontend-design`、`webapp-testing`、`skill-creator`、`mcp-builder`、`theme-factory`、`web-artifacts-builder`。 +- 备注:如果你想找保守、规范、可参考的 Skill 示例,优先看这个。 + +### Matt Pocock Skills + +- 仓库:[mattpocock/skills](https://github.com/mattpocock/skills) +- 方向:工程与生产力工作流。 +- 适合:TypeScript 工程、TDD、问题诊断、代码评审、原型开发、PRD/Issue/Handoff 等开发流程。 +- 代表 Skills:`tdd`、`triage`、`diagnose`、`prototype`、`review`、`to-prd`、`to-issues`、`handoff`、`write-a-skill`。 +- 备注:适合希望 Agent 更像工程协作者时使用。 + +## 设计、幻灯片与可视化 + +### Frontend Slides + +- 仓库:[zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides) +- 方向:用前端技术生成网页幻灯片。 +- 适合:HTML/CSS 幻灯片、视觉叙事、浏览器渲染的演示稿。 +- 备注:适合把演示稿当成 Web Artifact 来做,而不是传统 Office 文件。 + +### 华叔 Design + +- 仓库:[alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design) +- 方向:Claude Code 中的 HTML 原生设计 Skill。 +- 适合:高保真原型、幻灯片、动画概念、视觉评审和导出型设计流程。 +- 备注:包含设计哲学、评审维度和演示型工作流。 + +### 归藏 PPT Skill + +- 仓库:[op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill) +- 方向:生成高质量 HTML 幻灯片。 +- 适合:杂志风、编辑风、瑞士风等视觉风格的演示稿、社交封面、图片提示词和叙事型页面。 +- 备注:包含演示运行时和风格化生成模式。 + +### HTML PPT Skill + +- 仓库:[lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill) +- 方向:HTML PPT Studio。 +- 适合:主题化幻灯片、复杂布局演示稿和带动画的浏览器演示。 +- 代表能力:多主题、多布局、动画模式和 HTML 演示脚手架。 + +### PPT Image First + +- 仓库:[NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first) +- 方向:图片优先的 PPT 生成。 +- 适合:视觉方向先行的演示稿创作。 +- 备注:面向 Codex、Claude Code、OpenCode CLI 等 Agent 工作流。 + +### GPT Image To PPT + +- 仓库:[JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills) +- 方向:用图像生成能力复刻或改造 PPT 视觉版式。 +- 适合:从已有 `.pptx` 模板中学习版式,再替换成自己的内容。 +- 备注:涉及图像生成和外部 API 时请先检查配置与数据发送逻辑。 + +### Fireworks Tech Graph + +- 仓库:[yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph) +- 方向:技术图表生成。 +- 适合:架构图、流程图、UML 风格图、AI Agent 工作流图,以及 SVG/PNG 输出。 +- 备注:需要图表而不是整套演示稿时很实用。 + +### Diagram Skill + +- 仓库:[312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md) +- 方向:结构化图表生成。 +- 适合:生成图表、模板化视觉解释和技术说明。 +- 备注:这是一个直接指向 `SKILL.md` 的链接,安装前也要检查同目录下的 `references`、`scripts` 和 `templates`。 + +## 写作、文档与知识工作 + +### 华叔 Markdown To HTML + +- 仓库:[alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html) +- 方向:Markdown 与 HTML 双向转换流水线。 +- 适合:把文件或网页转 Markdown,把 Markdown 转精美 HTML,把 HTML 再转回 Markdown。 +- 代表工具:MarkItDown、Pandoc、html-to-markdown、trafilatura。 +- 备注:适合内容发布、文档清理和 HTML 页面生成。 + +### 中文网文写作 Skill + +- 仓库:[Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill) +- 方向:中文网文小说写作。 +- 适合:长篇小说规划、章节创作、风格延续和网文式叙事。 +- 代表 Skill:`webnovel-writing`。 + +### 软件著作权材料 Skill + +- 仓库:[Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill) +- 方向:中国软件著作权申请材料生成。 +- 适合:根据本地项目生成 `.docx` 软著申请材料。 +- 代表 Skills:`software-copyright-materials`、`docx-toolkit`。 +- 备注:可能读取本地项目文件,运行前请审计文件访问和文档生成逻辑。 + +### 专利交底书 Skill + +- 仓库:[handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill) +- 方向:专利技术交底书生成。 +- 适合:从项目文档挖掘专利点、联网查新、脱敏成文和自检。 +- 备注:可能涉及敏感技术资料和联网检索,使用前请关注数据处理方式。 + +## 图片、媒体与社交发布 + +### 宝玉 Skills + +- 仓库:[JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills) +- 方向:图片生成、内容转换、发布和媒体工作流。 +- 适合:图片卡片、文章配图、幻灯片、URL 转 Markdown、YouTube 字幕、Markdown 转 HTML、社交平台发布。 +- 代表 Skills:`baoyu-image-gen`、`baoyu-imagine`、`baoyu-slide-deck`、`baoyu-markdown-to-html`、`baoyu-post-to-x`、`baoyu-post-to-wechat`、`baoyu-post-to-weibo`、`baoyu-url-to-markdown`、`baoyu-youtube-transcript`、`baoyu-translate`、`baoyu-diagram`、`baoyu-comic`。 +- 安全提示:发帖和网页读取类 Skill 可能访问账号会话、Cookie、浏览器状态或外部 API,使用前务必审计。 + +### Virtual Couple Travel Vlog + +- 仓库:[vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog) +- 方向:旅行 vlog 风格媒体生成。 +- 适合:短视频视觉叙事、角色化旅行内容和可复用媒体提示词。 +- 备注:这是一个大仓库里的子目录 Skill。 + +## Web 访问、研究与内容监控 + +### Web Access + +- 仓库:[eze-is/web-access](https://github.com/eze-is/web-access) +- 方向:为 Agent 提供结构化联网能力。 +- 适合:网页研究、浏览器辅助任务、并行信息收集和需要交互的网站。 +- 安全提示:浏览器访问可能暴露已登录状态和本地浏览器数据,启用前要审计。 + +### OpenCLI + +- 仓库:[jackwener/opencli](https://github.com/jackwener/opencli) +- 方向:把网站、浏览器会话、Electron 应用和本地工具转换成 CLI 可调用的自动化入口。 +- 适合:让 Agent 操作已登录的 Chrome 页面、编写可复用网站适配器、封装本地命令,以及把浏览器流程变成稳定命令。 +- 代表 Skills:`opencli-browser`、`opencli-adapter-author`、`opencli-autofix`、`opencli-usage`。 +- 安全提示:浏览器命令可能使用已登录会话和本地浏览器状态。启用前请审计扩展、daemon、适配器和生成的命令,敏感 profile 里尤其要谨慎。 + +### Follow Builders + +- 仓库:[zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders) +- 方向:跟踪 AI builders 的 X、博客和 YouTube 播客内容。 +- 适合:关注 builder 而不是 influencer,生成摘要和内容 digest。 +- 代表内容:X feed、blog feed、podcast feed、prompts 和状态文件。 +- 安全提示:社交和 feed 自动化要关注账号、Cookie 和访问权限。 + +### SlowMist Agent Security + +- 仓库:[slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security) +- 方向:AI Agent 安全审计框架。 +- 适合:检查 Skill、MCP、仓库、URL、Prompt 和链上地址的安全风险。 +- 核心原则:所有外部输入在验证前都不可信。 +- 备注:安装陌生社区 Skill 前建议优先使用。 + +## Persona、思维方式与顾问类 Skills + +### 华叔 Nuwa Skill + +- 仓库:[alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill) +- 方向:把某个人或视角蒸馏成可复用 Skill。 +- 适合:顾问团式思考、心智模型、决策启发式和特定视角写作。 +- 代表视角:华叔 Nuwa、Feynman、Jobs、Musk、Naval、Paul Graham、Taleb。 +- 备注:适合头脑风暴和视角模拟,不应当作事实权威。 + +### PUA / 反 PUA 类 Skills + +- 仓库:[tanweai/pua](https://github.com/tanweai/pua) +- 方向:高能动性、强反馈、反操控或尖锐教练风格的 Agent 行为。 +- 适合:动机强化、批判反馈、反操控和刻意强风格交互。 +- 代表 Skills:`pua`、`pua-en`、`pua-ja`、`pua-loop`、`mama`、`p7`、`p9`、`p10`、`pro`、`shot`、`yes`。 +- 备注:这类 Skill 会明显改变语气和互动方式,不建议直接用于共享或面向用户的环境。 + +### Ex Skill + +- 仓库:[therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill) +- 方向:把某个前任/人格风格蒸馏成 AI Skill。 +- 适合:Persona 实验、情绪化角色扮演和特定语气模拟。 +- 代表 Skill:`create-ex`。 +- 备注:Persona 类 Skill 可能强烈影响语气和情绪框架,使用前请确认场景合适。 + +## 快速推荐 + +如果你只想先装一批实用的,可以从这些开始: + +- [Anthropic 官方 Skills](https://github.com/anthropics/skills/tree/main/skills):参考实现和通用能力。 +- [Matt Pocock Skills](https://github.com/mattpocock/skills):工程流程。 +- [宝玉 Skills](https://github.com/JimLiu/baoyu-skills):图片、媒体和发布。 +- [华叔 Design](https://github.com/alchaincyf/huashu-design):高保真 HTML 设计。 +- [归藏 PPT Skill](https://github.com/op7418/guizang-ppt-skill) 或 [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill):浏览器演示稿。 +- [华叔 Markdown To HTML](https://github.com/alchaincyf/huashu-md-html):Markdown/HTML 文档转换。 +- [Web Access](https://github.com/eze-is/web-access):网页研究。 +- [OpenCLI](https://github.com/jackwener/opencli):已登录浏览器自动化和可复用网站 CLI 适配器。 +- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph):技术图表。 +- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security):社区 Skill 安全审计。 + +## 来源说明 + +本文档基于一份 Hermes / Claude Skills 分享清单整理,并补充了公开 GitHub 仓库描述与目录信息。 diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue new file mode 100644 index 0000000..999e6b0 --- /dev/null +++ b/packages/client/src/App.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/packages/client/src/api/auth.ts b/packages/client/src/api/auth.ts new file mode 100644 index 0000000..dee1d07 --- /dev/null +++ b/packages/client/src/api/auth.ts @@ -0,0 +1,160 @@ +import { request } from './client' + +export interface AuthStatus { + hasPasswordLogin: boolean + hasUsers?: boolean +} + +export async function fetchAuthStatus(): Promise { + const res = await fetch('/api/auth/status') + if (!res.ok) throw new Error('Failed to fetch auth status') + return res.json() +} + +export async function loginWithPassword(username: string, password: string): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + const err: any = new Error(data.error || 'Login failed') + err.status = res.status + throw err + } + const data = await res.json() + return data.token +} + +export interface CurrentUser { + id: number + username: string + role: UserRole + status: UserStatus + created_at: number + updated_at: number + last_login_at: number | null + requiresCredentialChange?: boolean +} + +export async function fetchCurrentUser(): Promise { + const res = await request<{ user: CurrentUser }>('/api/auth/me') + return res.user +} + +export async function setupPassword(username: string, password: string): Promise { + return request('/api/auth/setup', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) +} + +export async function changePassword(currentPassword: string, newPassword: string): Promise { + return request('/api/auth/change-password', { + method: 'POST', + body: JSON.stringify({ currentPassword, newPassword }), + }) +} + +export async function changeUsername(currentPassword: string, newUsername: string): Promise { + return request('/api/auth/change-username', { + method: 'POST', + body: JSON.stringify({ currentPassword, newUsername }), + }) +} + +export async function removePassword(): Promise { + return request('/api/auth/password', { + method: 'DELETE', + }) +} + +export type UserRole = 'super_admin' | 'admin' +export type UserStatus = 'active' | 'disabled' + +export interface ManagedUser { + id: number + username: string + role: UserRole + status: UserStatus + profiles: string[] + default_profile: string | null + created_at: number + updated_at: number + last_login_at: number | null +} + +export interface ManagedUsersResponse { + users: ManagedUser[] + profiles: string[] +} + +export async function fetchManagedUsers(): Promise { + return request('/api/auth/users') +} + +export async function createManagedUser(input: { + username: string + password: string + role: UserRole + status: UserStatus + profiles: string[] + defaultProfile?: string | null +}): Promise { + const res = await request<{ users: ManagedUser[] }>('/api/auth/users', { + method: 'POST', + body: JSON.stringify(input), + }) + const current = await fetchManagedUsers() + return { ...current, users: res.users } +} + +export async function updateManagedUser(id: number, input: { + username?: string + password?: string + role?: UserRole + status?: UserStatus + profiles?: string[] + defaultProfile?: string | null +}): Promise { + const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }) + const current = await fetchManagedUsers() + return { ...current, users: res.users } +} + +export async function deleteManagedUser(id: number): Promise { + const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, { + method: 'DELETE', + }) + const current = await fetchManagedUsers() + return { ...current, users: res.users } +} + +export interface LockedIp { + ip: string + type: 'password' | 'token' + failures: number + lockedUntil: number +} + +export async function fetchLockedIps(): Promise { + const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips') + return res.locks +} + +export async function unlockSpecificIp(ip: string): Promise { + return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, { + method: 'DELETE', + }) +} + +export async function unlockAllIps(): Promise { + const res = await request<{ count: number }>('/api/auth/locked-ips', { + method: 'DELETE', + }) + return res.count +} diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts new file mode 100644 index 0000000..029a447 --- /dev/null +++ b/packages/client/src/api/client.ts @@ -0,0 +1,153 @@ +import router from '@/router' + +const DEFAULT_BASE_URL = '' + +function isDesktopShell(): boolean { + return typeof window !== 'undefined' && + (window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true +} + +function getBaseUrl(): string { + if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL + if (isDesktopShell()) return DEFAULT_BASE_URL + return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL +} + +export function getApiKey(): string { + return localStorage.getItem('hermes_api_key') || '' +} + +export function setServerUrl(url: string) { + localStorage.setItem('hermes_server_url', url) +} + +export function setApiKey(key: string) { + localStorage.setItem('hermes_api_key', key) +} + +export function clearApiKey() { + localStorage.removeItem('hermes_api_key') +} + +export function hasApiKey(): boolean { + return !!getApiKey() +} + +export type StoredUserRole = 'super_admin' | 'admin' + +export function getStoredUserRole(): StoredUserRole | null { + const token = getApiKey() + const payload = token.split('.')[1] + if (!payload) return null + try { + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + const data = JSON.parse(atob(padded)) as { role?: unknown } + return data.role === 'super_admin' || data.role === 'admin' ? data.role : null + } catch { + return null + } +} + +export function isStoredSuperAdmin(): boolean { + return getStoredUserRole() === 'super_admin' +} + +export function getActiveProfileName(): string | null { + return localStorage.getItem('hermes_active_profile_name') +} + +function bodyHasProfileSelector(body: BodyInit | null | undefined): boolean { + if (typeof body !== 'string') return false + try { + const parsed = JSON.parse(body) as { profile?: unknown } + return typeof parsed?.profile === 'string' && parsed.profile.trim().length > 0 + } catch { + return false + } +} + +function shouldAttachProfileHeader(path: string, options: RequestInit): boolean { + try { + const url = new URL(path, 'http://hermes.local') + if (url.searchParams.has('profile')) return false + if (url.pathname.startsWith('/api/hermes/profiles')) return false + if (isProfileWideSessionCollection(url.pathname)) return false + } catch { + if (path.startsWith('/api/hermes/profiles')) return false + if (isProfileWideSessionCollection(path.split('?')[0] || path)) return false + } + return !bodyHasProfileSelector(options.body) +} + +function isProfileWideSessionCollection(pathname: string): boolean { + return pathname === '/api/hermes/sessions' || + pathname === '/api/hermes/sessions/batch-delete' || + pathname === '/api/hermes/search/sessions' || + pathname === '/api/hermes/sessions/search' || + pathname === '/api/hermes/sessions/conversations' +} + +function emitAuthNotice(kind: 'expired' | 'forbidden') { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } })) +} + +export async function request(path: string, options: RequestInit = {}): Promise { + const base = getBaseUrl() + const url = `${base}${path}` + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers as Record, + } + + const apiKey = getApiKey() + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}` + } + + // Inject active profile header for request-scoped endpoints. Explicit profile + // selectors in the URL/body and profile-name routes are validated directly. + const profileName = getActiveProfileName() + if (profileName && shouldAttachProfileHeader(path, options)) { + headers['X-Hermes-Profile'] = profileName + } + + const res = await fetch(url, { ...options, headers }) + + // Global 401 handler — only redirect to login for local BFF endpoints + // Proxied gateway requests should not trigger logout + const isLocalBff = !path.startsWith('/api/hermes/v1/') && + !path.startsWith('/v1/') + + if (res.status === 401 && isLocalBff) { + clearApiKey() + emitAuthNotice('expired') + if (router.currentRoute.value.name !== 'login') { + router.replace({ name: 'login' }) + } + throw new Error('Unauthorized') + } + + if (!res.ok) { + const text = await res.text().catch(() => '') + if (res.status === 403 && isLocalBff) { + if (text.includes('User is disabled or does not exist')) { + clearApiKey() + emitAuthNotice('expired') + if (router.currentRoute.value.name !== 'login') { + router.replace({ name: 'login' }) + } + } else { + emitAuthNotice('forbidden') + } + } + throw new Error(`API Error ${res.status}: ${text || res.statusText}`) + } + + return res.json() +} + +export function getBaseUrlValue(): string { + return getBaseUrl() +} diff --git a/packages/client/src/api/coding-agents.ts b/packages/client/src/api/coding-agents.ts new file mode 100644 index 0000000..5d9614c --- /dev/null +++ b/packages/client/src/api/coding-agents.ts @@ -0,0 +1,134 @@ +import { request } from './client' + +export type CodingAgentId = 'claude-code' | 'codex' +export type CodingAgentApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' +export type CodingAgentLaunchMode = 'scoped' | 'global' + +export interface CodingAgentToolStatus { + id: CodingAgentId + name: string + provider: string + command: string + packageName: string + installed: boolean + version: string + rawVersion: string + error?: string +} + +export interface CodingAgentsStatus { + tools: CodingAgentToolStatus[] +} + +export interface CodingAgentMutationResult extends CodingAgentsStatus { + success: boolean + tool: CodingAgentToolStatus + message?: string + code?: string +} + +export interface CodingAgentConfigFileContent { + key: string + path: string + absolutePath: string + language: string + content: string + exists: boolean + size: number + profile: string + provider: string + rootDir: string +} + +export interface CodingAgentConfigScope { + profile?: string | null + provider?: string | null +} + +export interface CodingAgentLaunchRequest { + mode?: CodingAgentLaunchMode + profile?: string | null + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: CodingAgentApiMode +} + +export interface CodingAgentLaunchResult { + agentId: CodingAgentId + mode: CodingAgentLaunchMode + profile: string + provider: string + model: string + rootDir: string + workspaceDir: string + command: string + args: string[] + env: Record + shellCommand: string + files: Array<{ key: string; path: string; absolutePath: string }> +} + +export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { + nativeTerminal: true + terminal: string +} + +export async function fetchCodingAgentsStatus(): Promise { + return request('/api/coding-agents') +} + +export async function installCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}/install`, { method: 'POST' }) +} + +export async function deleteCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}`, { method: 'DELETE' }) +} + +export async function readCodingAgentConfigFile( + id: CodingAgentId, + key: string, + scope: CodingAgentConfigScope = {}, +): Promise { + const params = new URLSearchParams() + if (scope.profile) params.set('profile', scope.profile) + if (scope.provider) params.set('provider', scope.provider) + const query = params.toString() + return request( + `/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`, + ) +} + +export async function writeCodingAgentConfigFile( + id: CodingAgentId, + key: string, + content: string, + scope: CodingAgentConfigScope = {}, +): Promise { + return request(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }), + }) +} + +export async function prepareCodingAgentLaunch( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/prepare`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function launchCodingAgentNativeTerminal( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/native`, { + method: 'POST', + body: JSON.stringify(data), + }) +} diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts new file mode 100644 index 0000000..a5e0d1b --- /dev/null +++ b/packages/client/src/api/hermes/chat.ts @@ -0,0 +1,870 @@ +import { io, type Socket } from 'socket.io-client' +import { getBaseUrlValue, getApiKey } from '../client' + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; name: string; path: string; media_type: string } + | { type: 'file'; name: string; path: string; media_type?: string } + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string | ContentBlock[] +} + +export interface StartRunRequest { + input: string | ContentBlock[] + instructions?: string + session_id?: string + profile?: string + model?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + queue_id?: string + source?: 'api_server' | 'cli' +} + +export interface StartRunResponse { + run_id: string + status: string +} + +// SSE event types from /v1/runs/{id}/events +export interface RunEvent { + event: string + run_id?: string + delta?: string + /** Payload text for `reasoning.delta` / `thinking.delta` / `reasoning.available` events. */ + text?: string + tool?: string + name?: string + preview?: string + timestamp?: number + error?: string + /** Final response text on `run.completed`. May be empty/null if the agent + * silently swallowed an upstream error — see chat store for fallback. */ + output?: string | null + usage?: { + input_tokens: number + output_tokens: number + total_tokens: number + } + /** session_id tag added by server for client-side filtering */ + session_id?: string + /** Queue length from run.queued event */ + queue_length?: number + /** Queue item that was just removed because it is starting now. */ + dequeued_queue_id?: string + /** Queued user messages from run.queued/resume payloads. */ + queued_messages?: Array<{ + id?: string | number + role?: string + content?: string + timestamp?: number + queued?: boolean + }> + /** User message broadcast to other windows already watching the same session. */ + message?: { + id?: string | number + role?: string + content?: string + timestamp?: number + queued?: boolean + } +} + +export interface ResumeSessionPayload { + session_id: string + messages: any[] + messageTotal?: number + messageLoadedCount?: number + messagePageLimit?: number + hasMoreBefore?: boolean + isWorking: boolean + isAborting?: boolean + events: Array<{ event: string; data: RunEvent }> + inputTokens?: number + outputTokens?: number + contextTokens?: number + queueLength?: number + queueMessages?: RunEvent['queued_messages'] +} + +// ============================ +// Socket.IO chat run connection +// ============================ + +let chatRunSocket: Socket | null = null +let globalListenersRegistered = false +let chatRunSocketProfile: string | null = null + +const TRANSIENT_DISCONNECT_REASONS = new Set([ + 'transport close', + 'transport error', + 'ping timeout', +]) + +/** + * Session event handlers map + * Maps session_id to event handling functions for isolating concurrent session streams + */ +const sessionEventHandlers = new Map void + onReasoningDelta: (event: RunEvent) => void + onThinkingDelta: (event: RunEvent) => void + onReasoningAvailable: (event: RunEvent) => void + onToolStarted: (event: RunEvent) => void + onToolCompleted: (event: RunEvent) => void + onSubagentEvent?: (event: RunEvent) => void + onRunStarted: (event: RunEvent) => void + onRunCompleted: (event: RunEvent) => void + onRunFailed: (event: RunEvent) => void + onCompressionStarted: (event: RunEvent) => void + onCompressionCompleted: (event: RunEvent) => void + onAbortStarted: (event: RunEvent) => void + onAbortCompleted: (event: RunEvent) => void + onUsageUpdated: (event: RunEvent) => void + onAgentEvent?: (event: RunEvent) => void + onSessionCommand?: (event: RunEvent) => void + onRunQueued?: (event: RunEvent) => void + onApprovalRequested?: (event: RunEvent) => void + onApprovalResolved?: (event: RunEvent) => void + onPeerUserMessage?: (event: RunEvent) => void + onClarifyRequested?: (event: RunEvent) => void + onClarifyResolved?: (event: RunEvent) => void +}>() + +const peerUserMessageHandlers = new Set<(event: RunEvent) => void>() +const sessionCommandHandlers = new Set<(event: RunEvent) => void>() + +/** + * Global message.delta event handler + * Distributes events to appropriate session based on session_id + */ +function globalMessageDeltaHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onMessageDelta) { + handlers.onMessageDelta(event) + } +} + +/** + * Global reasoning.delta event handler + */ +function globalReasoningDeltaHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onReasoningDelta) { + handlers.onReasoningDelta(event) + } +} + +/** + * Global thinking.delta event handler (alias for reasoning.delta) + */ +function globalThinkingDeltaHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onThinkingDelta) { + handlers.onThinkingDelta(event) + } +} + +/** + * Global reasoning.available event handler + */ +function globalReasoningAvailableHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onReasoningAvailable) { + handlers.onReasoningAvailable(event) + } +} + +/** + * Global tool.started event handler + */ +function globalToolStartedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onToolStarted) { + handlers.onToolStarted(event) + } +} + +/** + * Global tool.completed event handler + */ +function globalToolCompletedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onToolCompleted) { + handlers.onToolCompleted(event) + } +} + +function globalSubagentEventHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onSubagentEvent) { + handlers.onSubagentEvent(event) + } +} + +/** + * Global run.started event handler + */ +function globalRunStartedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onRunStarted) { + handlers.onRunStarted(event) + } +} + +/** + * Global run.completed event handler + */ +function globalRunCompletedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onRunCompleted) { + handlers.onRunCompleted(event) + } + + // Auto-cleanup session handlers on completion (skip if more runs queued) + if ((event as any).queue_remaining > 0) return + sessionEventHandlers.delete(sid) +} + +/** + * Global run.failed event handler + */ +function globalRunFailedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onRunFailed) { + handlers.onRunFailed(event) + } + + // Auto-cleanup session handlers on failure (skip if more runs queued) + if ((event as any).queue_remaining > 0) return + sessionEventHandlers.delete(sid) +} + +/** + * Global run.queued event handler + */ +function globalRunQueuedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onRunQueued) { + handlers.onRunQueued(event) + } +} + +/** + * Global compression.started event handler + */ +function globalCompressionStartedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onCompressionStarted) { + handlers.onCompressionStarted(event) + } +} + +/** + * Global compression.completed event handler + */ +function globalCompressionCompletedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onCompressionCompleted) { + handlers.onCompressionCompleted(event) + } +} + +/** + * Global abort.started event handler + */ +function globalAbortStartedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onAbortStarted) { + handlers.onAbortStarted(event) + } +} + +/** + * Global abort.completed event handler + */ +function globalAbortCompletedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onAbortCompleted) { + handlers.onAbortCompleted(event) + } + + // If abort completion is followed by queued runs, keep the handler alive so + // the next run.started/message.delta/run.completed events are still received. + if ((event as any).queue_length > 0) return + sessionEventHandlers.delete(sid) +} + +/** + * Global usage.updated event handler + */ +function globalUsageUpdatedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onUsageUpdated) { + handlers.onUsageUpdated(event) + } +} + +function globalSessionCommandHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onSessionCommand) { + handlers.onSessionCommand(event) + } + + for (const handler of sessionCommandHandlers) { + handler(event) + } +} + +function globalAgentEventHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onAgentEvent) { + handlers.onAgentEvent(event) + } +} + +function globalApprovalRequestedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onApprovalRequested) { + handlers.onApprovalRequested(event) + } +} + +function globalApprovalResolvedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onApprovalResolved) { + handlers.onApprovalResolved(event) + } +} + +function globalPeerUserMessageHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onPeerUserMessage) { + handlers.onPeerUserMessage(event) + } + + for (const handler of peerUserMessageHandlers) { + handler(event) + } +} + +function globalClarifyRequestedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onClarifyRequested) { + handlers.onClarifyRequested(event) + } +} + +function globalClarifyResolvedHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onClarifyResolved) { + handlers.onClarifyResolved(event) + } +} + +/** + * Register event handlers for a session + * @param sessionId - Session ID + * @param handlers - Event handling functions + * @returns Cleanup function to unregister handlers + */ +export function registerSessionHandlers( + sessionId: string, + handlers: { + onMessageDelta: (event: RunEvent) => void + onReasoningDelta: (event: RunEvent) => void + onThinkingDelta: (event: RunEvent) => void + onReasoningAvailable: (event: RunEvent) => void + onToolStarted: (event: RunEvent) => void + onToolCompleted: (event: RunEvent) => void + onSubagentEvent?: (event: RunEvent) => void + onRunStarted: (event: RunEvent) => void + onRunCompleted: (event: RunEvent) => void + onRunFailed: (event: RunEvent) => void + onCompressionStarted: (event: RunEvent) => void + onCompressionCompleted: (event: RunEvent) => void + onAbortStarted: (event: RunEvent) => void + onAbortCompleted: (event: RunEvent) => void + onUsageUpdated: (event: RunEvent) => void + onAgentEvent?: (event: RunEvent) => void + onSessionCommand?: (event: RunEvent) => void + onRunQueued?: (event: RunEvent) => void + onApprovalRequested?: (event: RunEvent) => void + onApprovalResolved?: (event: RunEvent) => void + onPeerUserMessage?: (event: RunEvent) => void + onClarifyRequested?: (event: RunEvent) => void + onClarifyResolved?: (event: RunEvent) => void + } +): () => void { + sessionEventHandlers.set(sessionId, handlers) + + // Return cleanup function + return () => { + sessionEventHandlers.delete(sessionId) + } +} + +/** + * Unregister event handlers for a session + * @param sessionId - Session ID + */ +export function unregisterSessionHandlers(sessionId: string): void { + sessionEventHandlers.delete(sessionId) +} + +export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void { + peerUserMessageHandlers.add(handler) + return () => { + peerUserMessageHandlers.delete(handler) + } +} + +export function onSessionCommand(handler: (event: RunEvent) => void): () => void { + sessionCommandHandlers.add(handler) + return () => { + sessionCommandHandlers.delete(handler) + } +} + +export function respondClarify( + sessionId: string, + clarifyId: string, + response: string, +): void { + const socket = connectChatRun() + socket.emit('clarify.respond', { + session_id: sessionId, + clarify_id: clarifyId, + response, + }) +} + +export function respondToolApproval( + sessionId: string, + approvalId: string, + choice: 'once' | 'session' | 'always' | 'deny', +): void { + const socket = connectChatRun() + socket.emit('approval.respond', { + session_id: sessionId, + approval_id: approvalId, + choice, + }) +} + +export function getChatRunSocket(): Socket | null { + return chatRunSocket +} + +export function connectChatRun(requestedProfile?: string | null): Socket { + const normalizedRequestedProfile = requestedProfile?.trim() || null + if (chatRunSocket?.connected && (!normalizedRequestedProfile || chatRunSocketProfile === normalizedRequestedProfile)) { + return chatRunSocket + } + + // Clean up old socket to prevent duplicate event listeners + if (chatRunSocket) { + chatRunSocket.removeAllListeners() + chatRunSocket.disconnect() + globalListenersRegistered = false + chatRunSocketProfile = null + } + + const baseUrl = getBaseUrlValue() + const token = getApiKey() + + // Get active profile from store (authoritative source) + let profile = normalizedRequestedProfile || 'default' + try { + if (!normalizedRequestedProfile) { + const { useProfilesStore } = require('@/stores/hermes/profiles') + const profilesStore = useProfilesStore() + profile = profilesStore.activeProfileName || 'default' + } + } catch { + // Fallback to localStorage during early initialization + profile = normalizedRequestedProfile || localStorage.getItem('hermes_active_profile_name') || 'default' + } + chatRunSocketProfile = profile + + chatRunSocket = io(`${baseUrl}/chat-run`, { + auth: { token }, + query: { profile }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + randomizationFactor: 0.5, + timeout: 30000, + }) + + // Register global listeners only once per socket connection + if (!globalListenersRegistered) { + // Message events + chatRunSocket.on('message.delta', globalMessageDeltaHandler) + chatRunSocket.on('reasoning.delta', globalReasoningDeltaHandler) + chatRunSocket.on('thinking.delta', globalThinkingDeltaHandler) + chatRunSocket.on('reasoning.available', globalReasoningAvailableHandler) + + // Tool events + chatRunSocket.on('tool.started', globalToolStartedHandler) + chatRunSocket.on('tool.completed', globalToolCompletedHandler) + chatRunSocket.on('subagent.start', globalSubagentEventHandler) + chatRunSocket.on('subagent.tool', globalSubagentEventHandler) + chatRunSocket.on('subagent.progress', globalSubagentEventHandler) + chatRunSocket.on('subagent.complete', globalSubagentEventHandler) + + // Run lifecycle events + chatRunSocket.on('run.started', globalRunStartedHandler) + chatRunSocket.on('run.failed', globalRunFailedHandler) + chatRunSocket.on('run.completed', globalRunCompletedHandler) + chatRunSocket.on('run.queued', globalRunQueuedHandler) + chatRunSocket.on('approval.requested', globalApprovalRequestedHandler) + chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler) + chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler) + chatRunSocket.on('clarify.requested', globalClarifyRequestedHandler) + chatRunSocket.on('clarify.resolved', globalClarifyResolvedHandler) + + // Compression events + chatRunSocket.on('compression.started', globalCompressionStartedHandler) + chatRunSocket.on('compression.completed', globalCompressionCompletedHandler) + chatRunSocket.on('abort.started', globalAbortStartedHandler) + chatRunSocket.on('abort.completed', globalAbortCompletedHandler) + + // Usage events + chatRunSocket.on('usage.updated', globalUsageUpdatedHandler) + chatRunSocket.on('agent.event', globalAgentEventHandler) + chatRunSocket.on('session.command', globalSessionCommandHandler) + + globalListenersRegistered = true + } + + return chatRunSocket +} + +export function disconnectChatRun(): void { + if (chatRunSocket) { + chatRunSocket.disconnect() + chatRunSocket = null + chatRunSocketProfile = null + globalListenersRegistered = false + sessionEventHandlers.clear() + } +} + +function removeSocketListener(socket: Socket, event: string, handler: (...args: any[]) => void): void { + const candidate = socket as Socket & { + off?: (event: string, handler: (...args: any[]) => void) => Socket + removeListener?: (event: string, handler: (...args: any[]) => void) => Socket + } + if (typeof candidate.off === 'function') { + candidate.off(event, handler) + return + } + candidate.removeListener?.(event, handler) +} + +/** + * Start a chat run via Socket.IO and stream events back. + * Returns an AbortController-compatible handle for cancellation. + */ +/** + * Resume a session via Socket.IO. Returns messages, working status, and events. + */ +export function resumeSession( + sessionId: string, + onResumed: (data: ResumeSessionPayload) => void, + profile?: string | null, +): Socket { + const socket = connectChatRun(profile) + + socket.once('resumed', onResumed) + socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) }) + + return socket +} + +export function startRunViaSocket( + body: StartRunRequest, + onEvent: (event: RunEvent) => void, + onDone: () => void, + onError: (err: Error) => void, + onStarted?: (runId: string) => void, + options?: { + onReconnectResume?: (data: ResumeSessionPayload) => void + }, +): { abort: () => void } { + const sid = body.session_id + if (!sid) { + throw new Error('session_id is required for startRunViaSocket') + } + + let closed = false + const socket = connectChatRun(body.profile) + if (sessionEventHandlers.has(sid)) { + socket.emit('run', body) + return { + abort: () => { + if (!closed) { + socket.emit('abort', { session_id: sid }) + } + }, + } + } + + let sawTransientDisconnect = false + let removeTerminalSocketListeners: () => void = () => {} + let reconnectResumeHandler: ((data: ResumeSessionPayload) => void) | null = null + + const clearReconnectResumeHandler = () => { + if (!reconnectResumeHandler) return + removeSocketListener(socket, 'resumed', reconnectResumeHandler) + reconnectResumeHandler = null + } + + const emitReconnectResume = () => { + clearReconnectResumeHandler() + if (options?.onReconnectResume) { + reconnectResumeHandler = (data: ResumeSessionPayload) => { + clearReconnectResumeHandler() + if (closed || data.session_id !== sid) return + options.onReconnectResume?.(data) + } + socket.on('resumed', reconnectResumeHandler) + } + socket.emit('resume', { session_id: sid, ...(body.profile ? { profile: body.profile } : {}) }) + } + + const handleSocketError = (err: Error) => { + if (closed) return + closed = true + removeTerminalSocketListeners() + sessionEventHandlers.delete(sid) + onError(err) + } + const handleSocketConnectError = (err: Error) => { + if (closed) return + if (sawTransientDisconnect) return + handleSocketError(err) + } + socket.on('connect_error', handleSocketConnectError) + const handleSocketDisconnect = (reason: string) => { + if (closed || reason === 'io client disconnect') return + if (TRANSIENT_DISCONNECT_REASONS.has(reason)) { + sawTransientDisconnect = true + return + } + handleSocketError(new Error(`Socket disconnected: ${reason}`)) + } + socket.on('disconnect', handleSocketDisconnect) + + const handleSocketReconnect = () => { + if (closed || !sawTransientDisconnect) return + sawTransientDisconnect = false + emitReconnectResume() + } + socket.on('connect', handleSocketReconnect) + + removeTerminalSocketListeners = () => { + clearReconnectResumeHandler() + removeSocketListener(socket, 'connect_error', handleSocketConnectError) + removeSocketListener(socket, 'disconnect', handleSocketDisconnect) + removeSocketListener(socket, 'connect', handleSocketReconnect) + } + + // Define event handlers for this session + const handlers = { + onMessageDelta: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onReasoningDelta: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onThinkingDelta: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onReasoningAvailable: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onToolStarted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onToolCompleted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onSubagentEvent: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onRunStarted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + onStarted?.(evt.run_id || '') + }, + onRunCompleted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + if ((evt as any).queue_remaining > 0) return + closed = true + removeTerminalSocketListeners() + onDone() + }, + onRunFailed: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + if ((evt as any).queue_remaining > 0) return + closed = true + removeTerminalSocketListeners() + onDone() + }, + onCompressionStarted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onCompressionCompleted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onAbortStarted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onAbortCompleted: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + if ((evt as any).queue_length > 0) return + closed = true + removeTerminalSocketListeners() + onDone() + }, + onUsageUpdated: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onAgentEvent: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onSessionCommand: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + if ((evt as any).terminal === false) return + closed = true + removeTerminalSocketListeners() + sessionEventHandlers.delete(sid) + onDone() + }, + onRunQueued: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onApprovalRequested: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onApprovalResolved: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onClarifyRequested: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + onClarifyResolved: (evt: RunEvent) => { + if (closed) return + onEvent(evt) + }, + } + + // Register handlers in the global session map + sessionEventHandlers.set(sid, handlers) + + // Emit run request + socket.emit('run', body) + + return { + abort: () => { + if (!closed) { + socket.emit('abort', { session_id: sid }) + } + }, + } +} diff --git a/packages/client/src/api/hermes/codex-auth.ts b/packages/client/src/api/hermes/codex-auth.ts new file mode 100644 index 0000000..2102e8f --- /dev/null +++ b/packages/client/src/api/hermes/codex-auth.ts @@ -0,0 +1,30 @@ +import { request } from '../client' + +export interface CodexStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number +} + +export interface CodexPollResult { + status: 'pending' | 'approved' | 'expired' | 'error' + error: string | null +} + +export interface CodexStatusResult { + authenticated: boolean + last_refresh?: string +} + +export async function startCodexLogin(): Promise { + return request('/api/hermes/auth/codex/start', { method: 'POST' }) +} + +export async function pollCodexLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/codex/poll/${sessionId}`) +} + +export async function getCodexAuthStatus(): Promise { + return request('/api/hermes/auth/codex/status') +} diff --git a/packages/client/src/api/hermes/config.ts b/packages/client/src/api/hermes/config.ts new file mode 100644 index 0000000..d9f4a72 --- /dev/null +++ b/packages/client/src/api/hermes/config.ts @@ -0,0 +1,131 @@ +import { request } from '../client' + +export interface DisplayConfig { + compact?: boolean + personality?: string + resume_display?: string + busy_input_mode?: string + bell_on_complete?: boolean + show_reasoning?: boolean + streaming?: boolean + inline_diffs?: boolean + show_cost?: boolean + skin?: string +} + +export interface AgentConfig { + max_turns?: number + gateway_timeout?: number + restart_drain_timeout?: number + service_tier?: string + tool_use_enforcement?: string +} + +export interface MemoryConfig { + memory_enabled?: boolean + user_profile_enabled?: boolean + memory_char_limit?: number + user_char_limit?: number +} + +export interface CompressionConfig { + enabled?: boolean + threshold?: number + target_ratio?: number + protect_last_n?: number + protect_first_n?: number +} + +export interface SessionResetConfig { + mode?: string + idle_minutes?: number + at_hour?: number +} + +export interface PrivacyConfig { + redact_pii?: boolean +} + +export interface ApprovalConfig { + mode?: 'off' | 'manual' + timeout?: number +} + +export interface AppConfig { + display?: DisplayConfig + agent?: AgentConfig + memory?: MemoryConfig + compression?: CompressionConfig + session_reset?: SessionResetConfig + privacy?: PrivacyConfig + approvals?: ApprovalConfig + telegram?: Record + discord?: Record + slack?: Record + whatsapp?: Record + matrix?: Record + weixin?: Record + wecom?: Record + feishu?: Record + dingtalk?: Record + qqbot?: Record + platforms?: Record + [key: string]: any +} + +export async function fetchConfig(sections?: string[]): Promise { + const query = sections ? `?sections=${sections.join(',')}` : '' + return request(`/api/hermes/config${query}`) +} + +export async function updateConfigSection( + section: string, + values: Record, + options?: { restart?: boolean }, +): Promise { + await request('/api/hermes/config', { + method: 'PUT', + body: JSON.stringify({ section, values, ...options }), + }) +} + +export async function saveCredentials( + platform: string, + values: Record, +): Promise { + await request('/api/hermes/config/credentials', { + method: 'PUT', + body: JSON.stringify({ platform, values }), + }) +} + +export interface WeixinQrCode { + qrcode: string + qrcode_url: string +} + +export interface WeixinQrStatus { + status: 'wait' | 'scaned' | 'scaned_but_redirect' | 'expired' | 'confirmed' + account_id?: string + token?: string + base_url?: string +} + +export async function fetchWeixinQrCode(): Promise { + return request('/api/hermes/weixin/qrcode') +} + +export async function pollWeixinQrStatus(qrcode: string): Promise { + return request(`/api/hermes/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`) +} + +export async function saveWeixinCredentials(data: { + account_id: string + token: string + base_url?: string +}): Promise { + await request('/api/hermes/weixin/save', { + method: 'POST', + body: JSON.stringify(data), + }) +} diff --git a/packages/client/src/api/hermes/conversations.ts b/packages/client/src/api/hermes/conversations.ts new file mode 100644 index 0000000..cb7c07f --- /dev/null +++ b/packages/client/src/api/hermes/conversations.ts @@ -0,0 +1,59 @@ +import { request } from '../client' + +export interface ConversationSummary { + id: string + source: string + model: string + provider?: string + title: string | null + started_at: number + ended_at: number | null + last_active: number + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + preview: string + is_active: boolean + thread_session_count: number +} + +export interface ConversationMessage { + id: number | string + session_id: string + role: 'user' | 'assistant' + content: string + timestamp: number +} + +export interface ConversationDetail { + session_id: string + messages: ConversationMessage[] + visible_count: number + thread_session_count: number +} + +export async function fetchConversationSummaries(params: { humanOnly?: boolean; source?: string; limit?: number } = {}): Promise { + const query = new URLSearchParams() + if (params.humanOnly === false) query.set('humanOnly', 'false') + if (params.source) query.set('source', params.source) + if (params.limit != null) query.set('limit', String(params.limit)) + const suffix = query.toString() ? `?${query.toString()}` : '' + const res = await request<{ sessions: ConversationSummary[] }>(`/api/hermes/sessions/conversations${suffix}`) + return res.sessions +} + +export async function fetchConversationDetail(sessionId: string, params: { humanOnly?: boolean; source?: string } = {}): Promise { + const query = new URLSearchParams() + if (params.humanOnly === false) query.set('humanOnly', 'false') + if (params.source) query.set('source', params.source) + const suffix = query.toString() ? `?${query.toString()}` : '' + return request(`/api/hermes/sessions/conversations/${encodeURIComponent(sessionId)}/messages${suffix}`) +} diff --git a/packages/client/src/api/hermes/copilot-auth.ts b/packages/client/src/api/hermes/copilot-auth.ts new file mode 100644 index 0000000..e2733f8 --- /dev/null +++ b/packages/client/src/api/hermes/copilot-auth.ts @@ -0,0 +1,42 @@ +import { request } from '../client' + +export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null + +export interface CopilotStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number + interval: number +} + +export interface CopilotPollResult { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error: string | null +} + +export interface CopilotCheckTokenResult { + has_token: boolean + source: CopilotTokenSource + enabled: boolean +} + +export async function startCopilotLogin(): Promise { + return request('/api/hermes/auth/copilot/start', { method: 'POST' }) +} + +export async function pollCopilotLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/copilot/poll/${sessionId}`) +} + +export async function checkCopilotToken(): Promise { + return request('/api/hermes/auth/copilot/check-token') +} + +export async function enableCopilot(): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>('/api/hermes/auth/copilot/enable', { method: 'POST' }) +} + +export async function disableCopilot(): Promise<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }> { + return request<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }>('/api/hermes/auth/copilot/disable', { method: 'POST' }) +} diff --git a/packages/client/src/api/hermes/cron-history.ts b/packages/client/src/api/hermes/cron-history.ts new file mode 100644 index 0000000..6ab212e --- /dev/null +++ b/packages/client/src/api/hermes/cron-history.ts @@ -0,0 +1,27 @@ +import { request } from '../client' + +export interface RunEntry { + jobId: string + fileName: string + runTime: string + size: number +} + +export interface RunDetail { + jobId: string + fileName: string + runTime: string + content: string +} + +export async function listCronRuns(jobId?: string): Promise { + const params = new URLSearchParams() + if (jobId) params.set('jobId', jobId) + const qs = params.toString() + const res = await request<{ runs: RunEntry[] }>(`/api/cron-history${qs ? `?${qs}` : ''}`) + return res.runs +} + +export async function readCronRun(jobId: string, fileName: string): Promise { + return request(`/api/cron-history/${encodeURIComponent(jobId)}/${encodeURIComponent(fileName)}`) +} diff --git a/packages/client/src/api/hermes/download.ts b/packages/client/src/api/hermes/download.ts new file mode 100644 index 0000000..76707a8 --- /dev/null +++ b/packages/client/src/api/hermes/download.ts @@ -0,0 +1,71 @@ +import { getActiveProfileName, getApiKey, getBaseUrlValue } from '../client' + +/** + * Construct a download URL with auth token as query parameter. + * Token is passed via query param because tags cannot set headers. + */ +export function getDownloadUrl(filePath: string, fileName?: string): string { + const base = getBaseUrlValue() + + // Guard: if filePath is already a full download URL, extract the real path + // to prevent double-wrapping (/api/hermes/download?path=/api/hermes/download?path=...) + if (filePath.startsWith('/api/hermes/download?')) { + try { + const parsed = new URL(filePath, 'http://localhost') + const realPath = parsed.searchParams.get('path') + if (realPath) filePath = realPath + } catch { + // fall through with original filePath + } + } + + // Decode the path first in case it's already encoded (e.g., from AI responses) + // URLSearchParams will encode it again, so we need to start with decoded text + const decodedPath = decodeURIComponent(filePath) + const params = new URLSearchParams({ path: decodedPath }) + if (fileName) { + const decodedName = decodeURIComponent(fileName) + params.set('name', decodedName) + } + const profileName = getActiveProfileName() + if (profileName) params.set('profile', profileName) + const token = getApiKey() + if (token) params.set('token', token) + return `${base}/api/hermes/download?${params.toString()}` +} + +/** + * Download a file. Uses fetch to detect errors, then creates a blob URL + * for the browser download. Throws with error message on failure. + */ +export async function downloadFile(filePath: string, fileName?: string): Promise { + const url = getDownloadUrl(filePath, fileName) + const res = await fetch(url) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(body.error || `Download failed: ${res.status}`) + } + const blob = await res.blob() + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + a.download = fileName || filePath.split('/').pop() || 'download' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(blobUrl) +} + +/** + * Get preview file content. + * Throws with error message on failure. + */ +export async function fetchFileText(filePath: string, fileName?: string): Promise { + const url = getDownloadUrl(filePath, fileName) + const res = await fetch(url) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(body.error || `Preview failed: ${res.status}`) + } + return res.text() +} diff --git a/packages/client/src/api/hermes/files.ts b/packages/client/src/api/hermes/files.ts new file mode 100644 index 0000000..9bd027d --- /dev/null +++ b/packages/client/src/api/hermes/files.ts @@ -0,0 +1,107 @@ +import { request, getActiveProfileName, getApiKey, getBaseUrlValue } from '../client' + +export interface FileEntry { + name: string + path: string + absolutePath?: string + isDir: boolean + size: number + modTime: string +} + +export interface FileStat { + name: string + path: string + absolutePath?: string + isDir: boolean + size: number + modTime: string + permissions?: string +} + +export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> { + const params = new URLSearchParams() + if (path) params.set('path', path) + const query = params.toString() + return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`) +} + +export async function statFile(path: string): Promise { + return request(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`) +} + +export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> { + return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`) +} + +export async function writeFile(path: string, content: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/write', { + method: 'PUT', + body: JSON.stringify({ path, content }), + }) +} + +export async function deleteFile(path: string, recursive: boolean = false): Promise { + await request<{ ok: boolean }>('/api/hermes/files/delete', { + method: 'DELETE', + body: JSON.stringify({ path, recursive }), + }) +} + +export async function renameFile(oldPath: string, newPath: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/rename', { + method: 'POST', + body: JSON.stringify({ oldPath, newPath }), + }) +} + +export async function mkDir(path: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/mkdir', { + method: 'POST', + body: JSON.stringify({ path }), + }) +} + +export async function copyFile(srcPath: string, destPath: string): Promise { + await request<{ ok: boolean }>('/api/hermes/files/copy', { + method: 'POST', + body: JSON.stringify({ srcPath, destPath }), + }) +} + +export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> { + const base = getBaseUrlValue() + const formData = new FormData() + for (const file of files) { + formData.append('file', file) + } + const params = new URLSearchParams() + if (targetDir) params.set('path', targetDir) + const query = params.toString() + const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}` + + const headers: Record = {} + const token = getApiKey() + if (token) headers['Authorization'] = `Bearer ${token}` + const profileName = getActiveProfileName() + if (profileName) headers['X-Hermes-Profile'] = profileName + + const res = await fetch(url, { method: 'POST', headers, body: formData }) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(body.error || `Upload failed: ${res.status}`) + } + const data = await res.json() + return data.files +} + +export function getFileDownloadUrl(relativePath: string, fileName?: string): string { + const base = getBaseUrlValue() + const params = new URLSearchParams({ path: relativePath }) + if (fileName) params.set('name', fileName) + const profileName = getActiveProfileName() + if (profileName) params.set('profile', profileName) + const token = getApiKey() + if (token) params.set('token', token) + return `${base}/api/hermes/download?${params.toString()}` +} diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts new file mode 100644 index 0000000..74ecaea --- /dev/null +++ b/packages/client/src/api/hermes/group-chat.ts @@ -0,0 +1,235 @@ +import { io } from 'socket.io-client' +import { request, getApiKey } from '../client' + +// ─── Types ────────────────────────────────────────────────── + +export interface RoomInfo { + id: string + name: string + inviteCode: string | null + triggerTokens?: number + maxHistoryTokens?: number + tailMessageCount?: number + totalTokens?: number +} + +export interface RoomAgent { + id: string + roomId: string + agentId: string + profile: string + name: string + description: string + invited: number +} + +export interface AgentAddResult { + profile: string + ok: boolean + agent?: RoomAgent + code?: string + error?: string + reason?: string +} + +export interface ChatMessage { + id: string + roomId: string + senderId: string + senderName: string + content: string + timestamp: number + role?: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + finish_reason?: 'streaming' | 'tool_calls' | 'error' | string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null + isStreaming?: boolean + toolName?: string + toolCallId?: string + toolArgs?: string + toolPreview?: string + toolResult?: string + toolStatus?: 'running' | 'done' | 'error' + attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }> +} + +export interface MemberInfo { + id: string + userId: string + name: string + description: string + joinedAt: number +} + +export interface JoinResult { + roomId: string + roomName: string + members: MemberInfo[] + messages: ChatMessage[] + rooms: string[] +} + +// ─── Socket.IO Client ────────────────────────────────────── + +let socket: ReturnType | null = null + +export function connectGroupChat(opts?: { userId?: string; userName?: string; description?: string }): ReturnType { + if (socket?.connected) return socket + + const token = getApiKey() + const userId = opts?.userId || localStorage.getItem('gc_user_id') || generateUUID() + localStorage.setItem('gc_user_id', userId) + + socket = io('/group-chat', { + auth: { + token: token || undefined, + userId, + name: opts?.userName || localStorage.getItem('gc_user_name') || undefined, + description: opts?.description || localStorage.getItem('gc_user_description') || undefined, + }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + randomizationFactor: 0.5, + timeout: 30000, + }) + + return socket +} + +export function getStoredUserId(): string { + let id = localStorage.getItem('gc_user_id') + if (!id) { + id = generateUUID() + localStorage.setItem('gc_user_id', id) + } + return id +} + +export function getStoredUserName(): string | null { + return localStorage.getItem('gc_user_name') +} + +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) +} + +export function getSocket(): ReturnType | null { + return socket?.connected ? socket : null +} + +export function disconnectGroupChat(): void { + if (socket) { + socket.disconnect() + socket = null + } +} + +// ─── REST API ─────────────────────────────────────────────── + +export async function createRoom(data: { + name: string + inviteCode: string + agents?: { profile: string; name?: string; description?: string; invited?: boolean }[] + compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number } +}): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { + return request('/api/hermes/group-chat/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data || {}), + }) +} + +export async function listRooms(): Promise<{ rooms: RoomInfo[] }> { + return request('/api/hermes/group-chat/rooms') +} + +export async function getRoomDetail( + roomId: string, + options: { offset?: number; limit?: number } = {}, +): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[]; total?: number; offset?: number; limit?: number; hasMore?: boolean }> { + const params = new URLSearchParams() + if (options.offset != null) params.set('offset', String(options.offset)) + if (options.limit != null) params.set('limit', String(options.limit)) + const query = params.toString() + return request(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`) +} + +export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> { + return request(`/api/hermes/group-chat/rooms/join/${code}`) +} + +export async function updateInviteCode(roomId: string, inviteCode: string): Promise { + return request(`/api/hermes/group-chat/rooms/${roomId}/invite-code`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode }), + }) +} + +export async function addAgent(roomId: string, data: { + profile: string + name?: string + description?: string + invited?: boolean +}): Promise<{ agent: RoomAgent }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/agents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function listAgents(roomId: string): Promise<{ agents: RoomAgent[] }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/agents`) +} + +export async function removeAgent(roomId: string, agentId: string): Promise<{ success: boolean; agents: RoomAgent[]; members: MemberInfo[] }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, { + method: 'DELETE', + }) +} + +export async function deleteRoom(roomId: string): Promise { + return request(`/api/hermes/group-chat/rooms/${roomId}`, { + method: 'DELETE', + }) +} + +export async function clearRoomContext(roomId: string): Promise<{ success: boolean; room: RoomInfo }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, { + method: 'POST', + }) +} + +export async function updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): Promise<{ room: RoomInfo }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) +} + +export async function forceCompress(roomId: string): Promise<{ success: boolean; summary: string }> { + return request(`/api/hermes/group-chat/rooms/${roomId}/compress`, { + method: 'POST', + }) +} diff --git a/packages/client/src/api/hermes/jobs.ts b/packages/client/src/api/hermes/jobs.ts new file mode 100644 index 0000000..a068d36 --- /dev/null +++ b/packages/client/src/api/hermes/jobs.ts @@ -0,0 +1,188 @@ +import { request } from '../client' + +export interface JobScheduleInterval { + kind: 'interval' + minutes: number + display: string +} + +export interface JobScheduleCron { + kind: 'cron' + expr: string + display: string +} + +export interface JobScheduleOnce { + kind: 'once' + run_at: string + display: string +} + +type UnknownJobSchedule = { + kind: string + display?: string + expr?: string + minutes?: number + run_at?: string +} + +export type JobSchedule = string | JobScheduleInterval | JobScheduleCron | JobScheduleOnce | UnknownJobSchedule + +export interface Job { + job_id: string + id: string + name: string + prompt: string + prompt_preview?: string + skills: string[] + skill: string | null + model: string | null + provider: string | null + base_url: string | null + script: string | null + schedule: JobSchedule + schedule_display: string + repeat: string | { times: number | null; completed: number } + enabled: boolean + state: string + paused_at: string | null + paused_reason: string | null + created_at: string + next_run_at: string | null + last_run_at: string | null + last_status: string | null + last_error: string | null + deliver: string + origin: { + platform: string + chat_id: string + chat_name: string + thread_id: string | null + } | null + last_delivery_error: string | null +} + +export interface CreateJobRequest { + name: string + schedule: string + prompt?: string + deliver?: string + skills?: string[] + repeat?: number +} + +export interface UpdateJobRequest { + name?: string + schedule?: string + prompt?: string + deliver?: string + skills?: string[] + skill?: string + repeat?: number | null + enabled?: boolean + model?: string + provider?: string +} + +export interface JobFormValues { + name: string + schedule: string + prompt: string + deliver: string + repeat_times: number | null +} + +function unwrap(res: { job: Job }): Job { + return res.job +} + +function isScheduleObject(schedule: JobSchedule | null | undefined): schedule is Exclude { + return typeof schedule === 'object' && schedule !== null +} + +export function scheduleToEditableInput(schedule: JobSchedule | null | undefined, fallback = ''): string { + if (typeof schedule === 'string') return schedule + if (!isScheduleObject(schedule)) return fallback + + if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback + if (schedule.kind === 'once') return schedule.run_at || schedule.display || fallback + if (schedule.kind === 'interval') { + return schedule.display || (typeof schedule.minutes === 'number' ? `every ${schedule.minutes}m` : fallback) + } + + const unknownSchedule = schedule as UnknownJobSchedule + return unknownSchedule.expr || unknownSchedule.run_at || unknownSchedule.display || fallback +} + +export function scheduleToDisplayText(schedule: JobSchedule | null | undefined, fallback = '—'): string { + if (typeof schedule === 'string') return schedule + if (!isScheduleObject(schedule)) return fallback + + if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback + if (schedule.kind === 'interval') return schedule.display || scheduleToEditableInput(schedule, fallback) + if (schedule.kind === 'once') return schedule.display || scheduleToEditableInput(schedule, fallback) + + const unknownSchedule = schedule as UnknownJobSchedule + return unknownSchedule.display || unknownSchedule.expr || unknownSchedule.run_at || fallback +} + +export function jobRepeatToEditValue(repeat: Job['repeat']): number | null { + if (repeat && typeof repeat === 'object') return repeat.times ?? null + return null +} + +export function buildJobUpdateRequest(original: Job, form: JobFormValues): UpdateJobRequest { + const payload: UpdateJobRequest = {} + const originalSchedule = scheduleToEditableInput(original.schedule, original.schedule_display || '') + const originalRepeat = jobRepeatToEditValue(original.repeat) + const originalDeliver = original.deliver || 'origin' + + if (form.name !== original.name) payload.name = form.name + if (form.schedule !== originalSchedule) payload.schedule = form.schedule + if (form.prompt !== (original.prompt || '')) payload.prompt = form.prompt + if (form.deliver !== originalDeliver) payload.deliver = form.deliver + if (form.repeat_times !== originalRepeat) payload.repeat = form.repeat_times + + return payload +} + +export async function listJobs(): Promise { + const res = await request<{ jobs: Job[] }>('/api/hermes/jobs?include_disabled=true') + return res.jobs +} + +export async function getJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`)) +} + +export async function createJob(data: CreateJobRequest): Promise { + return unwrap(await request<{ job: Job }>('/api/hermes/jobs', { + method: 'POST', + body: JSON.stringify(data), + })) +} + +export async function updateJob(jobId: string, data: UpdateJobRequest): Promise { + return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`, { + method: 'PATCH', + body: JSON.stringify(data), + })) +} + +export async function deleteJob(jobId: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/hermes/jobs/${jobId}`, { + method: 'DELETE', + }) +} + +export async function pauseJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/pause`, { method: 'POST' })) +} + +export async function resumeJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/resume`, { method: 'POST' })) +} + +export async function runJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/run`, { method: 'POST' })) +} diff --git a/packages/client/src/api/hermes/kanban.ts b/packages/client/src/api/hermes/kanban.ts new file mode 100644 index 0000000..83bbb52 --- /dev/null +++ b/packages/client/src/api/hermes/kanban.ts @@ -0,0 +1,442 @@ +import { request, getApiKey, getBaseUrlValue } from '../client' + +// ─── Types ────────────────────────────────────────────────────── + +export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived' + +export interface KanbanTask { + id: string + title: string + body: string | null + assignee: string | null + status: KanbanTaskStatus + priority: number + created_by: string | null + created_at: number + started_at: number | null + completed_at: number | null + workspace_kind: string + workspace_path: string | null + tenant: string | null + result: string | null + skills: string[] | null +} + +export interface KanbanRun { + id: number + task_id: string + profile: string | null + status: string + outcome: string | null + summary: string | null + error: string | null + metadata: Record | null + worker_pid: number | null + started_at: number + ended_at: number | null +} + +export interface KanbanComment { + id: number + task_id: string + author: string + body: string + created_at: number +} + +export interface KanbanEvent { + id: number + task_id: string + kind: string + payload: Record | null + created_at: number + run_id: number | null +} + +export interface KanbanTaskMessage { + id: number | string + session_id: string + role: string + content: string + tool_call_id: string | null + tool_calls: any[] | null + tool_name: string | null + timestamp: number + token_count: number | null + finish_reason: string | null + reasoning: string | null +} + +export interface KanbanTaskSession { + id: string + title: string | null + source: string + model: string + started_at: number + ended_at: number | null + messages: KanbanTaskMessage[] +} + +export interface KanbanTaskDetail { + task: KanbanTask + latest_summary: string | null + session?: KanbanTaskSession + comments: KanbanComment[] + events: KanbanEvent[] + runs: KanbanRun[] + parents?: string[] + children?: string[] +} + +export interface KanbanStats { + by_status: Record + by_assignee: Record + total: number +} + +export interface KanbanAssignee { + name: string + on_disk: boolean + counts: Record | null +} + +export interface KanbanBoard { + slug: string + name: string + description: string + icon: string + color: string + created_at: number | null + archived: boolean + db_path?: string + is_current?: boolean + counts: Record + total: number +} + +export interface KanbanBoardCreateRequest { + slug: string + name?: string + description?: string + icon?: string + color?: string + switchCurrent?: boolean +} + +export interface KanbanCapabilityStatus { + key: string + status: 'supported' | 'partial' | 'missing' + reason?: string + canonicalRoute?: string + canonicalCommand?: string + requiresBoard: boolean +} + +export interface KanbanCapabilities { + source: 'hermes-cli' + supports: Record + missing: string[] + capabilities?: KanbanCapabilityStatus[] +} + +export interface KanbanTaskLog { + task_id: string + path: string | null + exists: boolean + size_bytes: number + content: string + truncated: boolean +} + +export interface KanbanCreateRequest { + title: string + body?: string + assignee?: string + priority?: number + tenant?: string +} + +export interface KanbanBoardOptions { + board?: string +} + +export interface KanbanListOptions extends KanbanBoardOptions { + status?: string + assignee?: string + tenant?: string + includeArchived?: boolean +} + +export interface KanbanCommentCreateRequest { + body: string + author?: string +} + +export interface KanbanTaskLogOptions extends KanbanBoardOptions { + tail?: number +} + +export interface KanbanDiagnosticsOptions extends KanbanBoardOptions { + task?: string + severity?: 'warning' | 'error' | 'critical' +} + +export interface KanbanReclaimOptions extends KanbanBoardOptions { + reason?: string +} + +export interface KanbanReassignOptions extends KanbanBoardOptions { + reclaim?: boolean + reason?: string +} + +export interface KanbanSpecifyOptions extends KanbanBoardOptions { + author?: string +} + +export interface KanbanDispatchOptions extends KanbanBoardOptions { + dryRun?: boolean + max?: number + failureLimit?: number +} + +export interface KanbanLinkRequest { + parent_id: string + child_id: string +} + +export interface KanbanBulkUpdateRequest { + ids: string[] + status?: KanbanTaskStatus + assignee?: string | null + archive?: boolean + summary?: string + reason?: string +} + +export interface KanbanBulkTaskResult { + id: string + ok: boolean + error?: string +} + +function normalizedBoard(board?: string): string { + const trimmed = board?.trim() + return trimmed || 'default' +} + +function activeProfileName(): string | null { + try { + return localStorage.getItem('hermes_active_profile_name') + } catch { + return null + } +} + +function appendQuery(path: string, params: URLSearchParams): string { + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + +function boardParams(board?: string): URLSearchParams { + const params = new URLSearchParams() + params.set('board', normalizedBoard(board)) + return params +} + +function websocketProtocol(base?: string): string { + if (base) return base.startsWith('https') ? 'wss:' : 'ws:' + return location.protocol === 'https:' ? 'wss:' : 'ws:' +} + +function formatHostForPort(hostname: string, port: number): string { + if (hostname.startsWith('[') && hostname.endsWith(']')) return `${hostname}:${port}` + return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}` +} + +export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string { + const base = getBaseUrlValue() + const params = boardParams(opts?.board) + const token = getApiKey() + if (token) params.set('token', token) + const profile = activeProfileName() + if (profile) params.set('profile', profile) + const path = `/api/hermes/kanban/events?${params.toString()}` + + if (base) { + return `${websocketProtocol(base)}//${new URL(base).host}${path}` + } + + const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT + const host = import.meta.env.DEV && directDevPort + ? formatHostForPort(location.hostname, Number(directDevPort)) + : location.host + return `${websocketProtocol()}//${host}${path}` +} + +export function openKanbanEventStream(opts?: KanbanBoardOptions): WebSocket { + return new WebSocket(buildKanbanEventsWebSocketUrl(opts)) +} + +// ─── API functions ─────────────────────────────────────────────── + +export async function listBoards(opts?: { includeArchived?: boolean }): Promise { + const params = new URLSearchParams() + if (opts?.includeArchived) params.set('includeArchived', 'true') + const res = await request<{ boards: KanbanBoard[] }>(appendQuery('/api/hermes/kanban/boards', params)) + return res.boards +} + +export async function createBoard(data: KanbanBoardCreateRequest): Promise { + const res = await request<{ board: KanbanBoard }>('/api/hermes/kanban/boards', { + method: 'POST', + body: JSON.stringify(data), + }) + return res.board +} + +export async function archiveBoard(slug: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/hermes/kanban/boards/${encodeURIComponent(slug)}`, { + method: 'DELETE', + }) +} + +export async function getCapabilities(): Promise { + const res = await request<{ capabilities: KanbanCapabilities }>('/api/hermes/kanban/capabilities') + return res.capabilities +} + +export async function listTasks(opts?: KanbanListOptions): Promise { + const params = boardParams(opts?.board) + if (opts?.status) params.set('status', opts.status) + if (opts?.assignee) params.set('assignee', opts.assignee) + if (opts?.tenant) params.set('tenant', opts.tenant) + if (opts?.includeArchived) params.set('includeArchived', 'true') + const res = await request<{ tasks: KanbanTask[] }>(appendQuery('/api/hermes/kanban', params)) + return res.tasks +} + +export async function getTask(id: string, opts?: KanbanBoardOptions): Promise { + return request(appendQuery(`/api/hermes/kanban/${encodeURIComponent(id)}`, boardParams(opts?.board))) +} + +export async function createTask(data: KanbanCreateRequest, opts?: KanbanBoardOptions): Promise { + const res = await request<{ task: KanbanTask }>(appendQuery('/api/hermes/kanban', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) + return res.task +} + +export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/complete', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ task_ids: taskIds, summary }), + }) +} + +export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/block`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ reason }), + }) +} + +export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/unblock', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ task_ids: taskIds }), + }) +} + +export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/assign`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ profile }), + }) +} + +export async function addComment(taskId: string, data: KanbanCommentCreateRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> { + return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/comments`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function linkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> { + return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function unlinkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> { + const params = boardParams(opts?.board) + params.set('parent_id', data.parent_id) + params.set('child_id', data.child_id) + return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', params), { + method: 'DELETE', + }) +} + +export async function bulkUpdateTasks(data: KanbanBulkUpdateRequest, opts?: KanbanBoardOptions): Promise<{ results: KanbanBulkTaskResult[] }> { + return request<{ results: KanbanBulkTaskResult[] }>(appendQuery('/api/hermes/kanban/tasks/bulk', boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function getTaskLog(taskId: string, opts?: KanbanTaskLogOptions): Promise { + const params = boardParams(opts?.board) + if (opts?.tail !== undefined) params.set('tail', String(opts.tail)) + return request(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/log`, params)) +} + +export async function getDiagnostics(opts?: KanbanDiagnosticsOptions): Promise { + const params = boardParams(opts?.board) + if (opts?.task) params.set('task', opts.task) + if (opts?.severity) params.set('severity', opts.severity) + const res = await request<{ diagnostics: unknown[] }>(appendQuery('/api/hermes/kanban/diagnostics', params)) + return res.diagnostics +} + +export async function reclaimTask(taskId: string, opts?: KanbanReclaimOptions): Promise<{ ok: boolean; output?: string }> { + return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reclaim`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ reason: opts?.reason }), + }) +} + +export async function reassignTask(taskId: string, profile: string, opts?: KanbanReassignOptions): Promise<{ ok: boolean; output?: string }> { + return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reassign`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ profile, reclaim: opts?.reclaim, reason: opts?.reason }), + }) +} + +export async function specifyTask(taskId: string, opts?: KanbanSpecifyOptions): Promise { + const res = await request<{ results: unknown[] }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/specify`, boardParams(opts?.board)), { + method: 'POST', + body: JSON.stringify({ author: opts?.author }), + }) + return res.results +} + +export async function dispatch(opts?: KanbanDispatchOptions): Promise { + const params = boardParams(opts?.board) + const res = await request<{ result: unknown }>(appendQuery('/api/hermes/kanban/dispatch', params), { + method: 'POST', + body: JSON.stringify({ dryRun: opts?.dryRun, max: opts?.max, failureLimit: opts?.failureLimit }), + }) + return res.result +} + +export async function getStats(opts?: KanbanBoardOptions): Promise { + const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board))) + return res.stats +} + +export async function getAssignees(opts?: KanbanBoardOptions): Promise { + const res = await request<{ assignees: KanbanAssignee[] }>(appendQuery('/api/hermes/kanban/assignees', boardParams(opts?.board))) + return res.assignees +} diff --git a/packages/client/src/api/hermes/logs.ts b/packages/client/src/api/hermes/logs.ts new file mode 100644 index 0000000..4773eb0 --- /dev/null +++ b/packages/client/src/api/hermes/logs.ts @@ -0,0 +1,36 @@ +import { request } from '../client' + +export interface LogFileInfo { + name: string + size: string + modified: string +} + +export interface LogEntry { + timestamp: string + level: string + logger: string + message: string + raw: string +} + +export async function fetchLogFiles(): Promise { + const res = await request<{ files: LogFileInfo[] }>('/api/hermes/logs') + return res.files +} + +export async function fetchLogs(name: string, params?: { + lines?: number + level?: string + session?: string + since?: string +}): Promise { + const query = new URLSearchParams() + if (params?.lines) query.set('lines', String(params.lines)) + if (params?.level) query.set('level', params.level) + if (params?.session) query.set('session', params.session) + if (params?.since) query.set('since', params.since) + const qs = query.toString() + const res = await request<{ entries: (LogEntry | null)[] }>(`/api/hermes/logs/${name}${qs ? `?${qs}` : ''}`) + return res.entries.filter((e): e is LogEntry => e !== null) +} diff --git a/packages/client/src/api/hermes/mcp.ts b/packages/client/src/api/hermes/mcp.ts new file mode 100644 index 0000000..1872bc6 --- /dev/null +++ b/packages/client/src/api/hermes/mcp.ts @@ -0,0 +1,90 @@ +import { request } from '../client' + +export interface McpServerInfo { + name: string + transport: 'stdio' | 'http' | 'sse' + connected: boolean + tools: number + tools_registered: number + tool_names: string[] + tool_names_registered: string[] + tool_details: Array<{ name: string; description?: string }> + error?: string | null + raw_config: McpServerConfig +} + +export interface McpServersResponse { + ok: boolean + servers: McpServerInfo[] + total_tools: number + error?: string +} + +export interface McpToolsResponse { + ok: boolean + results: Array<{ + server: string + tools: Array<{ + name: string + description: string + input_schema: Record + }> + }> + error?: string +} + +export interface McpServerConfig { + command?: string + args?: string[] + url?: string + env?: Record + headers?: Record + timeout?: number + connect_timeout?: number + enabled?: boolean + transport?: 'stdio' | 'http' | 'sse' + tools?: { include?: string[]; exclude?: string[] } + prompts?: boolean + resources?: boolean +} + +export async function fetchMcpServers(): Promise { + return request('/api/hermes/mcp/servers') +} + +export async function fetchMcpTools(server?: string, raw?: boolean): Promise { + const params = new URLSearchParams() + if (server) params.set('server', server) + if (raw) params.set('raw', '1') + const query = params.toString() ? `?${params.toString()}` : '' + return request(`/api/hermes/mcp/tools${query}`) +} + +export async function mcpServerAdd(name: string, config: McpServerConfig): Promise<{ ok: boolean; name?: string; error?: string }> { + return request('/api/hermes/mcp/servers', { + method: 'POST', + body: JSON.stringify({ name, config }), + }) +} + +export async function mcpServerRemove(name: string): Promise<{ ok: boolean; error?: string }> { + return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, { + method: 'DELETE', + }) +} + +export async function mcpServerUpdate(name: string, config: McpServerConfig): Promise<{ ok: boolean; error?: string }> { + return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, { + method: 'PATCH', + body: JSON.stringify({ config }), + }) +} + +export async function mcpReload(name?: string): Promise<{ ok: boolean; message?: string; error?: string }> { + const query = name ? `?server=${encodeURIComponent(name)}` : '' + return request(`/api/hermes/mcp/reload${query}`, { method: 'POST' }) +} + +export async function mcpServerTest(name: string): Promise<{ ok: boolean; tools?: string[]; error?: string }> { + return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' }) +} diff --git a/packages/client/src/api/hermes/model-context.ts b/packages/client/src/api/hermes/model-context.ts new file mode 100644 index 0000000..cbd9830 --- /dev/null +++ b/packages/client/src/api/hermes/model-context.ts @@ -0,0 +1,41 @@ +import { request } from '../client' + +export interface ModelContext { + id: number + provider: string + model: string + context_limit: number +} + +/** + * 根据 provider 和 model 查询模型上下文配置 + */ +export async function getModelContext(provider: string, model: string): Promise { + try { + const res = await request<{ data: ModelContext }>( + `/api/hermes/model-context?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}` + ) + return res.data + } catch (err: any) { + if (err.status === 404) return null + throw err + } +} + +/** + * 设置模型上下文配置(UPSERT:存在则更新,不存在则插入) + */ +export async function setModelContext( + provider: string, + model: string, + contextLimit: number +): Promise { + const res = await request<{ success: boolean; data: ModelContext }>( + `/api/hermes/model-context/${encodeURIComponent(provider)}/${encodeURIComponent(model)}`, + { + method: 'PUT', + body: JSON.stringify({ provider, model, context_limit: contextLimit }), + } + ) + return res.data +} diff --git a/packages/client/src/api/hermes/nous-auth.ts b/packages/client/src/api/hermes/nous-auth.ts new file mode 100644 index 0000000..890ccd7 --- /dev/null +++ b/packages/client/src/api/hermes/nous-auth.ts @@ -0,0 +1,29 @@ +import { request } from '../client' + +export interface NousStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number +} + +export interface NousPollResult { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error: string | null +} + +export interface NousStatusResult { + authenticated: boolean +} + +export async function startNousLogin(): Promise { + return request('/api/hermes/auth/nous/start', { method: 'POST' }) +} + +export async function pollNousLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/nous/poll/${sessionId}`) +} + +export async function getNousAuthStatus(): Promise { + return request('/api/hermes/auth/nous/status') +} diff --git a/packages/client/src/api/hermes/performance-monitor.ts b/packages/client/src/api/hermes/performance-monitor.ts new file mode 100644 index 0000000..ecce62b --- /dev/null +++ b/packages/client/src/api/hermes/performance-monitor.ts @@ -0,0 +1,63 @@ +import { request } from '../client' + +export interface ProcessUsage { + pid: number + role: 'web' | 'broker' | 'worker' + profile?: string + running: boolean + cpuPercent: number + memoryRssBytes: number + command?: string + error?: string +} + +export interface PerformanceRuntimeSnapshot { + timestamp: number + system: { + platform: string + arch: string + uptimeSeconds: number + cpuCount: number + cpuPercent: number + loadAverage: number[] + totalMemoryBytes: number + freeMemoryBytes: number + usedMemoryBytes: number + memoryPercent: number + } + web: { + pid: number + uptimeSeconds: number + memory: Record + cpuPercent: number + } + bridge: { + endpoint: string + reachable: boolean + error?: string + broker: { + running: boolean + ready: boolean + pid?: number + process?: ProcessUsage + restartScheduled: boolean + restartAttempts: number + } + workers: Array + totalWorkerMemoryRssBytes: number + } + sessions: { + active: number + running: number + byProfile: Record + } +} + +export async function fetchPerformanceRuntime(): Promise { + return request('/api/hermes/performance/runtime') +} diff --git a/packages/client/src/api/hermes/plugins.ts b/packages/client/src/api/hermes/plugins.ts new file mode 100644 index 0000000..5cee32d --- /dev/null +++ b/packages/client/src/api/hermes/plugins.ts @@ -0,0 +1,37 @@ +import { request } from '../client' + +export type PluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed' +export type PluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed' + +export interface HermesPluginInfo { + key: string + name: string + kind: string + source: string + configStatus: PluginConfigStatus | string + effectiveStatus: PluginEffectiveStatus | string + version: string + description: string + author: string + path: string + providesTools: string[] + providesHooks: string[] + requiresEnv: Array> +} + +export interface HermesPluginsMetadata { + hermesAgentRoot: string + pythonExecutable: string + cwd: string + projectPluginsEnabled: boolean +} + +export interface HermesPluginsResponse { + plugins: HermesPluginInfo[] + warnings: string[] + metadata: HermesPluginsMetadata +} + +export async function fetchPlugins(): Promise { + return request('/api/hermes/plugins') +} diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts new file mode 100644 index 0000000..87a6e6d --- /dev/null +++ b/packages/client/src/api/hermes/profiles.ts @@ -0,0 +1,228 @@ +import { request, getBaseUrlValue, getApiKey } from '../client' + +export interface HermesProfile { + name: string + active: boolean + model: string + gatewayStatus?: string + alias: string + avatar?: ProfileAvatar | null +} + +export interface HermesProfileDetail { + name: string + path: string + model: string + provider: string + skills: number + hasEnv: boolean + hasSoulMd: boolean + avatar?: ProfileAvatar | null +} + +export interface ProfileAvatar { + type: 'generated' | 'image' + seed?: string + dataUrl?: string + updatedAt?: number +} + +export interface ProfileRuntimeStatus { + profile: string + bridge: { + running: boolean + profile: string + mode?: string + reachable?: boolean + error?: string + } + gateway: { + profile: string + running: boolean + pid?: number + port?: number + host?: string + url?: string + error?: string + diagnostics?: { + health_url?: string + reason?: string + health_ok?: boolean + } + } +} + +export interface ProfileRuntimeStatusesResponse { + profiles: ProfileRuntimeStatus[] + refreshing?: boolean +} + +export async function fetchProfiles(): Promise { + const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles') + return res.profiles +} + +export async function fetchProfileDetail(name: string): Promise { + const res = await request<{ profile: HermesProfileDetail }>(`/api/hermes/profiles/${encodeURIComponent(name)}`) + return res.profile +} + +export async function fetchProfileRuntimeStatus(name: string): Promise { + return request(`/api/hermes/profiles/${encodeURIComponent(name)}/runtime-status`) +} + +export async function fetchProfileRuntimeStatusesWithMeta(options: { refresh?: boolean } = {}): Promise { + const query = options.refresh === false ? '?refresh=0' : '' + return request(`/api/hermes/profiles/runtime-statuses${query}`) +} + +export async function fetchProfileRuntimeStatuses(): Promise { + const res = await fetchProfileRuntimeStatusesWithMeta() + return res.profiles +} + +export async function updateProfileAvatar(name: string, avatar: ProfileAvatar): Promise { + const res = await request<{ avatar: ProfileAvatar }>(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, { + method: 'PUT', + body: JSON.stringify(avatar), + }) + return res.avatar +} + +export async function deleteProfileAvatar(name: string): Promise { + await request(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, { method: 'DELETE' }) +} + +export async function restartProfileGateway(name: string): Promise { + const res = await request<{ success: boolean; gateway: ProfileRuntimeStatus['gateway'] }>( + `/api/hermes/profiles/${encodeURIComponent(name)}/gateway/restart`, + { method: 'POST' }, + ) + return res.gateway +} + +export async function restartProfileRuntime(name: string): Promise { + const res = await request<{ success: boolean; status: ProfileRuntimeStatus }>( + `/api/hermes/profiles/${encodeURIComponent(name)}/restart`, + { method: 'POST' }, + ) + return res.status +} + +export interface CreateProfileResult { + success: boolean + /** clone=true 时被清理的独占平台凭据 KEY 名 */ + strippedCredentials?: string[] + /** clone=true 时被禁用的独占平台名 */ + disabledPlatforms?: string[] + /** clone=true 时在 config.yaml 中被清理的内嵌凭据字段路径 */ + strippedConfigCredentials?: string[] +} + +export async function createProfile(name: string, clone?: boolean): Promise { + try { + const res = await request<{ + success: boolean + strippedCredentials?: string[] + disabledPlatforms?: string[] + strippedConfigCredentials?: string[] + error?: string + }>('/api/hermes/profiles', { + method: 'POST', + body: JSON.stringify({ name, clone }), + }) + return { + success: !!res.success, + strippedCredentials: res.strippedCredentials, + disabledPlatforms: res.disabledPlatforms, + strippedConfigCredentials: res.strippedConfigCredentials, + error: res.error, + } + } catch (err: any) { + return { success: false, error: err.message || 'Unknown error' } + } +} + +export async function deleteProfile(name: string): Promise { + try { + await request(`/api/hermes/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' }) + return true + } catch { + return false + } +} + +export async function renameProfile(name: string, newName: string): Promise { + try { + await request(`/api/hermes/profiles/${encodeURIComponent(name)}/rename`, { + method: 'POST', + body: JSON.stringify({ new_name: newName }), + }) + return true + } catch { + return false + } +} + +export async function switchProfile(name: string): Promise { + return !!name +} + +export async function switchHermesProfile(name: string): Promise { + try { + await request('/api/hermes/profiles/active', { + method: 'PUT', + body: JSON.stringify({ name }), + }) + return true + } catch { + return false + } +} + +export async function exportProfile(name: string): Promise { + try { + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`${baseUrl}/api/hermes/profiles/${encodeURIComponent(name)}/export`, { + method: 'POST', + headers, + }) + if (!res.ok) throw new Error() + + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `hermes-profile-${name}.tar.gz` + a.click() + URL.revokeObjectURL(url) + return true + } catch { + return false + } +} + +export async function importProfile(file: File): Promise { + try { + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + + const formData = new FormData() + formData.append('file', file) + + const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, { + method: 'POST', + headers, + body: formData, + }) + return res.ok + } catch { + return false + } +} diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts new file mode 100644 index 0000000..66841a0 --- /dev/null +++ b/packages/client/src/api/hermes/sessions.ts @@ -0,0 +1,308 @@ +import { request, getApiKey, getBaseUrlValue } from '../client' + +export interface SessionSummary { + id: string + profile?: string | null + source: string + model: string + provider?: string + title: string | null + preview?: string + started_at: number + ended_at: number | null + last_active?: number + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + workspace?: string | null + webui_imported?: boolean +} + +export interface SessionDetail extends SessionSummary { + messages: HermesMessage[] +} + +export interface PaginatedSessionMessages { + session: SessionSummary + messages: HermesMessage[] + total: number + offset: number + limit: number + hasMore: boolean +} + +export interface SessionSearchResult extends SessionSummary { + matched_message_id: number | null + snippet: string + rank: number +} + +export interface HermesMessage { + id: number + session_id: string + role: 'user' | 'assistant' | 'system' | 'tool' | 'command' + content: string + tool_call_id: string | null + tool_calls: any[] | null + tool_name: string | null + timestamp: number + token_count: number | null + finish_reason: string | null + reasoning: string | null +} + +export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise { + const params = new URLSearchParams() + if (source) params.set('source', source) + if (limit) params.set('limit', String(limit)) + if (profile) params.set('profile', profile) + const query = params.toString() + const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`) + return res.sessions +} + +/** + * Fetch Hermes sessions only (exclude api_server source) + */ +export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null): Promise { + const params = new URLSearchParams() + if (source) params.set('source', source) + if (limit) params.set('limit', String(limit)) + if (profile) params.set('profile', profile) + const query = params.toString() + const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`) + return res.sessions +} + +export async function searchSessions(q: string, source?: string, limit?: number, profile?: string): Promise { + const params = new URLSearchParams() + params.set('q', q) + if (source) params.set('source', source) + if (limit) params.set('limit', String(limit)) + if (profile) params.set('profile', profile) + const query = params.toString() + const res = await request<{ results: SessionSearchResult[] }>(`/api/hermes/search/sessions?${query}`) + return res.results +} + +export async function fetchSession(id: string, profile?: string | null): Promise { + try { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + const query = params.toString() + const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`) + return res.session + } catch { + return null + } +} + +export async function fetchSessionMessagesPage( + id: string, + offset: number, + limit = 300, + profile?: string | null, +): Promise { + try { + const params = new URLSearchParams() + params.set('offset', String(offset)) + params.set('limit', String(limit)) + if (profile) params.set('profile', profile) + const res = await request( + `/api/hermes/sessions/conversations/${encodeURIComponent(id)}/messages/paginated?${params}`, + ) + return res + } catch { + return null + } +} + +/** + * Fetch Hermes session detail only (exclude api_server source) + */ +export async function fetchHermesSession(id: string, profile?: string | null): Promise { + try { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + const query = params.toString() + const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/hermes/${id}${query ? `?${query}` : ''}`) + return res.session + } catch { + return null + } +} + +export async function deleteSession(id: string, profile?: string | null): Promise { + try { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + const query = params.toString() + await request(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`, { method: 'DELETE' }) + return true + } catch { + return false + } +} + +export async function importHermesSession(id: string, profile?: string | null): Promise<{ ok: boolean; imported: boolean; session?: SessionDetail }> { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + const query = params.toString() + return request<{ ok: boolean; imported: boolean; session?: SessionDetail }>( + `/api/hermes/sessions/hermes/${encodeURIComponent(id)}/import${query ? `?${query}` : ''}`, + { method: 'POST' }, + ) +} + +export interface BatchDeleteSessionTarget { + id: string + profile?: string | null +} + +export async function batchDeleteSessions(targets: Array): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> { + try { + const sessions = targets.map(target => + typeof target === 'string' + ? { id: target } + : { id: target.id, profile: target.profile || undefined }, + ) + const res = await request<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }>( + '/api/hermes/sessions/batch-delete', + { + method: 'POST', + body: JSON.stringify({ + ids: sessions.map(session => session.id), + sessions, + }), + } + ) + return res + } catch (err: any) { + throw err + } +} + +export async function renameSession(id: string, title: string): Promise { + try { + await request(`/api/hermes/sessions/${id}/rename`, { + method: 'POST', + body: JSON.stringify({ title }), + }) + return true + } catch { + return false + } +} + +export async function setSessionWorkspace(id: string, workspace: string | null): Promise { + try { + await request(`/api/hermes/sessions/${id}/workspace`, { + method: 'POST', + body: JSON.stringify({ workspace: workspace || '' }), + }) + return true + } catch { + return false + } +} + +export async function setSessionModel(id: string, model: string, provider: string): Promise { + try { + await request(`/api/hermes/sessions/${id}/model`, { + method: 'POST', + body: JSON.stringify({ model, provider }), + }) + return true + } catch { + return false + } +} + +export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise { + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const url = `${baseUrl}/api/hermes/sessions/${id}/export?mode=${mode}&ext=${ext}&token=${encodeURIComponent(token)}` + const res = await fetch(url) + if (!res.ok) throw new Error('Export failed') + const blob = await res.blob() + const contentDisposition = res.headers.get('Content-Disposition') || '' + let filename = `session_${id}.${ext}` + const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i) + if (match) filename = decodeURIComponent(match[1].replace(/"/g, '')) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) +} + +export interface UsageStatsResponse { + total_input_tokens: number + total_output_tokens: number + total_cache_read_tokens: number + total_cache_write_tokens: number + total_reasoning_tokens: number + total_sessions: number + total_cost: number + total_api_calls?: number + period_days?: number + model_usage: Array<{ + model: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + sessions: number + }> + daily_usage: Array<{ + date: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + sessions: number + errors: number + cost: number + }> +} + +export async function fetchUsageStats(days = 30): Promise { + const safeDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30 + const params = new URLSearchParams() + params.set('days', String(safeDays)) + return request(`/api/hermes/usage/stats?${params}`) +} + +export async function fetchSessionUsage(ids: string[]): Promise> { + if (ids.length === 0) return {} + const params = new URLSearchParams() + params.set('ids', ids.join(',')) + return request(`/api/hermes/sessions/usage?${params}`) +} + +export async function fetchSessionUsageSingle(id: string): Promise<{ input_tokens: number; output_tokens: number } | null> { + try { + return await request<{ input_tokens: number; output_tokens: number }>(`/api/hermes/sessions/${id}/usage`) + } catch { + return null + } +} + +export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise { + const params = new URLSearchParams() + if (profile) params.set('profile', profile) + if (provider) params.set('provider', provider) + if (model) params.set('model', model) + const query = params.toString() + const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`) + return res.context_length +} diff --git a/packages/client/src/api/hermes/skills.ts b/packages/client/src/api/hermes/skills.ts new file mode 100644 index 0000000..f63713a --- /dev/null +++ b/packages/client/src/api/hermes/skills.ts @@ -0,0 +1,127 @@ +import { request } from '../client' + +export type SkillSource = 'builtin' | 'hub' | 'local' | 'external' + +export interface SkillInfo { + name: string + description: string + enabled?: boolean + source?: SkillSource + modified?: boolean + patchCount?: number + useCount?: number + viewCount?: number + pinned?: boolean +} + +export interface SkillCategory { + name: string + description: string + skills: SkillInfo[] +} + +export interface SkillListResponse { + categories: SkillCategory[] + archived: SkillInfo[] +} + +export interface SkillFileEntry { + path: string + name: string + isDir: boolean +} + +export interface MemoryData { + memory: string + user: string + soul: string + memory_mtime: number | null + user_mtime: number | null + soul_mtime: number | null +} + +export interface SkillsData { + categories: SkillCategory[] + archived: SkillInfo[] +} + +export interface SkillUsageRow { + skill: string + view_count: number + manage_count: number + total_count: number + percentage: number + last_used_at: number | null +} + +export interface SkillUsageDailySkillRow { + skill: string + view_count: number + manage_count: number + total_count: number +} + +export interface SkillUsageDailyRow { + date: string + view_count: number + manage_count: number + total_count: number + skills: SkillUsageDailySkillRow[] +} + +export interface SkillUsageStats { + period_days: number + summary: { + total_skill_loads: number + total_skill_edits: number + total_skill_actions: number + distinct_skills_used: number + } + by_day: SkillUsageDailyRow[] + top_skills: SkillUsageRow[] +} + +export async function fetchSkills(): Promise { + const res = await request('/api/hermes/skills') + return { categories: res.categories, archived: res.archived ?? [] } +} + +export async function fetchSkillUsageStats(days = 7): Promise { + const params = new URLSearchParams({ days: String(days) }) + return request(`/api/hermes/skills/usage/stats?${params}`) +} + +export async function fetchSkillContent(skillPath: string): Promise { + const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`) + return res.content +} + +export async function fetchSkillFiles(category: string, skill: string): Promise { + const res = await request<{ files: SkillFileEntry[] }>(`/api/hermes/skills/${category}/${skill}/files`) + return res.files +} + +export async function fetchMemory(): Promise { + return request('/api/hermes/memory') +} + +export async function saveMemory(section: 'memory' | 'user' | 'soul', content: string): Promise { + await request('/api/hermes/memory', { + method: 'POST', + body: JSON.stringify({ section, content }), + }) +} + +export async function toggleSkill(name: string, enabled: boolean): Promise { + await request('/api/hermes/skills/toggle', { + method: 'PUT', + body: JSON.stringify({ name, enabled }), + }) +} + +export async function pinSkillApi(name: string, pinned: boolean): Promise { + await request('/api/hermes/skills/pin', { + method: 'PUT', + body: JSON.stringify({ name, pinned }), + }) +} diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts new file mode 100644 index 0000000..a106c8a --- /dev/null +++ b/packages/client/src/api/hermes/system.ts @@ -0,0 +1,258 @@ +import { request } from '../client' + +export interface HealthResponse { + status: string + version?: string + webui_version?: string + webui_latest?: string + webui_update_available?: boolean + node_version?: string +} + +export interface PreviewTag { + name: string + sha: string +} + +export interface PreviewStatus { + preview_dir: string + exists: boolean + has_package: boolean + installed: boolean + running: boolean + pid: number | null + current_tag: string + frontend_url: string + agent_bridge_endpoint: string + log_path: string + webui_home: string + action_log_path: string + dev_log_path: string + active_action: string | null + active_action_started_at: string | null + last_action: string | null + last_action_completed_at: string | null + last_action_success: boolean | null + last_action_message: string + last_action_code: string + action_log: string + dev_log: string +} + +export interface PreviewActionResponse extends PreviewStatus { + success: boolean + accepted?: boolean + message?: string + code?: string +} + +// Config-based model types +export interface ModelInfo { + id: string + label: string +} + +export interface ModelGroup { + provider: string + models: ModelInfo[] +} + +export interface ConfigModelsResponse { + default: string + groups: ModelGroup[] +} + +export interface ModelVisibilityRule { + mode: 'all' | 'include' + models: string[] +} + +export type ModelVisibility = Record +export type CustomModels = Record + +export interface AvailableModelGroup { + provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai") + label: string // display name (e.g. "zai", "subrouter.ai") + base_url: string + models: string[] + /** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */ + available_models?: string[] + api_key: string + builtin?: boolean + /** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */ + base_url_env?: string + /** 可选:模型 ID -> 元数据(preview/disabled/alias)。alias 仅用于 Web UI 展示。 */ + model_meta?: Record +} + +export interface ProfileAvailableModels { + profile: string + default: string + default_provider: string + groups: AvailableModelGroup[] +} + +export interface AvailableModelsResponse { + default: string + default_provider: string + groups: AvailableModelGroup[] + allProviders: AvailableModelGroup[] + profiles?: ProfileAvailableModels[] + /** Web UI-only display aliases keyed by provider -> canonical model ID. */ + model_aliases?: Record> + model_visibility?: ModelVisibility + custom_models?: CustomModels +} + +export interface CustomProvider { + name: string + base_url: string + api_key: string + model: string + context_length?: number + providerKey?: string | null +} + +export async function checkHealth(): Promise { + return request('/health') +} + +export async function triggerUpdate(): Promise<{ success: boolean; message: string }> { + return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' }) +} + +export async function fetchPreviewStatus(): Promise { + return request('/api/hermes/update/preview') +} + +export async function fetchPreviewTags(): Promise<{ tags: PreviewTag[] }> { + return request<{ tags: PreviewTag[] }>('/api/hermes/update/preview/tags') +} + +export async function preparePreview(tag: string): Promise { + return request('/api/hermes/update/preview/prepare', { + method: 'POST', + body: JSON.stringify({ tag }), + }) +} + +export async function installPreview(): Promise { + return request('/api/hermes/update/preview/install', { method: 'POST' }) +} + +export async function startPreview(tag?: string): Promise { + return request('/api/hermes/update/preview/start', { + method: 'POST', + body: JSON.stringify({ tag }), + }) +} + +export async function stopPreview(): Promise { + return request('/api/hermes/update/preview/stop', { method: 'POST' }) +} + +export async function fetchConfigModels(): Promise { + return request('/api/hermes/config/models') +} + +export async function fetchAvailableModels(): Promise { + return request('/api/hermes/available-models') +} + +export async function fetchAvailableModelsForProfile(profile: string): Promise { + const params = new URLSearchParams() + params.set('profile', profile || 'default') + return request(`/api/hermes/available-models?${params.toString()}`) +} + +export async function fetchProviderModels(data: { + base_url: string + api_key?: string + freeOnly?: boolean +}): Promise<{ models: string[] }> { + return request<{ models: string[] }>('/api/hermes/provider-models', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateDefaultModel(data: { + default: string + provider?: string + base_url?: string + api_key?: string +}): Promise { + await request('/api/hermes/config/model', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function updateModelAlias(data: { + provider: string + model: string + alias: string +}): Promise { + await request('/api/hermes/model-alias', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function addCustomProvider(data: CustomProvider): Promise { + await request('/api/hermes/config/providers', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function removeCustomProvider(name: string): Promise { + await request(`/api/hermes/config/providers/${encodeURIComponent(name)}`, { + method: 'DELETE', + }) +} + +export async function updateProvider(poolKey: string, data: { + name?: string + base_url?: string + api_key?: string + model?: string +}): Promise { + await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function updateModelVisibility(data: { + provider: string + mode: 'all' | 'include' + models: string[] +}): Promise<{ success: boolean; model_visibility: ModelVisibility }> { + return request<{ success: boolean; model_visibility: ModelVisibility }>('/api/hermes/model-visibility', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function addCustomModel(data: { + provider: string + model: string +}): Promise<{ success: boolean; custom_models: CustomModels }> { + return request<{ success: boolean; custom_models: CustomModels }>('/api/hermes/custom-model', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function removeCustomModel(data: { + provider: string + model: string +}): Promise<{ success: boolean; custom_models: CustomModels }> { + const params = new URLSearchParams() + params.set('provider', data.provider) + params.set('model', data.model) + return request<{ success: boolean; custom_models: CustomModels }>(`/api/hermes/custom-model?${params.toString()}`, { + method: 'DELETE', + }) +} diff --git a/packages/client/src/api/hermes/tts.ts b/packages/client/src/api/hermes/tts.ts new file mode 100644 index 0000000..581dabc --- /dev/null +++ b/packages/client/src/api/hermes/tts.ts @@ -0,0 +1,36 @@ +export interface TtsOptions { + text: string + lang?: string + rate?: string // Edge TTS rate format: "+NN%" or "-NN%" + pitch?: string // Edge TTS pitch format: "+NNHz" or "-NNHz" +} + +export async function generateSpeech(opts: TtsOptions): Promise<{ audio: Blob; engine: string }> { + const res = await fetch( + `${localStorage.getItem('hermes_server_url') || ''}/api/hermes/tts`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('hermes_api_key') || ''}`, + }, + body: JSON.stringify(opts), + }, + ) + + if (!res.ok) { + throw new Error(`TTS request failed: ${res.status}`) + } + + const audio = await res.blob() + const engine = res.headers.get('X-TTS-Engine') || 'unknown' + return { audio, engine } +} + +export function playAudioBlob(blob: Blob): HTMLAudioElement { + const url = URL.createObjectURL(blob) + const audio = new Audio(url) + audio.play() + audio.onended = () => URL.revokeObjectURL(url) + return audio +} diff --git a/packages/client/src/api/hermes/xai-auth.ts b/packages/client/src/api/hermes/xai-auth.ts new file mode 100644 index 0000000..a26b87e --- /dev/null +++ b/packages/client/src/api/hermes/xai-auth.ts @@ -0,0 +1,29 @@ +import { request } from '../client' + +export interface XaiStartResult { + session_id: string + authorization_url: string + expires_in: number +} + +export interface XaiPollResult { + status: 'pending' | 'approved' | 'expired' | 'error' + error: string | null +} + +export interface XaiStatusResult { + authenticated: boolean + last_refresh?: string +} + +export async function startXaiLogin(): Promise { + return request('/api/hermes/auth/xai/start', { method: 'POST' }) +} + +export async function pollXaiLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/xai/poll/${sessionId}`) +} + +export async function getXaiAuthStatus(): Promise { + return request('/api/hermes/auth/xai/status') +} diff --git a/packages/client/src/assets/image1.png b/packages/client/src/assets/image1.png new file mode 100644 index 0000000..5a5f657 Binary files /dev/null and b/packages/client/src/assets/image1.png differ diff --git a/packages/client/src/assets/image2.png b/packages/client/src/assets/image2.png new file mode 100644 index 0000000..93f0d89 Binary files /dev/null and b/packages/client/src/assets/image2.png differ diff --git a/packages/client/src/assets/logo.png b/packages/client/src/assets/logo.png new file mode 100644 index 0000000..5d23421 Binary files /dev/null and b/packages/client/src/assets/logo.png differ diff --git a/packages/client/src/assets/vite.svg b/packages/client/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/packages/client/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/packages/client/src/components/auth/AuthEventListener.vue b/packages/client/src/components/auth/AuthEventListener.vue new file mode 100644 index 0000000..022a3e8 --- /dev/null +++ b/packages/client/src/components/auth/AuthEventListener.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/client/src/components/auth/DefaultCredentialPrompt.vue b/packages/client/src/components/auth/DefaultCredentialPrompt.vue new file mode 100644 index 0000000..8d9718c --- /dev/null +++ b/packages/client/src/components/auth/DefaultCredentialPrompt.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/packages/client/src/components/common/AppLogo.vue b/packages/client/src/components/common/AppLogo.vue new file mode 100644 index 0000000..76a660e --- /dev/null +++ b/packages/client/src/components/common/AppLogo.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/client/src/components/common/RouteLinkItem.vue b/packages/client/src/components/common/RouteLinkItem.vue new file mode 100644 index 0000000..637027b --- /dev/null +++ b/packages/client/src/components/common/RouteLinkItem.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue new file mode 100644 index 0000000..957c5a3 --- /dev/null +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -0,0 +1,1068 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue new file mode 100644 index 0000000..77dade5 --- /dev/null +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -0,0 +1,2350 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/ConversationMonitorPane.vue b/packages/client/src/components/hermes/chat/ConversationMonitorPane.vue new file mode 100644 index 0000000..7c729dc --- /dev/null +++ b/packages/client/src/components/hermes/chat/ConversationMonitorPane.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/DrawerPanel.vue b/packages/client/src/components/hermes/chat/DrawerPanel.vue new file mode 100644 index 0000000..42a146f --- /dev/null +++ b/packages/client/src/components/hermes/chat/DrawerPanel.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/FilesPanel.vue b/packages/client/src/components/hermes/chat/FilesPanel.vue new file mode 100644 index 0000000..b5fecc8 --- /dev/null +++ b/packages/client/src/components/hermes/chat/FilesPanel.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/FolderPicker.vue b/packages/client/src/components/hermes/chat/FolderPicker.vue new file mode 100644 index 0000000..21af82a --- /dev/null +++ b/packages/client/src/components/hermes/chat/FolderPicker.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/HistoryMessageList.vue b/packages/client/src/components/hermes/chat/HistoryMessageList.vue new file mode 100644 index 0000000..d7f6891 --- /dev/null +++ b/packages/client/src/components/hermes/chat/HistoryMessageList.vue @@ -0,0 +1,245 @@ + + + + + + + diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue new file mode 100644 index 0000000..a487426 --- /dev/null +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -0,0 +1,780 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue new file mode 100644 index 0000000..ae4320f --- /dev/null +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -0,0 +1,1652 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue new file mode 100644 index 0000000..2f3f98e --- /dev/null +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -0,0 +1,798 @@ + + + + + + + diff --git a/packages/client/src/components/hermes/chat/OutlinePanel.vue b/packages/client/src/components/hermes/chat/OutlinePanel.vue new file mode 100644 index 0000000..301d7af --- /dev/null +++ b/packages/client/src/components/hermes/chat/OutlinePanel.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/SessionListItem.vue b/packages/client/src/components/hermes/chat/SessionListItem.vue new file mode 100644 index 0000000..99a7e86 --- /dev/null +++ b/packages/client/src/components/hermes/chat/SessionListItem.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/SessionSearchModal.vue b/packages/client/src/components/hermes/chat/SessionSearchModal.vue new file mode 100644 index 0000000..e4c8c06 --- /dev/null +++ b/packages/client/src/components/hermes/chat/SessionSearchModal.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue new file mode 100644 index 0000000..f2fdb80 --- /dev/null +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -0,0 +1,920 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/ThinkingIndicator.vue b/packages/client/src/components/hermes/chat/ThinkingIndicator.vue new file mode 100644 index 0000000..a86f6af --- /dev/null +++ b/packages/client/src/components/hermes/chat/ThinkingIndicator.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/VirtualMessageList.vue b/packages/client/src/components/hermes/chat/VirtualMessageList.vue new file mode 100644 index 0000000..cbd8fcc --- /dev/null +++ b/packages/client/src/components/hermes/chat/VirtualMessageList.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/highlight.ts b/packages/client/src/components/hermes/chat/highlight.ts new file mode 100644 index 0000000..30a2ae3 --- /dev/null +++ b/packages/client/src/components/hermes/chat/highlight.ts @@ -0,0 +1,103 @@ +import hljs from 'highlight.js' +import { copyToClipboard } from '@/utils/clipboard' + +const LANGUAGE_ALIASES: Record = { + shellscript: 'bash', + sh: 'bash', + zsh: 'bash', + yml: 'yaml', + vue: 'xml', +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function sanitizeLanguageClass(value: string): string { + return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain' +} + +export function normalizeHighlightLanguage(lang?: string): string { + const normalized = lang?.trim().toLowerCase() || '' + return LANGUAGE_ALIASES[normalized] || normalized +} + +export function inferStructuredLanguage(content: string): string | undefined { + try { + JSON.parse(content) + return 'json' + } catch { + return undefined + } +} + +type RenderHighlightedCodeBlockOptions = { + maxHighlightLength?: number +} + +export function renderHighlightedCodeBlock( + content: string, + lang: string | undefined, + copyLabel: string, + options: RenderHighlightedCodeBlockOptions = {}, +): string { + const requestedLanguage = lang?.trim().toLowerCase() || '' + const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage) + const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY + + let highlighted = '' + let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain' + let labelLanguage = requestedLanguage + + try { + if (normalizedLanguage && hljs.getLanguage(normalizedLanguage) && content.length <= highlightLimit) { + highlighted = hljs.highlight(content, { + language: normalizedLanguage, + ignoreIllegals: true, + }).value + codeClassLanguage = normalizedLanguage + } else { + highlighted = escapeHtml(content) + if (!labelLanguage) { + labelLanguage = 'text' + } + } + } catch { + highlighted = escapeHtml(content) + if (!labelLanguage) { + labelLanguage = 'text' + } + } + + const languageLabelHtml = labelLanguage + ? `${escapeHtml(labelLanguage)}` + : '' + + return `
${languageLabelHtml}
${highlighted}
` +} + +export async function copyTextToClipboard(text: string): Promise { + return copyToClipboard(text) +} + +export async function handleCodeBlockCopyClick(event: MouseEvent): Promise { + const target = event.target + if (!(target instanceof HTMLElement)) return null + + const button = target.closest('[data-copy-code="true"]') + if (!button) return null + + event.preventDefault() + + const block = button.closest('.hljs-code-block') + const code = block?.querySelector('code') + const text = code?.textContent ?? '' + if (!text) return false + + return copyTextToClipboard(text) +} diff --git a/packages/client/src/components/hermes/chat/markdownFenceRepair.ts b/packages/client/src/components/hermes/chat/markdownFenceRepair.ts new file mode 100644 index 0000000..9c8dd38 --- /dev/null +++ b/packages/client/src/components/hermes/chat/markdownFenceRepair.ts @@ -0,0 +1,216 @@ +const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd']) + +type FenceInfo = { + indent: string + marker: string + fence: string + length: number + info: string +} + +function parseFence(line: string): FenceInfo | null { + const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/) + if (!match) return null + + const [, indent, fence, rawInfo = ''] = match + const marker = fence[0] + const info = rawInfo.trim() + + // CommonMark permits backticks in tilde-fence info strings, but not in + // backtick-fence info strings. Keeping this distinction prevents inline-ish + // malformed backtick text from being promoted into a fence opener. + if (marker === '`' && info.includes('`')) return null + + return { + indent, + marker, + fence, + length: fence.length, + info, + } +} + +function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string { + return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}` +} + +function isMarkdownFence(fence: FenceInfo): boolean { + const language = fence.info.split(/\s+/)[0]?.toLowerCase() + return MARKDOWN_FENCE_LANGUAGES.has(language) +} + +function isClosingFence(line: string, opener: FenceInfo): boolean { + const fence = parseFence(line) + return Boolean( + fence + && fence.marker === opener.marker + && fence.length >= opener.length + && fence.info === '', + ) +} + +function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number { + let index = start + while (index >= 0 && lines[index].trim() === '') { + index -= 1 + } + return index +} + +function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number { + for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) { + if (isClosingFence(lines[i], opener)) { + return i + } + } + return -1 +} + +type OpenFence = { + marker: string + length: number +} + +function canBalanceNestedFences(lines: string[], marker: string): boolean { + const stack: OpenFence[] = [] + let sawFence = false + + for (const line of lines) { + const fence = parseFence(line) + if (!fence || fence.marker !== marker) continue + + sawFence = true + const current = stack[stack.length - 1] + if (fence.info === '' && current && fence.length >= current.length) { + stack.pop() + continue + } + + // Inside a Markdown example, an unlabeled fence can be either a closing + // fence or a literal nested unlabeled example opener. If there is no nested + // opener waiting to close, treat it as the latter while evaluating a later + // candidate closing fence for the outer example. + stack.push({ marker: fence.marker, length: fence.length }) + } + + return sawFence && stack.length === 0 +} + +function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number { + const candidates: number[] = [] + + for (let i = start; i < lines.length; i += 1) { + const fence = parseFence(lines[i]) + if ( + fence + && fence.marker === opener.marker + && fence.info === '' + && fence.length >= opener.length + ) { + candidates.push(i) + } + } + + for (let i = candidates.length - 1; i >= 0; i -= 1) { + const candidate = candidates[i] + if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) { + return candidate + } + } + + return candidates[0] ?? -1 +} + +function maxFenceLength(lines: string[], marker: string): number { + let maxLength = 0 + for (const line of lines) { + const fence = parseFence(line) + if (fence?.marker === marker) { + maxLength = Math.max(maxLength, fence.length) + } + } + return maxLength +} + +function promoteMarkdownExampleFences(lines: string[]): string[] { + const output: string[] = [] + + for (let i = 0; i < lines.length; i += 1) { + const opener = parseFence(lines[i]) + if (!opener || !isMarkdownFence(opener)) { + output.push(lines[i]) + continue + } + + const balancedClose = findBalancedClosingFence(lines, opener, i + 1) + if (balancedClose === -1) { + output.push(lines[i]) + continue + } + + const body = lines.slice(i + 1, balancedClose) + const innerMaxLength = maxFenceLength(body, opener.marker) + if (innerMaxLength >= opener.length) { + const promotedLength = innerMaxLength + 1 + output.push(serializeFence(opener, promotedLength)) + output.push(...body) + output.push(serializeFence(opener, promotedLength, '')) + } else { + output.push(lines[i]) + output.push(...body) + output.push(lines[balancedClose]) + } + + i = balancedClose + } + + return output +} + +/** + * LLMs often wrap a complete PR draft or Markdown answer in an outer + * ```md fence. Showing that outer wrapper as a code block makes the UI look + * like Markdown rendering is broken: headings, lists, and inline code remain + * literal text. Strip only that outer draft wrapper before handing content to + * markdown-it. + * + * The unwrapped draft can still contain Markdown examples that themselves + * contain fenced examples. CommonMark closes fences at the first same-marker + * line with at least the opener length, so a malformed example like + * ```md ... ```md ... ``` ... ``` must be normalized by making the example's + * outer fence longer than the literal fences inside it. + */ +export function repairNestedMarkdownFences(content: string): string { + if (!content.includes('```') && !content.includes('~~~')) return content + + const lines = content.split('\n') + const output: string[] = [] + let changed = false + + for (let i = 0; i < lines.length; i += 1) { + const opener = parseFence(lines[i]) + if (!opener || !isMarkdownFence(opener)) { + output.push(lines[i]) + continue + } + + const finalClose = findFinalClosingFence(lines, opener, i + 1) + if (finalClose === -1) { + output.push(lines[i]) + continue + } + + const lastNonEmpty = findLastNonEmptyLine(lines) + if (finalClose !== lastNonEmpty) { + output.push(lines[i]) + continue + } + + output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose))) + output.push(...lines.slice(finalClose + 1)) + changed = true + break + } + + return changed ? output.join('\n') : content +} diff --git a/packages/client/src/components/hermes/chat/mermaidRenderer.ts b/packages/client/src/components/hermes/chat/mermaidRenderer.ts new file mode 100644 index 0000000..ed6ecea --- /dev/null +++ b/packages/client/src/components/hermes/chat/mermaidRenderer.ts @@ -0,0 +1,46 @@ +const MERMAID_LANGUAGE = 'mermaid' + +export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4 +export const MERMAID_MAX_SOURCE_LENGTH = 20_000 +export const MERMAID_RENDER_TIMEOUT_MS = 5_000 +export const SUPPORT_PREVIEW_FILE_TYPES = ['txt', 'md', 'json', 'csv', 'log', 'py', 'yaml', 'yml', 'toml', 'sh', 'xml', 'html', 'css', 'js', 'ts', 'rs', 'go', 'java', 'c', 'cpp', 'h'] + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function getFenceLanguage(info: string | undefined): string { + return info?.trim().split(/\s+/)[0]?.toLowerCase() || '' +} + +export function isMermaidFence(info: string | undefined): boolean { + return getFenceLanguage(info) === MERMAID_LANGUAGE +} + +export function encodeMermaidSource(source: string): string { + return encodeURIComponent(source) +} + +export function decodeMermaidSource(encoded: string | null | undefined): string { + if (!encoded) return '' + + try { + return decodeURIComponent(encoded) + } catch { + return '' + } +} + +export function renderMermaidPlaceholder(source: string): string { + return [ + '
`, + '
Rendering Mermaid diagram…
', + '
', + ].join('') +} diff --git a/packages/client/src/components/hermes/files/FileBreadcrumb.vue b/packages/client/src/components/hermes/files/FileBreadcrumb.vue new file mode 100644 index 0000000..dacd1b5 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileBreadcrumb.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileContextMenu.vue b/packages/client/src/components/hermes/files/FileContextMenu.vue new file mode 100644 index 0000000..f36834f --- /dev/null +++ b/packages/client/src/components/hermes/files/FileContextMenu.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/client/src/components/hermes/files/FileEditor.vue b/packages/client/src/components/hermes/files/FileEditor.vue new file mode 100644 index 0000000..347076f --- /dev/null +++ b/packages/client/src/components/hermes/files/FileEditor.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileList.vue b/packages/client/src/components/hermes/files/FileList.vue new file mode 100644 index 0000000..37adc78 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileList.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FilePreview.vue b/packages/client/src/components/hermes/files/FilePreview.vue new file mode 100644 index 0000000..bce83df --- /dev/null +++ b/packages/client/src/components/hermes/files/FilePreview.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileRenameModal.vue b/packages/client/src/components/hermes/files/FileRenameModal.vue new file mode 100644 index 0000000..ece9748 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileRenameModal.vue @@ -0,0 +1,98 @@ + + + diff --git a/packages/client/src/components/hermes/files/FileToolbar.vue b/packages/client/src/components/hermes/files/FileToolbar.vue new file mode 100644 index 0000000..9c91958 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileToolbar.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileTree.vue b/packages/client/src/components/hermes/files/FileTree.vue new file mode 100644 index 0000000..5602f75 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileTree.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileUploadModal.vue b/packages/client/src/components/hermes/files/FileUploadModal.vue new file mode 100644 index 0000000..c145d40 --- /dev/null +++ b/packages/client/src/components/hermes/files/FileUploadModal.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue new file mode 100644 index 0000000..81625f6 --- /dev/null +++ b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/client/src/components/hermes/group-chat/GroupChatInput.vue b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue new file mode 100644 index 0000000..5002e55 --- /dev/null +++ b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue @@ -0,0 +1,774 @@ + + + + + diff --git a/packages/client/src/views/hermes/ModelsView.vue b/packages/client/src/views/hermes/ModelsView.vue new file mode 100644 index 0000000..5d040e5 --- /dev/null +++ b/packages/client/src/views/hermes/ModelsView.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/client/src/views/hermes/PerformanceView.vue b/packages/client/src/views/hermes/PerformanceView.vue new file mode 100644 index 0000000..7a9b538 --- /dev/null +++ b/packages/client/src/views/hermes/PerformanceView.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/packages/client/src/views/hermes/PluginsView.vue b/packages/client/src/views/hermes/PluginsView.vue new file mode 100644 index 0000000..0064354 --- /dev/null +++ b/packages/client/src/views/hermes/PluginsView.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/packages/client/src/views/hermes/ProfilesView.vue b/packages/client/src/views/hermes/ProfilesView.vue new file mode 100644 index 0000000..de3bd72 --- /dev/null +++ b/packages/client/src/views/hermes/ProfilesView.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/packages/client/src/views/hermes/SettingsView.vue b/packages/client/src/views/hermes/SettingsView.vue new file mode 100644 index 0000000..487da39 --- /dev/null +++ b/packages/client/src/views/hermes/SettingsView.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/client/src/views/hermes/SkillsUsageView.vue b/packages/client/src/views/hermes/SkillsUsageView.vue new file mode 100644 index 0000000..59c909e --- /dev/null +++ b/packages/client/src/views/hermes/SkillsUsageView.vue @@ -0,0 +1,686 @@ + + + + + diff --git a/packages/client/src/views/hermes/SkillsView.vue b/packages/client/src/views/hermes/SkillsView.vue new file mode 100644 index 0000000..07178b0 --- /dev/null +++ b/packages/client/src/views/hermes/SkillsView.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/packages/client/src/views/hermes/TerminalView.vue b/packages/client/src/views/hermes/TerminalView.vue new file mode 100644 index 0000000..70503d7 --- /dev/null +++ b/packages/client/src/views/hermes/TerminalView.vue @@ -0,0 +1,1102 @@ + + + + + + + diff --git a/packages/client/src/views/hermes/UsageView.vue b/packages/client/src/views/hermes/UsageView.vue new file mode 100644 index 0000000..4128e8d --- /dev/null +++ b/packages/client/src/views/hermes/UsageView.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/packages/client/src/views/hermes/VersionPreviewView.vue b/packages/client/src/views/hermes/VersionPreviewView.vue new file mode 100644 index 0000000..a789343 --- /dev/null +++ b/packages/client/src/views/hermes/VersionPreviewView.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/desktop/README.md b/packages/desktop/README.md new file mode 100644 index 0000000..1ed2028 --- /dev/null +++ b/packages/desktop/README.md @@ -0,0 +1,40 @@ +# Hermes Studio + +Electron desktop distribution for Hermes Studio. + +## Install + +Download the latest macOS, Windows, or Linux installer for your CPU +architecture from the project +[GitHub Releases](https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest). + +The desktop app bundles the Web UI runtime and launches it locally from the +native shell app. + +## Data directories + +Hermes Agent data is stored in the same platform-specific location as native +Hermes installs: + +- Windows: `%LOCALAPPDATA%\hermes` (falls back to `%APPDATA%\hermes`) +- macOS/Linux: `~/.hermes` + +The desktop wrapper's own Web UI state is stored separately in +`~/.hermes-web-ui` unless `HERMES_WEB_UI_HOME` is set. + +## China mirror environment + +These mirrors are optional and are not required in CI: + +```sh +export NPM_CONFIG_REGISTRY=https://registry.npmmirror.com +export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ +export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ +``` + +If GitHub release downloads are slow, `fetch-python.mjs` can also use a compatible +python-build-standalone release mirror: + +```sh +export PBS_BASE_URL=https://github.com/astral-sh/python-build-standalone/releases/download +``` diff --git a/packages/desktop/build/icon.icns b/packages/desktop/build/icon.icns new file mode 100644 index 0000000..9419e36 Binary files /dev/null and b/packages/desktop/build/icon.icns differ diff --git a/packages/desktop/build/icon.ico b/packages/desktop/build/icon.ico new file mode 100644 index 0000000..89f8c3b Binary files /dev/null and b/packages/desktop/build/icon.ico differ diff --git a/packages/desktop/build/icon.png b/packages/desktop/build/icon.png new file mode 100644 index 0000000..14c2d55 Binary files /dev/null and b/packages/desktop/build/icon.png differ diff --git a/packages/desktop/build/icons/128x128.png b/packages/desktop/build/icons/128x128.png new file mode 100644 index 0000000..8c1407d Binary files /dev/null and b/packages/desktop/build/icons/128x128.png differ diff --git a/packages/desktop/build/icons/16x16.png b/packages/desktop/build/icons/16x16.png new file mode 100644 index 0000000..0f44785 Binary files /dev/null and b/packages/desktop/build/icons/16x16.png differ diff --git a/packages/desktop/build/icons/256x256.png b/packages/desktop/build/icons/256x256.png new file mode 100644 index 0000000..31ca586 Binary files /dev/null and b/packages/desktop/build/icons/256x256.png differ diff --git a/packages/desktop/build/icons/32x32.png b/packages/desktop/build/icons/32x32.png new file mode 100644 index 0000000..df8f031 Binary files /dev/null and b/packages/desktop/build/icons/32x32.png differ diff --git a/packages/desktop/build/icons/48x48.png b/packages/desktop/build/icons/48x48.png new file mode 100644 index 0000000..6bb9be4 Binary files /dev/null and b/packages/desktop/build/icons/48x48.png differ diff --git a/packages/desktop/build/icons/512x512.png b/packages/desktop/build/icons/512x512.png new file mode 100644 index 0000000..5815f8d Binary files /dev/null and b/packages/desktop/build/icons/512x512.png differ diff --git a/packages/desktop/build/icons/64x64.png b/packages/desktop/build/icons/64x64.png new file mode 100644 index 0000000..b286ee6 Binary files /dev/null and b/packages/desktop/build/icons/64x64.png differ diff --git a/packages/desktop/build/installer.nsh b/packages/desktop/build/installer.nsh new file mode 100644 index 0000000..ed2954d --- /dev/null +++ b/packages/desktop/build/installer.nsh @@ -0,0 +1,8 @@ +!macro customInit + IfFileExists "$INSTDIR\Hermes Studio.exe" 0 hermesStudioStopDone + DetailPrint "Stopping Hermes Studio..." + nsExec::ExecToLog '"$INSTDIR\Hermes Studio.exe" --quit' + Sleep 5000 + nsExec::ExecToLog 'taskkill.exe /IM "Hermes Studio.exe" /T /F' + hermesStudioStopDone: +!macroend diff --git a/packages/desktop/build/runtime-release.json b/packages/desktop/build/runtime-release.json new file mode 100644 index 0000000..c5b3b64 --- /dev/null +++ b/packages/desktop/build/runtime-release.json @@ -0,0 +1,3 @@ +{ + "tag": "hermes-0.15.2-runtime" +} diff --git a/packages/desktop/build/trayTemplate.png b/packages/desktop/build/trayTemplate.png new file mode 100644 index 0000000..c458a32 Binary files /dev/null and b/packages/desktop/build/trayTemplate.png differ diff --git a/packages/desktop/build/trayWindows.png b/packages/desktop/build/trayWindows.png new file mode 100644 index 0000000..7c3c63e Binary files /dev/null and b/packages/desktop/build/trayWindows.png differ diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml new file mode 100644 index 0000000..c7189e6 --- /dev/null +++ b/packages/desktop/electron-builder.yml @@ -0,0 +1,85 @@ +appId: com.hermeswebui.studio +productName: Hermes Studio +copyright: Copyright © 2026 + +directories: + output: release + buildResources: build + +publish: + provider: generic + url: https://download.ekkolearnai.com + +# Don't auto-prune our root node_modules; we curate `files` and `extraResources` ourselves. +buildDependenciesFromSource: false +nodeGypRebuild: false +npmRebuild: false + +files: + - "dist/**/*" + - "package.json" + - "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,LICENSE,LICENSE.txt,license,*.d.ts}" + - "!**/node_modules/.bin" + - "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}" + +# Web UI source (built dist) lives outside the asar. Python/Node/Git runtime +# assets are downloaded into the user's Web UI home on first launch. +# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root. +extraResources: + - from: "build" + to: "build" + filter: + - "icon.png" + - "icon.ico" + - "trayTemplate.png" + - "trayWindows.png" + - "runtime-release.json" + - from: "../.." + to: "webui" + filter: + - "package.json" + - "dist/**" + - "node_modules/**" + # Drop other-platform node-pty prebuilds (saves ~45MB) + - "!node_modules/node-pty/prebuilds/!(${platform}-${arch})/**" + - "!node_modules/node-pty/build/**" + - "!packages/desktop/**" + - "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}" + - "!node_modules/**/*.md" + +asarUnpack: + - "**/*.node" + +mac: + target: + - target: dmg + arch: [arm64, x64] + - target: zip + arch: [arm64, x64] + category: public.app-category.developer-tools + hardenedRuntime: true + gatekeeperAssess: false + notarize: true + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" + +win: + target: + - target: nsis + arch: [x64] + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" + +linux: + icon: build/icons + target: + - target: AppImage + arch: [x64, arm64] + - target: deb + arch: [x64] # fpm has no arm64 binary; deb only on x64 + category: Development + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + perMachine: false + include: build/installer.nsh diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json new file mode 100644 index 0000000..0b219f0 --- /dev/null +++ b/packages/desktop/package-lock.json @@ -0,0 +1,4616 @@ +{ + "name": "hermes-studio", + "version": "0.6.9", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermes-studio", + "version": "0.6.9", + "license": "BSL-1.1", + "dependencies": { + "electron-updater": "^6.3.9" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "electron": "^42.3.0", + "electron-builder": "^25.1.8", + "typescript": "~5.6.3" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/brace-expansion": { + "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": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "42.3.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.0.tgz", + "integrity": "sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 0000000..a37e595 --- /dev/null +++ b/packages/desktop/package.json @@ -0,0 +1,42 @@ +{ + "name": "hermes-studio", + "version": "0.6.9", + "description": "Hermes Studio desktop distribution with bundled Python runtime and hermes-agent", + "homepage": "https://hermes-studio.ai", + "author": { + "name": "Hermes Studio Contributors", + "email": "noreply@hermes-studio.local" + }, + "license": "BSL-1.1", + "private": true, + "main": "dist/main/index.js", + "scripts": { + "build:main": "tsc -p tsconfig.json", + "build": "npm run build:main", + "fetch:node": "node scripts/fetch-node.mjs", + "fetch:git": "node scripts/fetch-git.mjs", + "fetch:python": "node scripts/fetch-python.mjs", + "install:hermes": "node scripts/install-hermes.mjs", + "patch:hermes": "node scripts/apply-hermes-patches.mjs", + "write:runtime-release": "node scripts/write-runtime-release.mjs", + "prepare:runtime": "npm run fetch:node && npm run fetch:git && npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python", + "prepare:python": "npm run prepare:runtime", + "package:runtime": "node scripts/package-runtime.mjs", + "runtime:asset-name": "node scripts/runtime-asset-name.mjs", + "prune:python": "node scripts/prune-python.mjs", + "dev": "npm run build:main && electron .", + "dist": "npm run build && electron-builder", + "dist:mac": "npm run build && electron-builder --mac", + "dist:win": "npm run build && electron-builder --win", + "dist:linux": "npm run build && electron-builder --linux" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "electron": "^42.3.0", + "electron-builder": "^25.1.8", + "typescript": "~5.6.3" + }, + "dependencies": { + "electron-updater": "^6.3.9" + } +} diff --git a/packages/desktop/scripts/apply-hermes-patches.mjs b/packages/desktop/scripts/apply-hermes-patches.mjs new file mode 100644 index 0000000..73aba6f --- /dev/null +++ b/packages/desktop/scripts/apply-hermes-patches.mjs @@ -0,0 +1,342 @@ +#!/usr/bin/env node +// Apply locally-curated patches to hermes-agent inside the bundled venv. +// Each patch is idempotent: a marker string is searched for first, and the +// edit is skipped if the patch is already in place. +// +// Run after `install-hermes.mjs`. Designed to be safe to re-run. + +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { platform as osPlatform, arch as osArch } from 'node:os' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`) + +// Allow the CI sanity-check path to point at a temp install dir without +// the full bundled-Python layout (e.g. `pip install --target /tmp/foo`). +const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? ( + TARGET_OS === 'win32' + ? join(PY_DIR, 'Lib', 'site-packages') + : (() => { + const libDir = join(PY_DIR, 'lib') + if (!existsSync(libDir)) throw new Error(`No lib dir at ${libDir}`) + const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n)) + if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`) + return join(libDir, py, 'site-packages') + })() +) + +const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py') +const browserToolPath = join(sitePkgs, 'tools', 'browser_tool.py') +const sitecustomizePath = join(sitePkgs, 'sitecustomize.py') +if (!existsSync(dtPath)) { + console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`) + process.exit(1) +} + +let src = readFileSync(dtPath, 'utf-8') +const before = src +let applied = 0 +let skipped = 0 + +function patch(id, marker, find, replace) { + if (src.includes(marker)) { + console.log(` · ${id} (already applied)`) + skipped++ + return + } + if (!src.includes(find)) { + console.log(` ✗ ${id} (anchor not found — upstream changed?)`) + return + } + src = src.replace(find, replace) + console.log(` ✓ ${id}`) + applied++ +} + +function patchText(text, id, marker, find, replace) { + if (text.includes(marker)) { + console.log(` · ${id} (already applied)`) + skipped++ + return text + } + if (!text.includes(find)) { + console.log(` ✗ ${id} (anchor not found — upstream changed?)`) + return text + } + applied++ + console.log(` ✓ ${id}`) + return text.replace(find, replace) +} + +console.log(`Patching ${dtPath}`) + +// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships +// `_IncomingHandler.pre_start()` natively (present in 0.15.x and on main), so +// re-adding it just injected a duplicate method. + +// ── dt-card-tpl-env ───────────────────────────────────────────── +// Fall back to DINGTALK_CARD_TEMPLATE_ID env var. +patch( + 'dt-card-tpl-env', + '# patch:dt-card-tpl-env', + ` self._card_template_id: Optional[str] = extra.get("card_template_id")`, + ` # patch:dt-card-tpl-env — env var fallback + self._card_template_id: Optional[str] = ( + extra.get("card_template_id") or os.getenv("DINGTALK_CARD_TEMPLATE_ID") + )`, +) + +// ── dt-card-before-webhook ────────────────────────────────────── +// Try AI Card *before* validating session_webhook — Card SDK does not need +// a webhook URL. Move the lookup of `current_message` and the AI Card block +// up before the webhook gate. +patch( + 'dt-card-before-webhook', + '# patch:dt-card-before-webhook', + ` # Check metadata first (for direct webhook sends) + session_webhook = metadata.get("session_webhook") + if not session_webhook: + webhook_info = self._get_valid_webhook(chat_id) + if not webhook_info: + logger.warning( + "[%s] No valid session_webhook for chat_id=%s", + self.name, chat_id, + ) + return SendResult( + success=False, + error="No valid session_webhook available. Reply must follow an incoming message.", + ) + session_webhook, _ = webhook_info + + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + # Look up the inbound message for this chat (for AI Card routing) + current_message = self._message_contexts.get(chat_id)`, + ` # patch:dt-card-before-webhook — try AI Card first; webhook gate moved below. + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + # Look up the inbound message for this chat (for AI Card routing) + current_message = self._message_contexts.get(chat_id) + session_webhook = metadata.get("session_webhook")`, +) + +// The above leaves the existing AI Card block intact; we still need to add +// the deferred webhook gate AFTER the AI Card attempt. The original code +// had `logger.debug("[%s] Sending via webhook", self.name)` immediately +// after the AI Card fallback log. Insert the gate right before that. +patch( + 'dt-card-before-webhook-gate', + '# patch:dt-card-before-webhook-gate', + ` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name) + + logger.debug("[%s] Sending via webhook", self.name)`, + ` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name) + + # patch:dt-card-before-webhook-gate — webhook required only for fallback path + if not session_webhook: + webhook_info = self._get_valid_webhook(chat_id) + if not webhook_info: + logger.warning( + "[%s] No valid session_webhook for chat_id=%s", + self.name, chat_id, + ) + return SendResult( + success=False, + error="No valid session_webhook available. Reply must follow an incoming message.", + ) + session_webhook, _ = webhook_info + + logger.debug("[%s] Sending via webhook", self.name)`, +) + +// ── dt-dm-robot-code ──────────────────────────────────────────── +patch( + 'dt-dm-robot-code', + '# patch:dt-dm-robot-code', + ` im_robot_open_deliver_model=( + dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel( + space_type="IM_ROBOT", + ) + ),`, + ` im_robot_open_deliver_model=( + dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel( + space_type="IM_ROBOT", + robot_code=self._robot_code, # patch:dt-dm-robot-code + ) + ),`, +) + +// ── dt-card-autolayout ────────────────────────────────────────── +patch( + 'dt-card-autolayout', + '# patch:dt-card-autolayout', + ` card_data=dingtalk_card_models.CreateCardRequestCardData( + card_param_map={"content": ""}, + ),`, + ` card_data=dingtalk_card_models.CreateCardRequestCardData( + # patch:dt-card-autolayout — wide-screen via sys_full_json_obj + card_param_map={ + "content": "", + "sys_full_json_obj": json.dumps({"config": {"autoLayout": True}}), + }, + ),`, +) + +if (src !== before) { + writeFileSync(dtPath, src) +} + +if (existsSync(browserToolPath)) { + console.log(`Patching ${browserToolPath}`) + let browserSrc = readFileSync(browserToolPath, 'utf-8') + const browserBefore = browserSrc + + browserSrc = patchText( + browserSrc, + 'browser-stdout-decode-fallback', + '# patch:browser-stdout-decode-fallback', + `from hermes_cli.config import cfg_get\n`, + `from hermes_cli.config import cfg_get + +# patch:browser-stdout-decode-fallback +def _hermes_read_browser_output(path: str) -> str: + data = Path(path).read_bytes() + for encoding in ("utf-8", "gb18030"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + pass + return data.decode("utf-8", errors="replace") +`, + ) + + for (const [id, find, replace] of [ + [ + 'browser-fallback-stdout-read', + ` with open(stdout_path, "r", encoding="utf-8") as f: + stdout = f.read().strip()`, + ` # patch:browser-fallback-stdout-read + stdout = _hermes_read_browser_output(stdout_path).strip()`, + ], + [ + 'browser-command-stdout-read', + ` with open(stdout_path, "r", encoding="utf-8") as f: + stdout = f.read() + with open(stderr_path, "r", encoding="utf-8") as f: + stderr = f.read()`, + ` # patch:browser-command-stdout-read + stdout = _hermes_read_browser_output(stdout_path) + stderr = _hermes_read_browser_output(stderr_path)`, + ], + ]) { + browserSrc = patchText( + browserSrc, + id, + `# patch:${id}`, + find, + replace, + ) + } + + if (browserSrc !== browserBefore) { + writeFileSync(browserToolPath, browserSrc) + } +} + +const brotlicffiCompatMarker = '# patch:brotlicffi-error-compat' +const brotlicffiCompat = ` +${brotlicffiCompatMarker} +try: + import brotlicffi as _hermes_brotlicffi + if not hasattr(_hermes_brotlicffi, "error"): + _hermes_brotlicffi.error = ( + getattr(_hermes_brotlicffi, "Error", None) + or getattr(_hermes_brotlicffi, "BrotliError", None) + or Exception + ) +except Exception: + pass +` + +const desktopHiddenSubprocessMarker = '# patch:desktop-hidden-subprocess-defaults' +const desktopHiddenSubprocessDefaults = ` +${desktopHiddenSubprocessMarker} +try: + import os as _hermes_os + if _hermes_os.name == "nt" and _hermes_os.environ.get("HERMES_DESKTOP", "").strip().lower() == "true": + import asyncio as _hermes_asyncio + import subprocess as _hermes_subprocess + if not getattr(_hermes_subprocess, "_hermes_desktop_hidden_defaults_installed", False): + _hermes_create_no_window = getattr(_hermes_subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000 + + def _hermes_apply_hidden_process_options(kwargs): + flags = kwargs.get("creationflags", 0) or 0 + try: + kwargs["creationflags"] = int(flags) | _hermes_create_no_window + except Exception: + kwargs["creationflags"] = _hermes_create_no_window + + startupinfo = kwargs.get("startupinfo") + if startupinfo is None: + try: + startupinfo = _hermes_subprocess.STARTUPINFO() + except Exception: + return + kwargs["startupinfo"] = startupinfo + try: + startupinfo.dwFlags |= getattr(_hermes_subprocess, "STARTF_USESHOWWINDOW", 1) + startupinfo.wShowWindow = getattr(_hermes_subprocess, "SW_HIDE", 0) + except Exception: + pass + + _hermes_original_popen = _hermes_subprocess.Popen + _hermes_original_create_subprocess_exec = _hermes_asyncio.create_subprocess_exec + _hermes_original_create_subprocess_shell = _hermes_asyncio.create_subprocess_shell + + class _HermesHiddenPopen(_hermes_original_popen): + def __init__(self, *args, **kwargs): + _hermes_apply_hidden_process_options(kwargs) + super().__init__(*args, **kwargs) + + async def _hermes_hidden_create_subprocess_exec(*args, **kwargs): + _hermes_apply_hidden_process_options(kwargs) + return await _hermes_original_create_subprocess_exec(*args, **kwargs) + + async def _hermes_hidden_create_subprocess_shell(*args, **kwargs): + _hermes_apply_hidden_process_options(kwargs) + return await _hermes_original_create_subprocess_shell(*args, **kwargs) + + _hermes_subprocess.Popen = _HermesHiddenPopen + _hermes_asyncio.create_subprocess_exec = _hermes_hidden_create_subprocess_exec + _hermes_asyncio.create_subprocess_shell = _hermes_hidden_create_subprocess_shell + _hermes_subprocess._hermes_desktop_hidden_defaults_installed = True +except Exception: + pass +` + +function appendSitecustomizePatch(id, marker, body) { + const sitecustomize = existsSync(sitecustomizePath) ? readFileSync(sitecustomizePath, 'utf-8') : '' + if (sitecustomize.includes(marker)) { + console.log(` · ${id} (already applied)`) + skipped++ + return + } + const nextSitecustomize = `${sitecustomize.replace(/\s*$/, '')}\n${body.trim()}\n` + writeFileSync(sitecustomizePath, nextSitecustomize) + console.log(` ✓ ${id}`) + applied++ +} + +appendSitecustomizePatch('brotlicffi-error-compat', brotlicffiCompatMarker, brotlicffiCompat) +appendSitecustomizePatch('desktop-hidden-subprocess-defaults', desktopHiddenSubprocessMarker, desktopHiddenSubprocessDefaults) + +console.log(`Done. Applied ${applied}, skipped ${skipped}.`) diff --git a/packages/desktop/scripts/fetch-git.mjs b/packages/desktop/scripts/fetch-git.mjs new file mode 100644 index 0000000..5a4bf24 --- /dev/null +++ b/packages/desktop/scripts/fetch-git.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Download Git for Windows MinGit for Windows builds. Other platforms create +// an empty resource directory so electron-builder can use the same resource map. +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os' +import { spawnSync } from 'node:child_process' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const OUT_DIR = resolve(ROOT, 'resources', 'git', `${OS_LABEL}-${TARGET_ARCH}`) + +mkdirSync(OUT_DIR, { recursive: true }) + +if (TARGET_OS !== 'win32') { + writeFileSync(resolve(OUT_DIR, '.placeholder'), 'Git for Windows is only bundled on Windows.\n') + console.log(`Git resource placeholder ready at ${OUT_DIR}`) + process.exit(0) +} + +if (TARGET_ARCH !== 'x64') { + console.error(`Unsupported Git for Windows target: ${TARGET_OS}-${TARGET_ARCH}`) + process.exit(1) +} + +if (existsSync(resolve(OUT_DIR, 'cmd', 'git.exe'))) { + console.log(`Git for Windows already present at ${OUT_DIR}, skipping`) + process.exit(0) +} + +async function latestMinGitUrl() { + if (process.env.GIT_FOR_WINDOWS_URL?.trim()) return process.env.GIT_FOR_WINDOWS_URL.trim() + + const headers = { 'User-Agent': 'hermes-studio-desktop-build' } + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}` + + const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', { + headers, + }) + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}`) + } + const release = await response.json() + const asset = release.assets?.find(candidate => + typeof candidate?.name === 'string' + && /^MinGit-.*-64-bit\.zip$/.test(candidate.name) + && typeof candidate.browser_download_url === 'string', + ) + if (!asset) throw new Error('Could not find MinGit 64-bit zip in latest Git for Windows release') + return asset.browser_download_url +} + +let url +try { + url = await latestMinGitUrl() +} catch (err) { + console.error(`Failed to resolve Git for Windows download URL: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +} + +const file = url.split('/').pop() || 'mingit.zip' +const archivePath = resolve(tmpdir(), file) + +console.log(`Fetching ${url}`) +const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' }) +if (curl.status !== 0) { + console.error('curl failed') + process.exit(curl.status ?? 1) +} + +console.log(`Extracting into ${OUT_DIR}`) +const extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR], { stdio: 'inherit' }) +if (extract.status !== 0) { + console.error('extract failed') + process.exit(extract.status ?? 1) +} + +rmSync(archivePath, { force: true }) +console.log(`Git for Windows ready at ${OUT_DIR}`) diff --git a/packages/desktop/scripts/fetch-node.mjs b/packages/desktop/scripts/fetch-node.mjs new file mode 100644 index 0000000..724be1e --- /dev/null +++ b/packages/desktop/scripts/fetch-node.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Download a portable Node.js runtime for the current (or target) platform/arch +// and extract into resources/node/-/. +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os' +import { spawnSync } from 'node:child_process' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const NODE_VERSION = (process.env.HERMES_DESKTOP_NODE_VERSION || process.env.NODE_VERSION || process.versions.node).replace(/^v/, '') + +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const OUT_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`) + +const DIST_PLATFORM = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'darwin' : TARGET_OS +const DIST_ARCH = TARGET_ARCH === 'x64' ? 'x64' : TARGET_ARCH === 'arm64' ? 'arm64' : '' +if (!DIST_ARCH || !['win', 'darwin', 'linux'].includes(DIST_PLATFORM)) { + console.error(`Unsupported target: ${TARGET_OS}-${TARGET_ARCH}`) + process.exit(1) +} + +const ext = TARGET_OS === 'win32' ? 'zip' : 'tar.gz' +const file = `node-v${NODE_VERSION}-${DIST_PLATFORM}-${DIST_ARCH}.${ext}` +const baseUrl = (process.env.NODE_DIST_BASE_URL || 'https://nodejs.org/dist').replace(/\/$/, '') +const url = `${baseUrl}/v${NODE_VERSION}/${file}` +const marker = TARGET_OS === 'win32' ? 'node.exe' : join('bin', 'node') + +if (existsSync(resolve(OUT_DIR, marker))) { + console.log(`Node.js already present at ${OUT_DIR}, skipping`) + process.exit(0) +} + +mkdirSync(OUT_DIR, { recursive: true }) +const archivePath = resolve(tmpdir(), file) + +console.log(`Fetching ${url}`) +const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' }) +if (curl.status !== 0) { + console.error('curl failed') + process.exit(curl.status ?? 1) +} + +console.log(`Extracting into ${OUT_DIR}`) +let extract +if (TARGET_OS === 'win32') { + extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' }) +} else { + extract = spawnSync('tar', ['-xzf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' }) +} +if (extract.status !== 0) { + console.error('extract failed') + process.exit(extract.status ?? 1) +} + +rmSync(archivePath, { force: true }) +console.log(`Node.js ready at ${OUT_DIR}`) diff --git a/packages/desktop/scripts/fetch-python.mjs b/packages/desktop/scripts/fetch-python.mjs new file mode 100644 index 0000000..6bc389d --- /dev/null +++ b/packages/desktop/scripts/fetch-python.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Download python-build-standalone for the current (or target) platform/arch +// and extract into resources/python/-/ +import { mkdirSync, existsSync, createWriteStream, rmSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +// Pin a known-good python-build-standalone release. Bump intentionally. +const PBS_TAG = process.env.PBS_TAG || '20260510' +const PYTHON_VERSION = process.env.PBS_PY || '3.12.13' + +const TARGET_OS = process.env.TARGET_OS || osPlatform() // darwin | win32 | linux +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() // arm64 | x64 + +const TRIPLE_MAP = { + 'darwin-arm64': 'aarch64-apple-darwin', + 'darwin-x64': 'x86_64-apple-darwin', + 'win32-x64': 'x86_64-pc-windows-msvc', + 'linux-x64': 'x86_64-unknown-linux-gnu', + 'linux-arm64': 'aarch64-unknown-linux-gnu', +} + +const key = `${TARGET_OS}-${TARGET_ARCH}` +const triple = TRIPLE_MAP[key] +if (!triple) { + console.error(`Unsupported target: ${key}`) + process.exit(1) +} + +// electron-builder uses `mac`/`win`/`linux` for `${os}` — match that +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const OUT_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`) +const FLAVOR = 'install_only_stripped' +const FILE = `cpython-${PYTHON_VERSION}+${PBS_TAG}-${triple}-${FLAVOR}.tar.gz` +const PBS_BASE_URL = (process.env.PBS_BASE_URL || 'https://github.com/astral-sh/python-build-standalone/releases/download').replace(/\/$/, '') +const URL = `${PBS_BASE_URL}/${PBS_TAG}/${FILE}` + +if (existsSync(resolve(OUT_DIR, 'python')) || existsSync(resolve(OUT_DIR, 'bin', 'python3'))) { + console.log(`✓ Python already present at ${OUT_DIR}, skipping`) + process.exit(0) +} + +mkdirSync(OUT_DIR, { recursive: true }) +const tarPath = resolve(tmpdir(), FILE) + +console.log(`→ Fetching ${URL}`) +const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', tarPath, URL], { stdio: 'inherit' }) +if (curl.status !== 0) { + console.error('curl failed') + process.exit(curl.status ?? 1) +} + +console.log(`→ Extracting into ${OUT_DIR}`) +// PBS tarballs unpack to a top-level "python/" directory; --strip-components=1 flattens it +const tar = spawnSync('tar', ['-xzf', tarPath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' }) +if (tar.status !== 0) { + console.error('tar failed') + process.exit(tar.status ?? 1) +} + +rmSync(tarPath, { force: true }) +console.log(`✓ Python ready at ${OUT_DIR}`) diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs new file mode 100644 index 0000000..5ac1199 --- /dev/null +++ b/packages/desktop/scripts/install-hermes.mjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node +// Install hermes-agent into the bundled Python at resources/python/-/. +// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip. +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os' +import { hermesVersion } from './runtime-config.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const HERMES_VERSION = hermesVersion() +// Match the packaged runtime to the channel list exposed at /hermes/channels. +// Telegram, Discord, and Slack are covered by "messaging". We intentionally +// install Matrix's plaintext deps below instead of using the "matrix" extra: +// that extra pulls mautrix[encryption] -> python-olm, which needs a fragile +// native build on desktop packaging machines. WhatsApp, QQBot, and Weixin do +// not expose dedicated hermes-agent extras; their deps are covered by base or +// the channel extras below. +const HERMES_EXTRAS = [ + 'mcp', + 'messaging', + 'slack', + 'wecom', + 'dingtalk', + 'feishu', +].join(',') +const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[${HERMES_EXTRAS}]==${HERMES_VERSION}` +const EXTRA_PYTHON_PACKAGES = splitPackageList( + process.env.HERMES_EXTRA_PYTHON_PACKAGES || [ + 'websockets', + 'mautrix==0.21.0', + 'Markdown==3.10.2', + 'aiosqlite==0.22.1', + 'asyncpg==0.31.0', + 'aiohttp-socks==0.11.0', + ].join(' '), +) +const BROWSER_PACKAGES = splitPackageList( + process.env.HERMES_BROWSER_PACKAGES || 'agent-browser@^0.26.0 @askjo/camofox-browser@^1.5.2', +) +const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1' + || process.env.HERMES_SKIP_BROWSER_RUNTIME?.toLowerCase() === 'true' + +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`) +const NODE_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`) +const NODE_PREFIX = resolve(PY_DIR, 'node') +const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser') +const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright') + +const pyBin = TARGET_OS === 'win32' + ? resolve(PY_DIR, 'python.exe') + : resolve(PY_DIR, 'bin', 'python3') + +if (!existsSync(pyBin)) { + console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`) + process.exit(1) +} + +function hasUv() { + const r = spawnSync('uv', ['--version'], { stdio: 'ignore' }) + return r.status === 0 +} + +function splitPackageList(value) { + return value + .split(/[,\s]+/) + .map(part => part.trim()) + .filter(Boolean) +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }) + if (result.status !== 0) process.exit(result.status ?? 1) + return result +} + +function optionalRun(command, args, options = {}) { + return spawnSync(command, args, { stdio: 'inherit', ...options }) +} + +function commandInvocation(command) { + if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) { + const cmdCommand = /[\s&()[\]{}^=;!'+,`~]/.test(command) ? `"${command}"` : command + return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', cmdCommand] } + } + return { command, argsPrefix: [] } +} + +function runInvocation(invocation, args, options = {}) { + return run(invocation.command, [...invocation.argsPrefix, ...args], options) +} + +function optionalRunInvocation(invocation, args, options = {}) { + return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options) +} + +function pythonBuildEnv() { + if (TARGET_OS !== 'darwin') return process.env + + const env = { ...process.env } + if (!env.AR && existsSync('/usr/bin/ar')) env.AR = '/usr/bin/ar' + if (!env.RANLIB && existsSync('/usr/bin/ranlib')) env.RANLIB = '/usr/bin/ranlib' + return env +} + +function installPythonPackages(packages, label) { + if (packages.length === 0) return + const env = pythonBuildEnv() + if (hasUv()) { + console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`) + run('uv', [ + 'pip', 'install', + '--python', pyBin, + ...packages, + ], { env }) + } else { + console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`) + run(pyBin, [ + '-m', 'pip', 'install', + ...packages, + '--no-warn-script-location', + '--disable-pip-version-check', + ], { env }) + } +} + +function npmCommand() { + const bundled = TARGET_OS === 'win32' + ? resolve(NODE_DIR, 'npm.cmd') + : resolve(NODE_DIR, 'bin', 'npm') + const candidates = TARGET_OS === 'win32' + ? [bundled, 'npm.cmd', 'npm.exe', 'npm'] + : [bundled, 'npm'] + for (const candidate of candidates) { + if (candidate === bundled && !existsSync(candidate)) continue + const invocation = commandInvocation(candidate) + const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore', env: browserRuntimeEnv(false) }) + if (result.status === 0) return invocation + } + return null +} + +function agentBrowserCommand() { + if (TARGET_OS === 'win32') { + return resolve(NODE_PREFIX, 'agent-browser.cmd') + } + return resolve(NODE_PREFIX, 'bin', 'agent-browser') +} + +function browserRuntimeEnv(includeAgentBrowser = true) { + const bundledNodeBin = TARGET_OS === 'win32' + ? NODE_DIR + : resolve(NODE_DIR, 'bin') + const nodePath = TARGET_OS === 'win32' + ? NODE_PREFIX + : resolve(NODE_PREFIX, 'bin') + const inheritedPath = process.env.PATH || process.env.Path || '' + const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH' + const browserExecutable = includeAgentBrowser ? ensureBundledBrowserExecutable() : null + const pathEntries = includeAgentBrowser + ? [nodePath, bundledNodeBin, inheritedPath] + : [bundledNodeBin, inheritedPath] + const env = { + ...process.env, + [pathKey]: pathEntries.filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'), + HERMES_AGENT_NODE: TARGET_OS === 'win32' ? resolve(NODE_DIR, 'node.exe') : resolve(NODE_DIR, 'bin', 'node'), + HERMES_AGENT_NODE_ROOT: NODE_DIR, + AGENT_BROWSER_HOME, + PLAYWRIGHT_BROWSERS_PATH, + } + if (browserExecutable) env.AGENT_BROWSER_EXECUTABLE_PATH = browserExecutable + return env +} + +function bundledBrowserExecutableNames() { + if (TARGET_OS === 'win32') return new Set(['chrome.exe']) + if (TARGET_OS === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome']) + return new Set(['chrome', 'chromium', 'chromium-browser']) +} + +function defaultAgentBrowserHomes() { + const candidates = [ + process.env.USERPROFILE, + process.env.UserProfile, + process.env.HOME, + process.env.HOMEDRIVE && process.env.HOMEPATH + ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` + : null, + osHomedir(), + ] + return Array.from(new Set( + candidates + .map(home => home?.trim()) + .filter(Boolean) + .map(home => resolve(home, '.agent-browser')), + )) +} + +function findBrowserInstallInHome(home) { + const names = bundledBrowserExecutableNames() + const browsersDir = join(home, 'browsers') + const bundleDirs = [] + + if (existsSync(browsersDir)) { + try { + for (const entry of readdirSync(browsersDir, { withFileTypes: true })) { + if (entry.isDirectory()) bundleDirs.push(join(browsersDir, entry.name)) + } + } catch {} + } + + for (const bundleDir of bundleDirs) { + const executable = findBrowserExecutableUnder(bundleDir, names) + if (executable) return { executable, bundleDir } + } + + return null +} + +function findBrowserExecutableUnder(root, names) { + const stack = [root].filter(existsSync) + const visited = new Set() + + while (stack.length > 0) { + const dir = stack.pop() + if (!dir || visited.has(dir)) continue + visited.add(dir) + + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isFile() && names.has(entry.name)) return path + if (entry.isDirectory()) stack.push(path) + } + } + + return null +} + +function findBundledBrowserExecutable() { + return findBrowserInstallInHome(AGENT_BROWSER_HOME)?.executable ?? null +} + +function ensureBundledBrowserExecutable() { + const bundled = findBrowserInstallInHome(AGENT_BROWSER_HOME) + if (bundled) return bundled.executable + + const searchedHomes = [] + for (const fallbackHome of defaultAgentBrowserHomes()) { + if (fallbackHome === AGENT_BROWSER_HOME) continue + searchedHomes.push(fallbackHome) + + const fallback = findBrowserInstallInHome(fallbackHome) + if (!fallback) continue + + const targetBrowsersDir = join(AGENT_BROWSER_HOME, 'browsers') + const targetBundleDir = join(targetBrowsersDir, basename(fallback.bundleDir)) + mkdirSync(targetBrowsersDir, { recursive: true }) + cpSync(fallback.bundleDir, targetBundleDir, { recursive: true, force: true, verbatimSymlinks: true }) + console.log(`✓ copied Chrome bundle into ${targetBundleDir}`) + + return findBundledBrowserExecutable() + } + + if (searchedHomes.length > 0) { + console.warn(`! no Chrome bundle found in fallback agent-browser homes: ${searchedHomes.join(', ')}`) + } + return null +} + +function sitePackagesDir() { + if (TARGET_OS === 'win32') { + return resolve(PY_DIR, 'Lib', 'site-packages') + } + const libDir = resolve(PY_DIR, 'lib') + const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n)) + if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`) + return resolve(libDir, py, 'site-packages') +} + +function pythonModuleExists(moduleName) { + const result = optionalRun(pyBin, [ + '-c', + `import importlib.util, sys; sys.exit(0 if importlib.util.find_spec(${JSON.stringify(moduleName)}) else 1)`, + ], { stdio: 'ignore' }) + return result.status === 0 +} + +function removeBrokenDashboardAuthPlugin() { + if (pythonModuleExists('hermes_cli.dashboard_auth')) return + + const pluginDir = resolve(sitePackagesDir(), 'plugins', 'dashboard_auth', 'nous') + if (!existsSync(pluginDir)) return + + rmSync(pluginDir, { recursive: true, force: true }) + console.warn( + '! Removed bundled dashboard_auth/nous plugin because hermes_cli.dashboard_auth is missing from the hermes-agent package', + ) +} + +function installBrowserRuntime() { + if (SKIP_BROWSER_RUNTIME) { + console.warn('! Skipping bundled browser runtime because HERMES_SKIP_BROWSER_RUNTIME is set') + return + } + if (BROWSER_PACKAGES.length === 0) { + console.warn('! Skipping bundled browser runtime because HERMES_BROWSER_PACKAGES is empty') + return + } + + const npm = npmCommand() + if (!npm) { + console.error('npm not found; bundled browser runtime requires Node.js/npm') + process.exit(1) + } + + console.log(`→ Installing browser runtime via npm prefix ${NODE_PREFIX}`) + runInvocation(npm, [ + 'install', + '-g', + '--prefix', + NODE_PREFIX, + '--silent', + '--ignore-scripts', + ...BROWSER_PACKAGES, + ]) + + const ab = agentBrowserCommand() + if (!existsSync(ab)) { + console.error(`agent-browser binary not found at ${ab} after npm install`) + process.exit(1) + } + + console.log(`→ Installing Chromium for bundled agent-browser at ${AGENT_BROWSER_HOME}`) + runInvocation(commandInvocation(ab), ['install'], { env: browserRuntimeEnv() }) + + const browserExecutable = ensureBundledBrowserExecutable() + if (!browserExecutable) { + console.error(`Bundled Chrome executable not found under ${AGENT_BROWSER_HOME} after agent-browser install`) + process.exit(1) + } + console.log(`✓ bundled Chrome executable available at ${browserExecutable}`) +} + +installPythonPackages([HERMES_PACKAGE], 'hermes-agent') +installPythonPackages(EXTRA_PYTHON_PACKAGES, 'extra Python runtime packages') +removeBrokenDashboardAuthPlugin() +installBrowserRuntime() + +run(pyBin, [ + '-c', + [ + 'import importlib.util', + 'import mcp', + 'import tools.mcp_tool as t', + 'assert t._MCP_AVAILABLE', + 'assert importlib.util.find_spec("websockets") is not None', + ].join('; '), +]) + +const hermesBin = TARGET_OS === 'win32' + ? resolve(PY_DIR, 'Scripts', 'hermes.exe') + : resolve(PY_DIR, 'bin', 'hermes') +const hermesCheckCommand = TARGET_OS === 'win32' ? pyBin : hermesBin +const hermesCheckArgs = TARGET_OS === 'win32' ? ['-m', 'hermes_cli.main', '--version'] : ['--version'] + +if (!existsSync(hermesBin)) { + console.error(`hermes binary not found at ${hermesBin} after install`) + process.exit(1) +} + +// hermes-web-ui's agent-bridge searches for `run_agent.py` at /run_agent.py +// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface +// it at the venv root with a *relative* symlink so the venv stays portable when copied +// into the packaged .app/.exe (an absolute symlink would break the moment the bundle +// is moved to /Applications/...). +function siteRunAgentRelative() { + if (TARGET_OS === 'win32') { + return ['Lib', 'site-packages', 'run_agent.py'].join('\\') + } + return `${sitePackagesDir().slice(PY_DIR.length + 1)}/run_agent.py` +} +{ + const relSrc = siteRunAgentRelative() + const absSrc = resolve(PY_DIR, relSrc) + const dst = resolve(PY_DIR, 'run_agent.py') + if (existsSync(absSrc)) { + try { lstatSync(dst); unlinkSync(dst) } catch {} + if (TARGET_OS === 'win32') copyFileSync(absSrc, dst) + else symlinkSync(relSrc, dst) + console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`) + } else { + console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`) + } +} + +// Relocate: replace the pip-generated launcher (which embeds an absolute +// shebang to the build-time Python path) with a relative wrapper so the +// bundled venv works after being moved into the .app/.exe payload. +if (TARGET_OS === 'win32') { + // Windows: pip generates a .exe launcher that embeds a relative shebang + // already. Add a .cmd wrapper that prefers the colocated python.exe. + const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd') + writeFileSync( + cmdPath, + [ + '@echo off', + 'set "PY=%~dp0..\\python.exe"', + '"%PY%" -m hermes_cli.main %*', + ].join('\r\n'), + ) +} else { + const launcher = [ + '#!/bin/sh', + 'DIR="$(cd "$(dirname "$0")" && pwd)"', + 'exec "$DIR/python3" -m hermes_cli.main "$@"', + '', + ].join('\n') + writeFileSync(hermesBin, launcher, { mode: 0o755 }) + chmodSync(hermesBin, 0o755) + // Same for hermes-agent / hermes-acp (they all just dispatch into modules) + for (const [name, mod] of [ + ['hermes-agent', 'run_agent'], + ['hermes-acp', 'acp_adapter.entry'], + ]) { + const p = resolve(PY_DIR, 'bin', name) + if (existsSync(p)) { + writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 }) + chmodSync(p, 0o755) + } + } +} + +console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`) + +run(hermesCheckCommand, hermesCheckArgs) + +if (!SKIP_BROWSER_RUNTIME) { + run(pyBin, [ + '-c', + [ + 'import os, shutil', + `os.environ["PLAYWRIGHT_BROWSERS_PATH"] = ${JSON.stringify(PLAYWRIGHT_BROWSERS_PATH)}`, + 'from tools.browser_tool import _chromium_installed', + 'assert shutil.which("agent-browser") is not None', + 'assert _chromium_installed()', + ].join('; '), + ], { env: browserRuntimeEnv() }) +} + +if (SKIP_BROWSER_RUNTIME) { + console.log('✓ hermes Python, MCP, and websockets checks passed; browser runtime skipped') +} else { + console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed') +} diff --git a/packages/desktop/scripts/merge-mac-latest-yml.mjs b/packages/desktop/scripts/merge-mac-latest-yml.mjs new file mode 100644 index 0000000..20c2736 --- /dev/null +++ b/packages/desktop/scripts/merge-mac-latest-yml.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Merge two per-arch `latest-mac.yml` manifests (arm64 + x64) into a single +// manifest whose `files:` array lists BOTH dmgs, so electron-updater can pick +// the right architecture. +// +// Why this exists: our Release workflow builds macOS arm64 and x64 in separate +// matrix jobs, each emitting its own `latest-mac.yml`. When the publish job +// flattens the artifacts they collide and only one arch survives — leaving the +// other arch's users served a mismatched dmg (runs under Rosetta / fails the +// updater signature check). Merging the `files` lists fixes that. +// +// Usage: node merge-mac-latest-yml.mjs > latest-mac.yml +// +// The manifest shape electron-builder emits is small and regular, so we parse +// it with a focused extractor rather than pulling in a YAML dependency. + +import { readFileSync } from 'node:fs' + +function parse(path) { + const text = readFileSync(path, 'utf-8') + const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim() + const releaseDate = (text.match(/^releaseDate:\s*(.+)$/m) || [])[1]?.trim() + // Each entry under `files:` is `- url: ...` then indented sha512/size lines. + const files = [] + const re = /- url:\s*(\S+)\s*\n\s*sha512:\s*(\S+)\s*\n\s*size:\s*(\d+)/g + let m + while ((m = re.exec(text)) !== null) { + files.push({ url: m[1], sha512: m[2], size: Number(m[3]) }) + } + if (!version || files.length === 0) { + throw new Error(`Could not parse manifest at ${path} (version=${version}, files=${files.length})`) + } + return { version, releaseDate, files } +} + +const [, , aPath, bPath] = process.argv +if (!aPath || !bPath) { + console.error('Usage: merge-mac-latest-yml.mjs ') + process.exit(1) +} + +const a = parse(aPath) +const b = parse(bPath) + +if (a.version !== b.version) { + console.error(`Version mismatch: ${aPath}=${a.version} vs ${bPath}=${b.version}`) + process.exit(1) +} + +// Dedupe by url, preserving order (a first, then b). +const seen = new Set() +const files = [] +for (const f of [...a.files, ...b.files]) { + if (seen.has(f.url)) continue + seen.add(f.url) + files.push(f) +} + +// Top-level path/sha512/size are the legacy single-file fields; point them at +// the first entry (arm64 when arm64 is passed first). electron-updater >=6 +// selects from `files` by arch; these remain as a fallback for old clients. +const head = files[0] +const releaseDate = a.releaseDate || b.releaseDate + +const lines = [`version: ${a.version}`, 'files:'] +for (const f of files) { + lines.push(` - url: ${f.url}`) + lines.push(` sha512: ${f.sha512}`) + lines.push(` size: ${f.size}`) +} +lines.push(`path: ${head.url}`) +lines.push(`sha512: ${head.sha512}`) +if (releaseDate) lines.push(`releaseDate: ${releaseDate}`) +process.stdout.write(lines.join('\n') + '\n') diff --git a/packages/desktop/scripts/package-runtime.mjs b/packages/desktop/scripts/package-runtime.mjs new file mode 100644 index 0000000..4783ebf --- /dev/null +++ b/packages/desktop/scripts/package-runtime.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Package prepared Python/Node/Git runtime resources into a release asset. +import { + cpSync, + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { createHash } from 'node:crypto' +import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const PLATFORM = `${OS_LABEL}-${TARGET_ARCH}` +const OUT_DIR = resolve(ROOT, 'release', 'runtime') + +const PY_DIR = resolve(ROOT, 'resources', 'python', PLATFORM) +const NODE_DIR = resolve(ROOT, 'resources', 'node', PLATFORM) +const GIT_DIR = resolve(ROOT, 'resources', 'git', PLATFORM) +const pyBin = TARGET_OS === 'win32' + ? resolve(PY_DIR, 'python.exe') + : resolve(PY_DIR, 'bin', 'python3') + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }) + if (result.status !== 0) process.exit(result.status ?? 1) + return result +} + +function output(command, args) { + const result = spawnSync(command, args, { encoding: 'utf-8' }) + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout || '') + process.exit(result.status ?? 1) + } + return result.stdout.trim() +} + +async function sha256File(file) { + const hash = createHash('sha256') + await new Promise((resolvePromise, rejectPromise) => { + const stream = createReadStream(file) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', resolvePromise) + stream.on('error', rejectPromise) + }) + return hash.digest('hex') +} + +for (const dir of [PY_DIR, NODE_DIR]) { + if (!existsSync(dir)) { + console.error(`Runtime directory missing: ${dir}`) + process.exit(1) + } +} + +const hermesAgentVersion = output(pyBin, [ + '-c', + 'import importlib.metadata as m; print(m.version("hermes-agent"))', +]) +const assetName = `hermes-runtime-hermes-agent-${hermesAgentVersion}-${PLATFORM}.tar.gz` +const manifestName = `hermes-runtime-${PLATFORM}.json` + +mkdirSync(OUT_DIR, { recursive: true }) +const stage = mkdtempSync(join(tmpdir(), `hermes-runtime-${PLATFORM}-`)) + +try { + cpSync(PY_DIR, join(stage, 'python'), { recursive: true, force: true, verbatimSymlinks: true }) + cpSync(NODE_DIR, join(stage, 'node'), { recursive: true, force: true, verbatimSymlinks: true }) + if (existsSync(GIT_DIR)) { + cpSync(GIT_DIR, join(stage, 'git'), { recursive: true, force: true, verbatimSymlinks: true }) + } else { + mkdirSync(join(stage, 'git'), { recursive: true }) + writeFileSync(join(stage, 'git', '.placeholder'), 'Git for Windows is only bundled on Windows.\n') + } + + const runtimeManifest = { + schema: 1, + platform: PLATFORM, + targetOs: TARGET_OS, + targetArch: TARGET_ARCH, + hermesAgentVersion, + asset: { + name: assetName, + }, + } + writeFileSync(join(stage, 'runtime-manifest.json'), JSON.stringify(runtimeManifest, null, 2) + '\n') + + const assetPath = resolve(OUT_DIR, assetName) + rmSync(assetPath, { force: true }) + run('tar', ['-czf', assetPath, '-C', stage, '.']) + + const sha256 = await sha256File(assetPath) + writeFileSync(`${assetPath}.sha256`, `${sha256} ${assetName}\n`) + + const platformManifest = { + ...runtimeManifest, + createdAt: new Date().toISOString(), + asset: { + name: assetName, + sha256, + size: statSync(assetPath).size, + }, + } + writeFileSync(resolve(OUT_DIR, manifestName), JSON.stringify(platformManifest, null, 2) + '\n') + + console.log(`Runtime asset: ${assetPath}`) + console.log(`Runtime manifest: ${resolve(OUT_DIR, manifestName)}`) +} finally { + rmSync(stage, { recursive: true, force: true }) +} diff --git a/packages/desktop/scripts/prune-python.mjs b/packages/desktop/scripts/prune-python.mjs new file mode 100644 index 0000000..390f83d --- /dev/null +++ b/packages/desktop/scripts/prune-python.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Strip __pycache__, *.pyc, tests, idle, tkinter from bundled Python to shrink the installer. +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readdirSync, statSync, rmSync, existsSync } from 'node:fs' +import { platform as osPlatform, arch as osArch } from 'node:os' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS +const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`) + +if (!existsSync(PY_DIR)) { + console.error(`No bundled python at ${PY_DIR}`) + process.exit(1) +} + +const PRUNE_DIR_NAMES = new Set(['__pycache__', 'test', 'tests', 'idle_test', 'idlelib', 'turtledemo', 'tkinter', 'ensurepip']) +const PRUNE_FILE_SUFFIXES = ['.pyc', '.pyo'] + +let bytesFreed = 0 +function walk(dir) { + let entries + try { entries = readdirSync(dir) } catch { return } + for (const name of entries) { + const p = join(dir, name) + let st + try { st = statSync(p) } catch { continue } + if (st.isDirectory()) { + if (PRUNE_DIR_NAMES.has(name)) { + bytesFreed += dirSize(p) + rmSync(p, { recursive: true, force: true }) + } else { + walk(p) + } + } else if (PRUNE_FILE_SUFFIXES.some(s => name.endsWith(s))) { + bytesFreed += st.size + rmSync(p, { force: true }) + } + } +} +function dirSize(dir) { + let total = 0 + try { + for (const name of readdirSync(dir)) { + const p = join(dir, name) + const st = statSync(p) + total += st.isDirectory() ? dirSize(p) : st.size + } + } catch {} + return total +} + +walk(PY_DIR) +console.log(`✓ Pruned ~${(bytesFreed / 1024 / 1024).toFixed(1)} MB from ${PY_DIR}`) diff --git a/packages/desktop/scripts/runtime-asset-name.mjs b/packages/desktop/scripts/runtime-asset-name.mjs new file mode 100644 index 0000000..65e1173 --- /dev/null +++ b/packages/desktop/scripts/runtime-asset-name.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { arch as osArch, platform as osPlatform } from 'node:os' +import { hermesVersion, runtimeReleaseTag } from './runtime-config.mjs' + +const TARGET_OS = process.env.TARGET_OS || osPlatform() +const TARGET_ARCH = process.env.TARGET_ARCH || osArch() +const HERMES_VERSION = hermesVersion() +const RUNTIME_RELEASE_TAG = runtimeReleaseTag() +const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS + +if (!['win', 'mac', 'linux'].includes(OS_LABEL) || !['x64', 'arm64'].includes(TARGET_ARCH)) { + console.error(`Unsupported runtime target: ${TARGET_OS}-${TARGET_ARCH}`) + process.exit(1) +} + +const platform = `${OS_LABEL}-${TARGET_ARCH}` +const asset = `hermes-runtime-hermes-agent-${HERMES_VERSION}-${platform}.tar.gz` +const manifest = `hermes-runtime-${platform}.json` + +if (process.argv.includes('--manifest')) { + console.log(manifest) +} else if (process.argv.includes('--platform')) { + console.log(platform) +} else if (process.argv.includes('--release-tag')) { + console.log(RUNTIME_RELEASE_TAG) +} else { + console.log(asset) +} diff --git a/packages/desktop/scripts/runtime-config.mjs b/packages/desktop/scripts/runtime-config.mjs new file mode 100644 index 0000000..58ca082 --- /dev/null +++ b/packages/desktop/scripts/runtime-config.mjs @@ -0,0 +1,12 @@ +export const DEFAULT_HERMES_VERSION = '0.15.2' + +export function hermesVersion(env = process.env) { + return env.HERMES_VERSION || DEFAULT_HERMES_VERSION +} + +export function runtimeReleaseTag(env = process.env) { + const version = hermesVersion(env) + return env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG + || env.RUNTIME_RELEASE_TAG + || `hermes-${version}-runtime` +} diff --git a/packages/desktop/scripts/write-runtime-release.mjs b/packages/desktop/scripts/write-runtime-release.mjs new file mode 100644 index 0000000..0096669 --- /dev/null +++ b/packages/desktop/scripts/write-runtime-release.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runtimeReleaseTag } from './runtime-config.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const outFile = resolve(ROOT, 'build', 'runtime-release.json') +const tag = runtimeReleaseTag() + +mkdirSync(dirname(outFile), { recursive: true }) +writeFileSync(outFile, JSON.stringify({ tag }, null, 2) + '\n') +console.log(`Runtime release metadata: ${tag}`) diff --git a/packages/desktop/src/main/cli-constants.ts b/packages/desktop/src/main/cli-constants.ts new file mode 100644 index 0000000..c34be5a --- /dev/null +++ b/packages/desktop/src/main/cli-constants.ts @@ -0,0 +1 @@ +export const HERMES_CLI_ARG = '--hermes-cli' diff --git a/packages/desktop/src/main/cli-shim.ts b/packages/desktop/src/main/cli-shim.ts new file mode 100644 index 0000000..1435218 --- /dev/null +++ b/packages/desktop/src/main/cli-shim.ts @@ -0,0 +1,232 @@ +import { execFile } from 'node:child_process' +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { HERMES_CLI_ARG } from './cli-constants' + +const execFileAsync = promisify(execFile) + +const SHIM_MARKER = 'HERMES_STUDIO_CLI_SHIM' +const PATH_MARKER_START = '# >>> Hermes Studio CLI shim >>>' +const PATH_MARKER_END = '# <<< Hermes Studio CLI shim <<<' + +type ShimInstallStatus = 'installed' | 'updated' | 'unchanged' | 'skipped' + +export interface CliShimInstallResult { + shimPath: string + status: ShimInstallStatus + pathUpdated: boolean + reason?: string +} + +interface CliShimInstallOptions { + env?: NodeJS.ProcessEnv + executablePath?: string + homeDir?: string + platform?: NodeJS.Platform +} + +function platformDelimiter(platform: NodeJS.Platform): string { + return platform === 'win32' ? ';' : delimiter +} + +function pathKey(value: string, platform: NodeJS.Platform): string { + const normalized = resolve(value) + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +export function pathContainsDir(pathValue: string | undefined, binDir: string, platform: NodeJS.Platform = process.platform): boolean { + if (!pathValue) return false + const target = pathKey(binDir, platform) + return pathValue + .split(platformDelimiter(platform)) + .map(entry => entry.trim()) + .filter(Boolean) + .some(entry => pathKey(entry, platform) === target) +} + +function executableForShim(options: Required>): string { + const appImage = options.platform === 'linux' ? options.env.APPIMAGE?.trim() : '' + return appImage || options.executablePath +} + +export function shimPathForPlatform(binDir: string, platform: NodeJS.Platform = process.platform): string { + return join(binDir, platform === 'win32' ? 'hermes-studio.cmd' : 'hermes-studio') +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +export function createShimContent(executablePath: string, platform: NodeJS.Platform = process.platform): string { + if (platform === 'win32') { + return [ + '@echo off', + `rem ${SHIM_MARKER}`, + `set "APP=${executablePath}"`, + 'if not exist "%APP%" (', + ' echo Hermes Studio executable not found at "%APP%" 1>&2', + ' exit /b 127', + ')', + 'set ELECTRON_RUN_AS_NODE=', + `"${'%APP%'}" -- ${HERMES_CLI_ARG} %*`, + 'exit /b %ERRORLEVEL%', + '', + ].join('\r\n') + } + + return [ + '#!/bin/sh', + `# ${SHIM_MARKER}`, + `APP=${shellQuote(executablePath)}`, + 'if [ ! -x "$APP" ]; then', + ' echo "Hermes Studio executable not found at $APP" >&2', + ' exit 127', + 'fi', + 'unset ELECTRON_RUN_AS_NODE', + `exec "$APP" -- ${HERMES_CLI_ARG} "$@"`, + '', + ].join('\n') +} + +function isManagedShim(content: string): boolean { + return content.includes(SHIM_MARKER) && content.includes(HERMES_CLI_ARG) +} + +function writeShim(shimPath: string, content: string, platform: NodeJS.Platform): ShimInstallStatus { + if (existsSync(shimPath)) { + const existing = readFileSync(shimPath, 'utf-8') + if (existing === content) return 'unchanged' + if (!isManagedShim(existing)) return 'skipped' + writeFileSync(shimPath, content, 'utf-8') + if (platform !== 'win32') chmodSync(shimPath, 0o755) + return 'updated' + } + + writeFileSync(shimPath, content, { encoding: 'utf-8', mode: platform === 'win32' ? 0o644 : 0o755 }) + if (platform !== 'win32') chmodSync(shimPath, 0o755) + return 'installed' +} + +function shellProfilePaths(homeDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] { + if (platform === 'win32') return [] + + const shell = env.SHELL?.trim() || '' + const name = shell.split('/').pop() || '' + if (name === 'fish') return [join(homeDir, '.config', 'fish', 'conf.d', 'hermes-studio.fish')] + if (name === 'bash') return [join(homeDir, '.bash_profile'), join(homeDir, '.bashrc')] + if (name === 'zsh' || platform === 'darwin') return [join(homeDir, '.zprofile'), join(homeDir, '.zshrc')] + return [join(homeDir, '.profile')] +} + +function profileMentionsUserBin(content: string, homeDir: string): boolean { + return content.includes('$HOME/bin') + || content.includes('~/bin') + || content.includes(resolve(homeDir, 'bin')) +} + +function shellPathSnippet(platform: NodeJS.Platform, profilePath: string): string { + if (platform !== 'win32' && profilePath.endsWith('.fish')) { + return [ + '', + PATH_MARKER_START, + 'fish_add_path -m "$HOME/bin"', + PATH_MARKER_END, + '', + ].join('\n') + } + + return [ + '', + PATH_MARKER_START, + 'case ":$PATH:" in', + ' *":$HOME/bin:"*) ;;', + ' *) export PATH="$HOME/bin:$PATH" ;;', + 'esac', + PATH_MARKER_END, + '', + ].join('\n') +} + +async function ensureWindowsUserPath(binDir: string): Promise { + let currentPath = '' + try { + const { stdout } = await execFileAsync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], { + encoding: 'utf-8', + timeout: 1500, + windowsHide: true, + }) + const line = stdout.split(/\r?\n/).find(row => /^\s*Path\s+REG_/.test(row)) + if (line) currentPath = line.replace(/^\s*Path\s+REG_\w+\s+/, '').trim() + } catch { + currentPath = process.env.Path || process.env.PATH || '' + } + + if (pathContainsDir(currentPath, binDir, 'win32')) return false + + const separator = currentPath ? ';' : '' + await execFileAsync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', `${binDir}${separator}${currentPath}`, '/f'], { + encoding: 'utf-8', + timeout: 1500, + windowsHide: true, + }) + return true +} + +function ensureUnixShellPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { + if (pathContainsDir(env.PATH, binDir, platform)) return false + + let updated = false + for (const profilePath of shellProfilePaths(homeDir, platform, env)) { + const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : '' + if (existing.includes(PATH_MARKER_START) || profileMentionsUserBin(existing, homeDir)) continue + + mkdirSync(dirname(profilePath), { recursive: true }) + appendFileSync(profilePath, shellPathSnippet(platform, profilePath), 'utf-8') + updated = true + break + } + return updated +} + +async function ensureUserBinOnPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Promise { + if (platform === 'win32') { + return await ensureWindowsUserPath(binDir) + } + return ensureUnixShellPath(homeDir, binDir, platform, env) +} + +export async function installHermesStudioCliShim(options: CliShimInstallOptions = {}): Promise { + const platform = options.platform || process.platform + const env = options.env || process.env + const homeDir = options.homeDir || homedir() + const binDir = resolve(homeDir, 'bin') + const executablePath = executableForShim({ + env, + executablePath: options.executablePath || process.execPath, + platform, + }) + const shimPath = shimPathForPlatform(binDir, platform) + + mkdirSync(binDir, { recursive: true }) + const status = writeShim(shimPath, createShimContent(executablePath, platform), platform) + const pathUpdated = await ensureUserBinOnPath(homeDir, binDir, platform, env).catch((err) => { + console.warn(`[cli-shim] failed to update PATH: ${err instanceof Error ? err.message : String(err)}`) + return false + }) + + return { + shimPath, + status, + pathUpdated, + reason: status === 'skipped' ? 'existing hermes-studio shim is not managed by Hermes Studio' : undefined, + } +} diff --git a/packages/desktop/src/main/desktop-i18n.ts b/packages/desktop/src/main/desktop-i18n.ts new file mode 100644 index 0000000..7b59554 --- /dev/null +++ b/packages/desktop/src/main/desktop-i18n.ts @@ -0,0 +1,289 @@ +import { app } from 'electron' + +type DesktopLocale = 'en' | 'zh' | 'zh-TW' | 'ja' | 'ko' | 'fr' | 'es' | 'de' | 'pt' + +type TranslationKey = + | 'tray.show' + | 'tray.hide' + | 'tray.checkForUpdates' + | 'tray.openAtLogin' + | 'tray.quit' + | 'update.upToDateTitle' + | 'update.upToDateMessage' + | 'update.checkingTitle' + | 'update.checkingMessage' + | 'update.currentVersion' + | 'update.availableTitle' + | 'update.availableMessage' + | 'update.downloading' + | 'update.readyTitle' + | 'update.readyMessage' + | 'update.readyDetail' + | 'update.restartNow' + | 'update.download' + | 'update.later' + | 'update.failedTitle' + | 'update.failedMessage' + | 'update.noUpdateInfoMessage' + | 'update.packagedOnlyMessage' + | 'common.ok' + +const supportedLocales: DesktopLocale[] = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt'] + +const translations: Record> = { + en: { + 'tray.show': 'Show Hermes Studio', + 'tray.hide': 'Hide Hermes Studio', + 'tray.checkForUpdates': 'Check for Updates', + 'tray.openAtLogin': 'Open at Login', + 'tray.quit': 'Quit Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio is up to date.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'Checking for updates...', + 'update.currentVersion': 'Current version: {version}', + 'update.availableTitle': 'Update available', + 'update.availableMessage': 'Hermes Studio {version} is available.', + 'update.downloading': 'The update is downloading in the background.', + 'update.readyTitle': 'Update ready', + 'update.readyMessage': 'Hermes Studio {version} is ready to install.', + 'update.readyDetail': 'Restart now to apply the update, or it will be installed on next quit.', + 'update.restartNow': 'Restart now', + 'update.download': 'Download', + 'update.later': 'Later', + 'update.failedTitle': 'Update check failed', + 'update.failedMessage': 'Could not check for Hermes Studio updates.', + 'update.noUpdateInfoMessage': 'Update information is not available for this platform yet.', + 'update.packagedOnlyMessage': 'Automatic updates are only available in the packaged desktop app.', + 'common.ok': 'OK', + }, + zh: { + 'tray.show': '显示 Hermes Studio', + 'tray.hide': '隐藏 Hermes Studio', + 'tray.checkForUpdates': '检查更新', + 'tray.openAtLogin': '开机启动', + 'tray.quit': '退出 Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio 已是最新版本。', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': '正在检查更新...', + 'update.currentVersion': '当前版本:{version}', + 'update.availableTitle': '发现新版本', + 'update.availableMessage': 'Hermes Studio {version} 可用。', + 'update.downloading': '更新正在后台下载。', + 'update.readyTitle': '更新已就绪', + 'update.readyMessage': 'Hermes Studio {version} 已准备好安装。', + 'update.readyDetail': '立即重启以应用更新,或下次退出时自动安装。', + 'update.restartNow': '立即重启', + 'update.download': '下载', + 'update.later': '稍后', + 'update.failedTitle': '检查更新失败', + 'update.failedMessage': '无法检查 Hermes Studio 更新。', + 'update.noUpdateInfoMessage': '当前平台的更新信息暂不可用。', + 'update.packagedOnlyMessage': '自动更新仅在打包后的桌面应用中可用。', + 'common.ok': '确定', + }, + 'zh-TW': { + 'tray.show': '顯示 Hermes Studio', + 'tray.hide': '隱藏 Hermes Studio', + 'tray.checkForUpdates': '檢查更新', + 'tray.openAtLogin': '開機啟動', + 'tray.quit': '結束 Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio 已是最新版本。', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': '正在檢查更新...', + 'update.currentVersion': '目前版本:{version}', + 'update.availableTitle': '發現新版本', + 'update.availableMessage': 'Hermes Studio {version} 可用。', + 'update.downloading': '更新正在背景下載。', + 'update.readyTitle': '更新已就緒', + 'update.readyMessage': 'Hermes Studio {version} 已準備好安裝。', + 'update.readyDetail': '立即重新啟動以套用更新,或下次結束時自動安裝。', + 'update.restartNow': '立即重新啟動', + 'update.download': '下載', + 'update.later': '稍後', + 'update.failedTitle': '檢查更新失敗', + 'update.failedMessage': '無法檢查 Hermes Studio 更新。', + 'update.noUpdateInfoMessage': '目前平台的更新資訊暫不可用。', + 'update.packagedOnlyMessage': '自動更新僅可在打包後的桌面應用中使用。', + 'common.ok': '確定', + }, + ja: { + 'tray.show': 'Hermes Studio を表示', + 'tray.hide': 'Hermes Studio を隠す', + 'tray.checkForUpdates': 'アップデートを確認', + 'tray.openAtLogin': 'ログイン時に開く', + 'tray.quit': 'Hermes Studio を終了', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio は最新です。', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'アップデートを確認しています...', + 'update.currentVersion': '現在のバージョン: {version}', + 'update.availableTitle': 'アップデートがあります', + 'update.availableMessage': 'Hermes Studio {version} が利用できます。', + 'update.downloading': 'アップデートをバックグラウンドでダウンロードしています。', + 'update.readyTitle': 'アップデートの準備ができました', + 'update.readyMessage': 'Hermes Studio {version} をインストールできます。', + 'update.readyDetail': '今すぐ再起動して適用するか、次回終了時にインストールされます。', + 'update.restartNow': '今すぐ再起動', + 'update.download': 'ダウンロード', + 'update.later': '後で', + 'update.failedTitle': 'アップデート確認に失敗しました', + 'update.failedMessage': 'Hermes Studio のアップデートを確認できませんでした。', + 'update.noUpdateInfoMessage': 'このプラットフォームのアップデート情報はまだ利用できません。', + 'update.packagedOnlyMessage': '自動アップデートはパッケージ版デスクトップアプリでのみ利用できます。', + 'common.ok': 'OK', + }, + ko: { + 'tray.show': 'Hermes Studio 표시', + 'tray.hide': 'Hermes Studio 숨기기', + 'tray.checkForUpdates': '업데이트 확인', + 'tray.openAtLogin': '로그인 시 열기', + 'tray.quit': 'Hermes Studio 종료', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio가 최신 버전입니다.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': '업데이트를 확인하는 중...', + 'update.currentVersion': '현재 버전: {version}', + 'update.availableTitle': '업데이트 사용 가능', + 'update.availableMessage': 'Hermes Studio {version}을 사용할 수 있습니다.', + 'update.downloading': '업데이트를 백그라운드에서 다운로드하고 있습니다.', + 'update.readyTitle': '업데이트 준비 완료', + 'update.readyMessage': 'Hermes Studio {version}을 설치할 준비가 되었습니다.', + 'update.readyDetail': '지금 다시 시작해 업데이트를 적용하거나 다음 종료 시 설치합니다.', + 'update.restartNow': '지금 다시 시작', + 'update.download': '다운로드', + 'update.later': '나중에', + 'update.failedTitle': '업데이트 확인 실패', + 'update.failedMessage': 'Hermes Studio 업데이트를 확인할 수 없습니다.', + 'update.noUpdateInfoMessage': '이 플랫폼의 업데이트 정보를 아직 사용할 수 없습니다.', + 'update.packagedOnlyMessage': '자동 업데이트는 패키징된 데스크톱 앱에서만 사용할 수 있습니다.', + 'common.ok': '확인', + }, + fr: { + 'tray.show': 'Afficher Hermes Studio', + 'tray.hide': 'Masquer Hermes Studio', + 'tray.checkForUpdates': 'Rechercher les mises a jour', + 'tray.openAtLogin': 'Ouvrir a la connexion', + 'tray.quit': 'Quitter Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio est a jour.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'Recherche de mises a jour...', + 'update.currentVersion': 'Version actuelle : {version}', + 'update.availableTitle': 'Mise a jour disponible', + 'update.availableMessage': 'Hermes Studio {version} est disponible.', + 'update.downloading': 'La mise a jour se telecharge en arriere-plan.', + 'update.readyTitle': 'Mise a jour prete', + 'update.readyMessage': 'Hermes Studio {version} est pret a etre installe.', + 'update.readyDetail': 'Redemarrez maintenant pour appliquer la mise a jour, ou elle sera installee a la prochaine fermeture.', + 'update.restartNow': 'Redemarrer maintenant', + 'update.download': 'Telecharger', + 'update.later': 'Plus tard', + 'update.failedTitle': 'Echec de la recherche de mise a jour', + 'update.failedMessage': 'Impossible de rechercher les mises a jour de Hermes Studio.', + 'update.noUpdateInfoMessage': 'Les informations de mise a jour ne sont pas encore disponibles pour cette plateforme.', + 'update.packagedOnlyMessage': 'Les mises a jour automatiques ne sont disponibles que dans l application de bureau packagee.', + 'common.ok': 'OK', + }, + es: { + 'tray.show': 'Mostrar Hermes Studio', + 'tray.hide': 'Ocultar Hermes Studio', + 'tray.checkForUpdates': 'Buscar actualizaciones', + 'tray.openAtLogin': 'Abrir al iniciar sesion', + 'tray.quit': 'Salir de Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio esta actualizado.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'Buscando actualizaciones...', + 'update.currentVersion': 'Version actual: {version}', + 'update.availableTitle': 'Actualizacion disponible', + 'update.availableMessage': 'Hermes Studio {version} esta disponible.', + 'update.downloading': 'La actualizacion se esta descargando en segundo plano.', + 'update.readyTitle': 'Actualizacion lista', + 'update.readyMessage': 'Hermes Studio {version} esta listo para instalarse.', + 'update.readyDetail': 'Reinicia ahora para aplicar la actualizacion, o se instalara al salir.', + 'update.restartNow': 'Reiniciar ahora', + 'update.download': 'Descargar', + 'update.later': 'Mas tarde', + 'update.failedTitle': 'Error al buscar actualizaciones', + 'update.failedMessage': 'No se pudieron buscar actualizaciones de Hermes Studio.', + 'update.noUpdateInfoMessage': 'La informacion de actualizacion aun no esta disponible para esta plataforma.', + 'update.packagedOnlyMessage': 'Las actualizaciones automaticas solo estan disponibles en la app de escritorio empaquetada.', + 'common.ok': 'Aceptar', + }, + de: { + 'tray.show': 'Hermes Studio anzeigen', + 'tray.hide': 'Hermes Studio ausblenden', + 'tray.checkForUpdates': 'Nach Updates suchen', + 'tray.openAtLogin': 'Beim Anmelden offnen', + 'tray.quit': 'Hermes Studio beenden', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio ist auf dem neuesten Stand.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'Suche nach Updates...', + 'update.currentVersion': 'Aktuelle Version: {version}', + 'update.availableTitle': 'Update verfugbar', + 'update.availableMessage': 'Hermes Studio {version} ist verfugbar.', + 'update.downloading': 'Das Update wird im Hintergrund heruntergeladen.', + 'update.readyTitle': 'Update bereit', + 'update.readyMessage': 'Hermes Studio {version} ist zur Installation bereit.', + 'update.readyDetail': 'Jetzt neu starten, um das Update anzuwenden, oder es wird beim nachsten Beenden installiert.', + 'update.restartNow': 'Jetzt neu starten', + 'update.download': 'Herunterladen', + 'update.later': 'Spater', + 'update.failedTitle': 'Update-Prufung fehlgeschlagen', + 'update.failedMessage': 'Updates fur Hermes Studio konnten nicht gepruft werden.', + 'update.noUpdateInfoMessage': 'Update-Informationen sind fur diese Plattform noch nicht verfugbar.', + 'update.packagedOnlyMessage': 'Automatische Updates sind nur in der paketierten Desktop-App verfugbar.', + 'common.ok': 'OK', + }, + pt: { + 'tray.show': 'Mostrar Hermes Studio', + 'tray.hide': 'Ocultar Hermes Studio', + 'tray.checkForUpdates': 'Verificar atualizacoes', + 'tray.openAtLogin': 'Abrir ao iniciar sessao', + 'tray.quit': 'Sair do Hermes Studio', + 'update.upToDateTitle': 'Hermes Studio', + 'update.upToDateMessage': 'Hermes Studio esta atualizado.', + 'update.checkingTitle': 'Hermes Studio', + 'update.checkingMessage': 'Verificando atualizacoes...', + 'update.currentVersion': 'Versao atual: {version}', + 'update.availableTitle': 'Atualizacao disponivel', + 'update.availableMessage': 'Hermes Studio {version} esta disponivel.', + 'update.downloading': 'A atualizacao esta sendo baixada em segundo plano.', + 'update.readyTitle': 'Atualizacao pronta', + 'update.readyMessage': 'Hermes Studio {version} esta pronto para instalar.', + 'update.readyDetail': 'Reinicie agora para aplicar a atualizacao, ou ela sera instalada ao sair.', + 'update.restartNow': 'Reiniciar agora', + 'update.download': 'Baixar', + 'update.later': 'Depois', + 'update.failedTitle': 'Falha ao verificar atualizacoes', + 'update.failedMessage': 'Nao foi possivel verificar atualizacoes do Hermes Studio.', + 'update.noUpdateInfoMessage': 'As informacoes de atualizacao ainda nao estao disponiveis para esta plataforma.', + 'update.packagedOnlyMessage': 'Atualizacoes automaticas estao disponiveis apenas no app desktop empacotado.', + 'common.ok': 'OK', + }, +} + +function resolveLocale(): DesktopLocale { + const tag = app.getLocale() + const lower = tag.toLowerCase() + if (lower.startsWith('zh')) { + return lower.includes('hant') || lower.includes('-tw') || lower.includes('-hk') || lower.includes('-mo') + ? 'zh-TW' + : 'zh' + } + + const short = tag.slice(0, 2) as DesktopLocale + return supportedLocales.includes(short) ? short : 'en' +} + +export function t(key: TranslationKey, params: Record = {}): string { + const message = translations[resolveLocale()][key] || translations.en[key] + return Object.entries(params).reduce( + (value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement), + message, + ) +} diff --git a/packages/desktop/src/main/hermes-cli.ts b/packages/desktop/src/main/hermes-cli.ts new file mode 100644 index 0000000..7eeb8f3 --- /dev/null +++ b/packages/desktop/src/main/hermes-cli.ts @@ -0,0 +1,96 @@ +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync } from 'node:fs' +import { delimiter, dirname, join } from 'node:path' +import { + bundledBrowserExecutable, + bundledGit, + bundledNode, + bundledPython, + gitPathDirs, + hermesBin, + hermesHome, + nodeBinDir, + pythonDir, + webUiHome, +} from './paths' +import { HERMES_CLI_ARG } from './cli-constants' +import { ensureDesktopRuntime } from './runtime-manager' + +export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null { + const index = argv.indexOf(HERMES_CLI_ARG) + if (index < 0) return null + return argv.slice(index + 1) +} + +export async function runBundledHermesCli(args: string[]): Promise { + try { + await ensureDesktopRuntime() + } catch (err) { + console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`) + return 1 + } + + const command = hermesBin() + if (!existsSync(command)) { + console.error(`hermes binary missing at ${command}`) + console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)') + return 127 + } + + mkdirSync(webUiHome(), { recursive: true }) + mkdirSync(hermesHome(), { recursive: true }) + + const binDir = dirname(command) + const bundledNodeBin = nodeBinDir() + const bundledAgentBrowserBin = process.platform === 'win32' + ? join(pythonDir(), 'node') + : join(pythonDir(), 'node', 'bin') + const inheritedPath = process.env.PATH || process.env.Path || '' + const pathValue = [ + binDir, + bundledAgentBrowserBin, + bundledNodeBin, + gitPathDirs().join(delimiter), + inheritedPath, + ].filter(Boolean).join(delimiter) + const gitBin = bundledGit() + const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable() + const env: NodeJS.ProcessEnv = { + ...process.env, + HERMES_DESKTOP: 'true', + HERMES_BIN: command, + HERMES_AGENT_BRIDGE_PYTHON: bundledPython(), + HERMES_AGENT_CLI_PYTHON: bundledPython(), + HERMES_AGENT_ROOT: pythonDir(), + HERMES_AGENT_NODE: bundledNode(), + HERMES_AGENT_NODE_ROOT: process.platform === 'win32' ? bundledNodeBin : dirname(bundledNodeBin), + AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(hermesHome(), 'agent-browser'), + ...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}), + PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'), + ...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}), + HERMES_HOME: hermesHome(), + HERMES_WEB_UI_HOME: webUiHome(), + HERMES_WEBUI_STATE_DIR: webUiHome(), + PATH: pathValue, + } + + return await new Promise(resolve => { + const child = spawn(command, args, { + env, + stdio: 'inherit', + windowsHide: false, + }) + child.once('error', (err) => { + console.error(`Failed to run bundled Hermes CLI: ${err.message}`) + resolve(1) + }) + child.once('exit', (code, signal) => { + if (typeof code === 'number') { + resolve(code) + return + } + console.error(`Bundled Hermes CLI exited from signal ${signal || 'unknown'}`) + resolve(1) + }) + }) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts new file mode 100644 index 0000000..d0d319a --- /dev/null +++ b/packages/desktop/src/main/index.ts @@ -0,0 +1,368 @@ +import { app, BrowserWindow, Menu, Tray, shell, ipcMain, nativeImage } from 'electron' +import { join } from 'node:path' +import { startWebUiServer, stopWebUiServer, getToken } from './webui-server' +import { desktopIcon, desktopTrayTemplateIcon, desktopWindowsTrayIcon, hermesBinExists, hermesBin } from './paths' +import { checkForDesktopUpdates, initAutoUpdater } from './updater' +import { t } from './desktop-i18n' +import { installHermesStudioCliShim } from './cli-shim' +import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli' +import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager' + +const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748 +const START_HIDDEN = process.argv.includes('--hidden') +const QUIT_EXISTING = process.argv.includes('--quit') + +let mainWindow: BrowserWindow | null = null +let serverUrl: string | null = null +let tray: Tray | null = null +let isQuitting = false +let isBootstrapping = false + +function showMainWindow() { + if (!mainWindow) { + createWindow() + } + if (!mainWindow) return + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() +} + +function quitApp() { + isQuitting = true + app.quit() +} + +function loginItemOptions() { + return { + path: process.execPath, + args: ['--hidden'], + } +} + +function getOpenAtLogin(): boolean { + return app.getLoginItemSettings(loginItemOptions()).openAtLogin +} + +function setOpenAtLogin(openAtLogin: boolean) { + app.setLoginItemSettings({ + ...loginItemOptions(), + openAtLogin, + openAsHidden: true, + }) +} + +function updateTrayMenu() { + if (!tray) return + const isVisible = !!mainWindow && mainWindow.isVisible() + const menu = Menu.buildFromTemplate([ + { + label: isVisible ? t('tray.hide') : t('tray.show'), + click: () => { + if (mainWindow?.isVisible()) { + mainWindow.hide() + } else { + showMainWindow() + } + updateTrayMenu() + }, + }, + { + label: t('tray.checkForUpdates'), + click: () => { + checkForDesktopUpdates(true).catch(err => { + console.error('[tray] update check failed:', err) + }) + }, + }, + { + label: t('tray.openAtLogin'), + type: 'checkbox', + checked: getOpenAtLogin(), + click: (item) => { + setOpenAtLogin(item.checked) + updateTrayMenu() + }, + }, + { type: 'separator' }, + { + label: t('tray.quit'), + click: quitApp, + }, + ]) + tray.setContextMenu(menu) +} + +function createTray() { + if (tray) return + const source = process.platform === 'darwin' + ? desktopTrayTemplateIcon() + : process.platform === 'win32' + ? desktopWindowsTrayIcon() + : desktopIcon() + const icon = nativeImage.createFromPath(source).resize({ + width: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 24 : 16, + height: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 24 : 16, + quality: 'best', + }) + if (process.platform === 'darwin') { + icon.setTemplateImage(true) + } + tray = new Tray(icon) + tray.setToolTip('Hermes Studio') + tray.on('click', () => { + showMainWindow() + updateTrayMenu() + }) + updateTrayMenu() +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 960, + minHeight: 600, + title: 'Hermes Studio', + backgroundColor: '#1a1a1a', + autoHideMenuBar: true, + show: !START_HIDDEN, + ...(process.platform === 'linux' ? { icon: desktopIcon() } : {}), + webPreferences: { + preload: join(__dirname, '..', 'preload', 'index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }) + + mainWindow.on('close', (event) => { + if (isQuitting) return + event.preventDefault() + mainWindow?.hide() + updateTrayMenu() + }) + + mainWindow.on('show', updateTrayMenu) + mainWindow.on('hide', updateTrayMenu) + + // External links → system browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) { + return { action: 'allow' } + } + shell.openExternal(url).catch(() => undefined) + return { action: 'deny' } + }) + + // If the Web UI server is already up (re-opening window after close on + // macOS), go straight to it. Otherwise show a loading splash; bootstrap() + // will swap in the real URL once the server is ready. + if (serverUrl) { + mainWindow.loadURL(serverUrl) + } else { + mainWindow.loadURL(splashHtml()) + } + updateTrayMenu() +} + +function splashHtml(): string { + const html = `Hermes Studio +
+

Hermes Studio

+
+
Starting local services...
+
+
+
` + return 'data:text/html;charset=utf-8,' + encodeURIComponent(html) +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unit = units[0] + for (let i = 1; i < units.length && value >= 1024; i += 1) { + value /= 1024 + unit = units[i] + } + return `${value.toFixed(value >= 100 ? 0 : 1)} ${unit}` +} + +function updateSplash(progress: RuntimeProgress) { + if (!mainWindow || mainWindow.isDestroyed()) return + const label = progress.message + const percent = typeof progress.percent === 'number' ? Math.round(progress.percent) : null + let detail = '' + if (progress.receivedBytes && progress.totalBytes) { + detail = `${formatBytes(progress.receivedBytes)} / ${formatBytes(progress.totalBytes)}` + if (percent !== null) detail += ` (${percent}%)` + } else if (percent !== null) { + detail = `${percent}%` + } + + mainWindow.webContents.executeJavaScript(` + { + const label = document.getElementById('label'); + const detail = document.getElementById('detail'); + const bar = document.getElementById('bar'); + if (label) label.textContent = ${JSON.stringify(label)}; + if (detail) detail.textContent = ${JSON.stringify(detail)}; + if (bar) bar.style.width = ${JSON.stringify(percent === null ? '100%' : `${percent}%`)}; + } + `).catch(() => undefined) +} + +async function bootstrap() { + if (isBootstrapping) return + isBootstrapping = true + + try { + await ensureDesktopRuntime(updateSplash) + } catch (err) { + console.error('Failed to prepare Hermes runtime:', err) + if (mainWindow) { + const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '') + mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent( + ` +

Failed to prepare Hermes runtime

${msg}
+ + + `, + )) + } + isBootstrapping = false + return + } + + if (!hermesBinExists()) { + console.error(`hermes binary missing at ${hermesBin()}`) + console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)') + } + + try { + const url = await startWebUiServer(PORT) + serverUrl = url + if (mainWindow) await mainWindow.loadURL(url) + } catch (err) { + console.error('Failed to start Web UI server:', err) + if (mainWindow) { + const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '') + mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent( + ` +

Failed to start local services

${msg}
+ `, + )) + } + } finally { + isBootstrapping = false + } +} + +ipcMain.handle('hermes-desktop:get-token', () => getToken()) +ipcMain.handle('hermes-desktop:retry-bootstrap', async () => { + if (serverUrl) { + await mainWindow?.loadURL(serverUrl) + return + } + await mainWindow?.loadURL(splashHtml()) + await bootstrap() +}) + +function runDesktopApp() { + const gotLock = app.requestSingleInstanceLock() + if (!gotLock) { + app.quit() + return + } + + app.on('second-instance', (_event, argv) => { + if (argv.includes('--quit')) { + quitApp() + return + } + showMainWindow() + }) + + app.whenReady().then(() => { + if (QUIT_EXISTING) { + quitApp() + return + } + + // Drop the default File/Edit/View/Window menu on Windows/Linux. The web + // UI provides its own in-page controls, so the native menu bar is just + // visual clutter. macOS keeps a menu (system requirement) but Electron's + // default is fine there. + if (process.platform !== 'darwin') Menu.setApplicationMenu(null) + if (app.isPackaged) { + installHermesStudioCliShim().then(result => { + if (result.status === 'skipped') { + console.warn(`[cli-shim] ${result.reason}: ${result.shimPath}`) + } + }).catch(err => { + console.warn(`[cli-shim] failed to install hermes-studio command: ${err instanceof Error ? err.message : String(err)}`) + }) + } + createTray() + createWindow() + bootstrap() + initAutoUpdater({ + beforeQuitAndInstall: () => { + isQuitting = true + }, + }) + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } else if (mainWindow) { + showMainWindow() + } + }) + }) + + app.on('window-all-closed', () => { + if (isQuitting && process.platform !== 'darwin') app.quit() + }) + + app.on('before-quit', async (e) => { + if (!isQuitting && process.platform !== 'darwin') { + e.preventDefault() + mainWindow?.hide() + updateTrayMenu() + return + } + e.preventDefault() + await stopWebUiServer().catch(() => undefined) + app.exit(0) + }) +} + +const hermesCliArgs = parseHermesCliArgs(process.argv) +if (hermesCliArgs) { + runBundledHermesCli(hermesCliArgs) + .then(code => app.exit(code)) + .catch(err => { + console.error(`Failed to run bundled Hermes CLI: ${err instanceof Error ? err.message : String(err)}`) + app.exit(1) + }) +} else { + runDesktopApp() +} diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts new file mode 100644 index 0000000..b722ec4 --- /dev/null +++ b/packages/desktop/src/main/paths.ts @@ -0,0 +1,190 @@ +import { app } from 'electron' +import { existsSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { homedir, platform, arch } from 'node:os' + +const isWin = platform() === 'win32' +const osLabel = isWin ? 'win' : platform() === 'darwin' ? 'mac' : platform() // mac | linux | win +const archLabel = arch() // arm64 | x64 + +export function isPackaged() { + return app.isPackaged +} + +// Bundled web-ui directory. +// dev: (or HERMES_WEB_UI_DIR) +// prod: /webui +export function webuiDir(): string { + if (app.isPackaged) return resolve(process.resourcesPath, 'webui') + return process.env.HERMES_WEB_UI_DIR?.trim() || resolve(app.getAppPath(), '..', '..') +} + +export function webuiServerEntry(): string { + return join(webuiDir(), 'dist', 'server', 'index.js') +} + +export function runtimePlatformKey(): string { + return `${osLabel}-${archLabel}` +} + +export function desktopRuntimeDir(): string { + const override = process.env.HERMES_DESKTOP_RUNTIME_DIR?.trim() + if (override) return resolve(override) + return join(webUiHome(), 'desktop-runtime', runtimePlatformKey()) +} + +function packagedResourceDir(name: string): string { + return resolve(process.resourcesPath, name) +} + +// dev: packages/desktop/resources/python/- +// prod: /python when present, otherwise downloaded runtime cache. +export function pythonDir(): string { + if (app.isPackaged) { + const packaged = packagedResourceDir('python') + return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'python') + } + return resolve(app.getAppPath(), 'resources', 'python', runtimePlatformKey()) +} + +export function nodeDir(): string { + if (app.isPackaged) { + const packaged = packagedResourceDir('node') + return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'node') + } + return resolve(app.getAppPath(), 'resources', 'node', runtimePlatformKey()) +} + +export function nodeBinDir(): string { + const dir = nodeDir() + return isWin ? dir : join(dir, 'bin') +} + +export function bundledNode(): string { + return isWin ? join(nodeDir(), 'node.exe') : join(nodeBinDir(), 'node') +} + +export function gitDir(): string { + if (app.isPackaged) { + const packaged = packagedResourceDir('git') + return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'git') + } + return resolve(app.getAppPath(), 'resources', 'git', runtimePlatformKey()) +} + +export function gitPathDirs(): string[] { + if (!isWin) return [] + const dir = gitDir() + return [ + join(dir, 'cmd'), + join(dir, 'mingw64', 'bin'), + join(dir, 'usr', 'bin'), + ].filter(existsSync) +} + +export function bundledGit(): string | undefined { + if (!isWin) return undefined + const git = join(gitDir(), 'cmd', 'git.exe') + return existsSync(git) ? git : undefined +} + +export function bundledAgentBrowserHome(): string { + return join(pythonDir(), 'agent-browser') +} + +function browserExecutableNames(): Set { + if (isWin) return new Set(['chrome.exe']) + if (platform() === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome']) + return new Set(['chrome', 'chromium', 'chromium-browser']) +} + +export function bundledBrowserExecutable(): string | undefined { + const names = browserExecutableNames() + const stack = [join(bundledAgentBrowserHome(), 'browsers'), bundledAgentBrowserHome()].filter(existsSync) + const visited = new Set() + + while (stack.length > 0) { + const dir = stack.pop() + if (!dir || visited.has(dir)) continue + visited.add(dir) + + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isFile() && names.has(entry.name)) return path + if (entry.isDirectory()) stack.push(path) + } + } + + return undefined +} + +export function pythonBinDir(): string { + const dir = pythonDir() + return isWin ? join(dir, 'Scripts') : join(dir, 'bin') +} + +export function bundledPython(): string { + const dir = pythonDir() + return isWin ? join(dir, 'python.exe') : join(dir, 'bin', 'python3') +} + +export function hermesBin(): string { + return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes') +} + +export function hermesBinExists(): boolean { + return existsSync(hermesBin()) +} + +export function desktopIcon(): string { + if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'icon.png') + return resolve(app.getAppPath(), 'build', 'icon.png') +} + +export function desktopWindowsTrayIcon(): string { + if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'trayWindows.png') + return resolve(app.getAppPath(), 'build', 'trayWindows.png') +} + +export function desktopTrayTemplateIcon(): string { + if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'trayTemplate.png') + return resolve(app.getAppPath(), 'build', 'trayTemplate.png') +} + +export function webUiHome(): string { + return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui') +} + +export function hermesHome(): string { + const override = process.env.HERMES_HOME?.trim() + if (override) return resolve(override) + + const defaultHome = resolve(homedir(), '.hermes') + + if (isWin) { + const candidates = [ + process.env.LOCALAPPDATA, + process.env.APPDATA, + ] + .map(value => value?.trim()) + .filter((value): value is string => !!value) + .map(value => resolve(value, 'hermes')) + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + } + + return defaultHome +} + +export function tokenFile(): string { + return join(webUiHome(), '.token') +} diff --git a/packages/desktop/src/main/runtime-manager.ts b/packages/desktop/src/main/runtime-manager.ts new file mode 100644 index 0000000..48f7587 --- /dev/null +++ b/packages/desktop/src/main/runtime-manager.ts @@ -0,0 +1,319 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { get as httpGet } from 'node:http' +import { get as httpsGet } from 'node:https' +import { basename, dirname, join, relative } from 'node:path' +import { promisify } from 'node:util' +import { app } from 'electron' +import { + bundledGit, + bundledNode, + desktopRuntimeDir, + hermesBinExists, + runtimePlatformKey, +} from './paths' + +const execFileAsync = promisify(execFile) +const DEFAULT_RUNTIME_BASE_URL = 'https://download.ekkolearnai.com' +const RUNTIME_MANIFEST_NAME = 'runtime-manifest.json' +const PACKAGED_RUNTIME_RELEASE_NAME = 'runtime-release.json' + +type RuntimeManifest = { + schema: number + platform: string + hermesAgentVersion?: string + asset?: { + name: string + url?: string + sha256?: string + size?: number + } +} + +type RuntimeDescriptor = { + name: string + url: string + sha256?: string + hermesAgentVersion?: string +} + +export type RuntimeProgress = { + stage: 'resolve' | 'download' | 'verify' | 'extract' | 'ready' + message: string + percent?: number + receivedBytes?: number + totalBytes?: number +} + +type RuntimeProgressHandler = (progress: RuntimeProgress) => void + +function requiredRuntimeFiles(root: string): string[] { + const pythonBin = process.platform === 'win32' + ? join(root, 'python', 'python.exe') + : join(root, 'python', 'bin', 'python3') + const hermesBin = process.platform === 'win32' + ? join(root, 'python', 'Scripts', 'hermes.exe') + : join(root, 'python', 'bin', 'hermes') + const nodeBin = process.platform === 'win32' + ? join(root, 'node', 'node.exe') + : join(root, 'node', 'bin', 'node') + const files = [pythonBin, hermesBin, nodeBin, join(root, RUNTIME_MANIFEST_NAME)] + if (process.platform === 'win32') files.push(join(root, 'git', 'cmd', 'git.exe')) + return files +} + +function missingRuntimeFiles(root: string): string[] { + return requiredRuntimeFiles(root).filter(file => !existsSync(file)) +} + +function runtimeReady(): boolean { + const gitReady = process.platform !== 'win32' || !!bundledGit() + return hermesBinExists() && existsSync(bundledNode()) && gitReady +} + +function releaseTagCandidates(): string[] { + const override = process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG?.trim() + if (override) return [override] + + const version = app.getVersion() + const candidates = [packagedRuntimeReleaseTag(), version, `v${version}`, 'latest'] + return Array.from(new Set(candidates.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0))) +} + +function packagedRuntimeReleaseTag(): string | null { + const candidates = app.isPackaged + ? [join(process.resourcesPath, 'build', PACKAGED_RUNTIME_RELEASE_NAME)] + : [join(app.getAppPath(), 'build', PACKAGED_RUNTIME_RELEASE_NAME)] + + for (const candidate of candidates) { + if (!existsSync(candidate)) continue + try { + const metadata = JSON.parse(readFileSync(candidate, 'utf-8')) as { tag?: unknown } + if (typeof metadata.tag === 'string' && metadata.tag.trim()) return metadata.tag.trim() + } catch {} + } + + return null +} + +function runtimeAssetUrl(assetName: string, tag: string): string { + const repo = process.env.HERMES_DESKTOP_RUNTIME_REPO?.trim() + if (repo) { + if (tag === 'latest') { + return `https://github.com/${repo}/releases/latest/download/${encodeURIComponent(assetName)}` + } + return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}` + } + + const template = process.env.HERMES_DESKTOP_RUNTIME_BASE_URL?.trim() || DEFAULT_RUNTIME_BASE_URL + if (template.includes('{asset}') || template.includes('{tag}')) { + return template + .replace(/\{asset\}/g, encodeURIComponent(assetName)) + .replace(/\{tag\}/g, encodeURIComponent(tag)) + } + return `${template.replace(/\/$/, '')}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}` +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`GET ${url} returned ${response.status}`) + } + return await response.json() as T +} + +async function resolveRuntimeDescriptor(): Promise { + const directUrl = process.env.HERMES_DESKTOP_RUNTIME_URL?.trim() + if (directUrl) { + return { name: basename(new URL(directUrl).pathname) || 'hermes-runtime.tar.gz', url: directUrl } + } + + const platformManifestName = `hermes-runtime-${runtimePlatformKey()}.json` + const manifestOverride = process.env.HERMES_DESKTOP_RUNTIME_MANIFEST_URL?.trim() + const candidates = manifestOverride + ? [{ tag: '', url: manifestOverride }] + : releaseTagCandidates().map(tag => ({ tag, url: runtimeAssetUrl(platformManifestName, tag) })) + + let lastError: Error | null = null + for (const candidate of candidates) { + try { + const manifest = await fetchJson(candidate.url) + if (!manifest.asset?.name) { + throw new Error(`runtime manifest is missing asset.name: ${candidate.url}`) + } + return { + name: manifest.asset.name, + url: manifest.asset.url || runtimeAssetUrl(manifest.asset.name, candidate.tag), + sha256: manifest.asset.sha256, + hermesAgentVersion: manifest.hermesAgentVersion, + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + + throw lastError || new Error('Unable to resolve Hermes desktop runtime package') +} + +function readCachedRuntimeManifest(root: string): RuntimeManifest | null { + const file = join(root, RUNTIME_MANIFEST_NAME) + if (!existsSync(file)) return null + try { + return JSON.parse(readFileSync(file, 'utf-8')) as RuntimeManifest + } catch { + return null + } +} + +function cachedRuntimeMatches(root: string, descriptor: RuntimeDescriptor): boolean { + if (!runtimeReady()) return false + const manifest = readCachedRuntimeManifest(root) + if (!manifest?.asset?.name) return true + return manifest.asset.name === descriptor.name +} + +function downloadFile( + url: string, + target: string, + onProgress?: RuntimeProgressHandler, + redirects = 5, +): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url) + const getter = parsed.protocol === 'http:' ? httpGet : httpsGet + const req = getter(parsed, response => { + const status = response.statusCode || 0 + const location = response.headers.location + if (status >= 300 && status < 400 && location && redirects > 0) { + response.resume() + downloadFile(new URL(location, url).toString(), target, onProgress, redirects - 1).then(resolve, reject) + return + } + if (status < 200 || status >= 300) { + response.resume() + reject(new Error(`GET ${url} returned ${status}`)) + return + } + + const totalBytes = Number(response.headers['content-length']) || undefined + let receivedBytes = 0 + response.on('data', chunk => { + receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk) + onProgress?.({ + stage: 'download', + message: 'Downloading Hermes runtime...', + percent: totalBytes ? Math.min(100, (receivedBytes / totalBytes) * 100) : undefined, + receivedBytes, + totalBytes, + }) + }) + + const file = createWriteStream(target) + response.pipe(file) + file.on('finish', () => file.close(() => resolve())) + file.on('error', reject) + }) + req.on('error', reject) + }) +} + +async function sha256File(file: string): Promise { + const hash = createHash('sha256') + await new Promise((resolve, reject) => { + const stream = createReadStream(file) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', resolve) + stream.on('error', reject) + }) + return hash.digest('hex') +} + +async function extractRuntimeArchive(archive: string, targetRoot: string): Promise { + const parent = dirname(targetRoot) + const tempRoot = join(parent, `.runtime-${process.pid}-${Date.now()}`) + rmSync(tempRoot, { recursive: true, force: true }) + mkdirSync(tempRoot, { recursive: true }) + + try { + await execFileAsync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-xzf', archive, '-C', tempRoot], { + windowsHide: true, + }) + const missing = missingRuntimeFiles(tempRoot) + if (missing.length > 0) { + throw new Error(`Runtime archive is missing required files: ${missing.map(file => relative(tempRoot, file)).join(', ')}`) + } + rmSync(targetRoot, { recursive: true, force: true }) + mkdirSync(parent, { recursive: true }) + renameSync(tempRoot, targetRoot) + } catch (err) { + rmSync(tempRoot, { recursive: true, force: true }) + throw err + } +} + +export async function ensureDesktopRuntime(onProgress?: RuntimeProgressHandler): Promise { + const runtimeRoot = desktopRuntimeDir() + mkdirSync(runtimeRoot, { recursive: true }) + + let descriptor: RuntimeDescriptor + try { + onProgress?.({ stage: 'resolve', message: 'Checking Hermes runtime...' }) + descriptor = await resolveRuntimeDescriptor() + } catch (err) { + if (runtimeReady() && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) { + console.warn(`[runtime] using cached Hermes runtime because update check failed: ${err instanceof Error ? err.message : String(err)}`) + return + } + throw err + } + + if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return + + const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`) + console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`) + onProgress?.({ stage: 'download', message: `Downloading ${descriptor.name}...` }) + let archiveSize = 0 + try { + await downloadFile(descriptor.url, archive, onProgress) + archiveSize = statSync(archive).size + if (descriptor.sha256) { + onProgress?.({ stage: 'verify', message: 'Verifying Hermes runtime...' }) + const actual = await sha256File(archive) + if (actual !== descriptor.sha256) { + throw new Error(`Runtime checksum mismatch for ${descriptor.name}`) + } + } + + onProgress?.({ stage: 'extract', message: 'Extracting Hermes runtime...' }) + await extractRuntimeArchive(archive, runtimeRoot) + } finally { + rmSync(archive, { force: true }) + } + + const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME) + if (!existsSync(manifestPath)) { + writeFileSync(manifestPath, JSON.stringify({ + schema: 1, + platform: runtimePlatformKey(), + hermesAgentVersion: descriptor.hermesAgentVersion, + asset: { + name: descriptor.name, + sha256: descriptor.sha256, + size: archiveSize, + }, + }, null, 2)) + } + onProgress?.({ stage: 'ready', message: 'Hermes runtime ready.' }) + console.log(`[runtime] Hermes runtime ready at ${runtimeRoot}`) +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000..ac952d4 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,194 @@ +import { app, dialog } from 'electron' +import { autoUpdater, type ProgressInfo, type UpdateDownloadedEvent, type UpdateInfo } from 'electron-updater' +import { t } from './desktop-i18n' + +let initialized = false +let checking = false +let updateDownloaded = false + +const LATEST_RELEASE_DOWNLOAD_URL = 'https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest/download' +const CLOUDFLARE_DOWNLOAD_BASE_URL = 'https://download.ekkolearnai.com' + +class MissingUpdateInfoError extends Error { + constructor(public readonly url: string) { + super(`Update information is not available at ${url}`) + this.name = 'MissingUpdateInfoError' + } +} + +interface AutoUpdaterOptions { + beforeQuitAndInstall?: () => void +} + +let options: AutoUpdaterOptions = {} + +async function getLatestReleaseTag(assetName: string): Promise { + const res = await fetch(`${LATEST_RELEASE_DOWNLOAD_URL}/${encodeURIComponent(assetName)}`, { + method: 'HEAD', + redirect: 'manual', + headers: { + 'User-Agent': `Hermes-Studio/${app.getVersion()}`, + }, + }) + + if (res.status < 300 || res.status >= 400) throw new Error(`GitHub returned ${res.status}`) + + const location = res.headers.get('location') + if (!location) throw new Error('Latest release redirect did not include a location') + + const redirectUrl = new URL(location, LATEST_RELEASE_DOWNLOAD_URL) + const parts = redirectUrl.pathname.split('/') + const downloadIndex = parts.indexOf('download') + const tag = downloadIndex >= 0 ? parts[downloadIndex + 1]?.trim() : '' + if (!tag) throw new Error('Latest release redirect did not include a tag') + return tag +} + +function updateManifestFile(): string { + if (process.platform === 'darwin') return 'latest-mac.yml' + if (process.platform === 'win32') return 'latest.yml' + return 'latest-linux.yml' +} + +async function assertUpdateManifestExists(feedUrl: string): Promise { + const manifestUrl = `${feedUrl}/${updateManifestFile()}` + const res = await fetch(manifestUrl, { + method: 'HEAD', + headers: { + 'User-Agent': `Hermes-Studio/${app.getVersion()}`, + }, + }) + if (res.status === 404) throw new MissingUpdateInfoError(manifestUrl) + if (!res.ok) throw new Error(`Update feed returned ${res.status}`) +} + +async function configureFeedFromLatestRelease(): Promise { + const tag = await getLatestReleaseTag(updateManifestFile()) + const feedUrl = `${CLOUDFLARE_DOWNLOAD_BASE_URL}/${tag}` + await assertUpdateManifestExists(feedUrl) + autoUpdater.setFeedURL({ + provider: 'generic', + url: feedUrl, + }) +} + +function showUpToDate(info?: UpdateInfo) { + const version = info?.version || app.getVersion() + dialog.showMessageBox({ + type: 'info', + title: t('update.upToDateTitle'), + message: t('update.upToDateMessage'), + detail: t('update.currentVersion', { version }), + buttons: [t('common.ok')], + }).catch(() => undefined) +} + +function showUpdateCheckFailed(err: unknown) { + const isMissingUpdateInfo = err instanceof MissingUpdateInfoError + dialog.showMessageBox({ + type: isMissingUpdateInfo ? 'info' : 'error', + title: isMissingUpdateInfo ? t('update.upToDateTitle') : t('update.failedTitle'), + message: isMissingUpdateInfo ? t('update.noUpdateInfoMessage') : t('update.failedMessage'), + buttons: [t('common.ok')], + }).catch(() => undefined) +} + +export function initAutoUpdater(nextOptions: AutoUpdaterOptions = {}) { + options = { ...options, ...nextOptions } + if (initialized) return + initialized = true + + if (!app.isPackaged) return // dev mode: skip + + autoUpdater.autoDownload = true + autoUpdater.autoInstallOnAppQuit = true + + autoUpdater.on('update-available', info => { + console.log(`[updater] update available: ${info.version}`) + dialog.showMessageBox({ + type: 'info', + title: t('update.availableTitle'), + message: t('update.availableMessage', { version: info.version }), + detail: t('update.downloading'), + buttons: [t('common.ok')], + }).catch(() => undefined) + }) + autoUpdater.on('update-not-available', info => { + console.log('[updater] up to date') + if (checking) showUpToDate(info) + }) + autoUpdater.on('error', err => { + console.error('[updater] error:', err) + if (checking) showUpdateCheckFailed(err) + }) + autoUpdater.on('download-progress', (info: ProgressInfo) => { + console.log(`[updater] download ${Math.round(info.percent)}%`) + }) + autoUpdater.on('update-downloaded', async (info: UpdateDownloadedEvent) => { + updateDownloaded = true + const { response } = await dialog.showMessageBox({ + type: 'info', + title: t('update.readyTitle'), + message: t('update.readyMessage', { version: info.version }), + detail: t('update.readyDetail'), + buttons: [t('update.restartNow'), t('update.later')], + defaultId: 0, + cancelId: 1, + }) + if (response === 0) { + options.beforeQuitAndInstall?.() + autoUpdater.quitAndInstall() + } + }) + + if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'false') { + checkForDesktopUpdates(false).catch(err => { + console.error('[updater] initial check failed:', err) + }) + } + + // Recheck every 6h while app is running + setInterval(() => { + checkForDesktopUpdates(false).catch(() => undefined) + }, 6 * 60 * 60 * 1000) +} + +export async function checkForDesktopUpdates(manual: boolean): Promise { + if (!app.isPackaged) { + if (manual) { + await dialog.showMessageBox({ + type: 'info', + title: t('update.checkingTitle'), + message: t('update.packagedOnlyMessage'), + buttons: [t('common.ok')], + }) + } + return + } + + if (updateDownloaded) { + options.beforeQuitAndInstall?.() + autoUpdater.quitAndInstall() + return + } + + if (manual) { + await dialog.showMessageBox({ + type: 'info', + title: t('update.checkingTitle'), + message: t('update.checkingMessage'), + buttons: [t('common.ok')], + }) + } + + checking = manual + try { + await configureFeedFromLatestRelease() + await autoUpdater.checkForUpdates() + } catch (err) { + if (manual) showUpdateCheckFailed(err) + throw err + } finally { + checking = false + } +} diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts new file mode 100644 index 0000000..6d5d3bf --- /dev/null +++ b/packages/desktop/src/main/webui-server.ts @@ -0,0 +1,444 @@ +import { ChildProcess, execFile, spawn } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, readdirSync } from 'node:fs' +import { createServer } from 'node:net' +import { homedir } from 'node:os' +import { dirname, delimiter, join } from 'node:path' +import { randomBytes } from 'node:crypto' +import { promisify } from 'node:util' +import { app } from 'electron' +import { + bundledBrowserExecutable, + bundledGit, + bundledNode, + gitPathDirs, + webuiServerEntry, + webuiDir, + hermesBin, + webUiHome, + hermesHome, + nodeBinDir, + tokenFile, + pythonDir, +} from './paths' + +const DEFAULT_PORT = 8748 +const DEFAULT_READY_TIMEOUT_MS = 120_000 +const AGENT_BRIDGE_STARTED_MARKER = '[bootstrap] agent bridge started' +const AGENT_BRIDGE_FAILED_MARKER = '[bootstrap] agent bridge failed to start' +const execFileAsync = promisify(execFile) + +let serverProc: ChildProcess | null = null +let cachedToken: string | null = null + +function killProcessTree(proc: ChildProcess): void { + if (!proc.pid || proc.killed) return + if (process.platform === 'win32') { + try { + const killer = spawn('taskkill.exe', ['/PID', String(proc.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) + killer.once('error', () => undefined) + return + } catch { + /* fall through */ + } + } + try { + proc.kill('SIGKILL') + } catch { + /* ignore */ + } +} + +function envPositiveInt(name: string): number | undefined { + const raw = process.env[name] + if (!raw) return undefined + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +function readyTimeoutMs(): number { + return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS +} + +function createAgentBridgeStartupTracker(): { + observe: (chunk: Buffer) => void + wait: (timeoutMs: number) => Promise +} { + let output = '' + let state: 'pending' | 'started' | 'failed' = 'pending' + let resolveReady: (() => void) | null = null + let rejectReady: ((err: Error) => void) | null = null + + const settle = (nextState: 'started' | 'failed') => { + if (state !== 'pending') return + state = nextState + if (nextState === 'started') { + resolveReady?.() + } else { + rejectReady?.(new Error('Agent bridge failed to start')) + } + } + + const observe = (chunk: Buffer) => { + if (state !== 'pending') return + output = (output + chunk.toString('utf-8')).slice(-4096) + if (output.includes(AGENT_BRIDGE_STARTED_MARKER)) { + settle('started') + } else if (output.includes(AGENT_BRIDGE_FAILED_MARKER)) { + settle('failed') + } + } + + const wait = (timeoutMs: number) => { + if (state === 'started') return Promise.resolve() + if (state === 'failed') return Promise.reject(new Error('Agent bridge failed to start')) + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (state !== 'pending') return + state = 'failed' + reject(new Error(`Agent bridge did not become ready within ${timeoutMs}ms`)) + }, timeoutMs) + + resolveReady = () => { + clearTimeout(timer) + resolve() + } + rejectReady = (err) => { + clearTimeout(timer) + reject(err) + } + }) + } + + return { observe, wait } +} + +function ensureToken(): string { + if (cachedToken) return cachedToken + const file = tokenFile() + mkdirSync(dirname(file), { recursive: true }) + if (existsSync(file)) { + cachedToken = readFileSync(file, 'utf-8').trim() + if (cachedToken) return cachedToken + } + cachedToken = randomBytes(32).toString('hex') + writeFileSync(file, cachedToken + '\n', { mode: 0o600 }) + return cachedToken +} + +// node-pty ships per-platform prebuilds with a `spawn-helper` binary that +// loses its +x bit when copied across some filesystems. Restore it. +function ensureNativeModules() { + try { + const helper = join( + webuiDir(), + 'node_modules', + 'node-pty', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'spawn-helper', + ) + if (existsSync(helper)) chmodSync(helper, 0o755) + } catch { + /* ignore */ + } +} + +const COMMON_USER_BIN_DIRS = process.platform === 'win32' + ? [] + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ] +const PATH_MARKER_START = '__HERMES_DESKTOP_PATH_START__' +const PATH_MARKER_END = '__HERMES_DESKTOP_PATH_END__' + +function mergePathEntries(...paths: Array): string { + const seen = new Set() + const entries: string[] = [] + for (const rawPath of paths) { + if (!rawPath) continue + for (const entry of rawPath.split(delimiter)) { + const trimmed = entry.trim() + if (!trimmed) continue + const key = process.platform === 'win32' ? trimmed.toLowerCase() : trimmed + if (seen.has(key)) continue + seen.add(key) + entries.push(trimmed) + } + } + return entries.join(delimiter) +} + +function extractMarkedPath(output: string): string | null { + const start = output.lastIndexOf(PATH_MARKER_START) + const end = output.lastIndexOf(PATH_MARKER_END) + if (start < 0 || end <= start) return null + const value = output.slice(start + PATH_MARKER_START.length, end).trim() + return value || null +} + +function compareNodeVersionDesc(left: string, right: string): number { + const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const diff = (rightParts[index] || 0) - (leftParts[index] || 0) + if (diff !== 0) return diff + } + return right.localeCompare(left) +} + +function getNvmNodeBinPaths(): string { + if (process.platform === 'win32') return '' + + const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm') + const versionsDir = join(nvmDir, 'versions', 'node') + if (!existsSync(versionsDir)) return '' + + try { + return readdirSync(versionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(compareNodeVersionDesc) + .map(version => join(versionsDir, version, 'bin')) + .filter(binDir => existsSync(binDir)) + .join(delimiter) + } catch { + return '' + } +} + +async function getLoginShellPath(): Promise { + if (process.platform === 'win32') return null + + const shell = process.env.SHELL?.trim() || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh') + if (!existsSync(shell)) return null + + try { + const { stdout } = await execFileAsync(shell, ['-l', '-c', `printf '\\n${PATH_MARKER_START}%s${PATH_MARKER_END}\\n' "$PATH"`], { + encoding: 'utf-8', + timeout: 1500, + windowsHide: true, + env: process.env, + }) + return extractMarkedPath(stdout) || stdout.trim() || null + } catch { + return null + } +} + +export function getToken(): string { + return ensureToken() +} + +export function getServerUrl(port = DEFAULT_PORT): string { + return `http://127.0.0.1:${port}` +} + +async function getFreeTcpPort(): Promise { + return await new Promise((resolveFreePort, rejectFreePort) => { + const server = createServer() + server.unref() + server.once('error', rejectFreePort) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (typeof address === 'object' && address?.port) { + resolveFreePort(address.port) + } else { + rejectFreePort(new Error('Unable to allocate local TCP port')) + } + }) + }) + }) +} + +async function canBindTcpPort(port: number): Promise { + return await new Promise((resolveCanBind) => { + const server = createServer() + server.unref() + server.once('error', () => resolveCanBind(false)) + server.listen(port, '127.0.0.1', () => { + server.close(() => resolveCanBind(true)) + }) + }) +} + +async function getFreeTcpPortInRange(min: number, max: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const port = min + (randomBytes(2).readUInt16BE(0) % (max - min + 1)) + if (await canBindTcpPort(port)) return port + } + return getFreeTcpPort() +} + +export async function startWebUiServer(port = DEFAULT_PORT): Promise { + ensureNativeModules() + const token = ensureToken() + const entry = webuiServerEntry() + if (!existsSync(entry)) { + throw new Error(`Web UI server entry not found at ${entry}. Run: npm run build:webui`) + } + + const home = webUiHome() + const agentHome = hermesHome() + mkdirSync(home, { recursive: true }) + mkdirSync(agentHome, { recursive: true }) + + // Tell agent-bridge to use the bundled Python directly. Otherwise the + // bridge auto-detects Python from HERMES_BIN's shebang — which on our + // setup is a #!/bin/sh wrapper, not a python interpreter, so detection + // resolves to /bin/sh and the bridge crashes (exit code 2) immediately. + const isWin = process.platform === 'win32' + const bundledPython = isWin + ? join(pythonDir(), 'python.exe') + : join(pythonDir(), 'bin', 'python3') + const bundledAgentBrowserBin = isWin + ? join(pythonDir(), 'node') + : join(pythonDir(), 'node', 'bin') + const bundledNodeBin = nodeBinDir() + const bundledGitPath = gitPathDirs().join(delimiter) + const bridgePort = await getFreeTcpPort() + const workerPortBase = await getFreeTcpPortInRange(20000, 59000) + const loginShellPath = await getLoginShellPath() + const nvmNodeBinPaths = getNvmNodeBinPaths() + const runtimePath = mergePathEntries( + dirname(hermesBin()), + bundledAgentBrowserBin, + bundledNodeBin, + bundledGitPath, + loginShellPath, + nvmNodeBinPaths, + process.env.PATH, + process.env.Path, + COMMON_USER_BIN_DIRS.join(delimiter), + ) + const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable() + const gitBin = bundledGit() + + // Run via Electron's "run as Node" mode — Electron binary doubles as Node. + const env: NodeJS.ProcessEnv = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODE_ENV: 'production', + HERMES_DESKTOP: 'true', + HERMES_BIN: hermesBin(), + // The bridge and its per-profile workers need working stdout/stderr for + // ready handshakes. Use python.exe on Windows and hide windows at the + // process creation layer instead of switching the bridge to pythonw.exe. + HERMES_AGENT_BRIDGE_PYTHON: bundledPython, + HERMES_AGENT_CLI_PYTHON: bundledPython, + HERMES_AGENT_ROOT: pythonDir(), + HERMES_AGENT_NODE: bundledNode(), + HERMES_AGENT_NODE_ROOT: isWin ? bundledNodeBin : dirname(bundledNodeBin), + AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'), + ...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}), + PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'), + ...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}), + // Force TCP loopback for the agent bridge. The default `ipc:///tmp/...` + // unix socket is rejected on macOS in some EDR/sandbox setups (silent + // SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works + // identically and avoids the issue cross-platform. + HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`, + // Desktop opens the UI as soon as the Web UI HTTP server is ready, while + // the Python bridge starts in the background. Let the first chat/context + // request wait for broker readiness instead of failing during cold start. + HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS: process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS ?? '120000', + // Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox + // reason as above — default ipc:// unix sockets in /tmp get killed. + HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp', + HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(workerPortBase), + // And for preview-mode bridges spawned by the in-app update controller. + HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp', + // Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui + // is bundled here; users can't `npm i -g` to upgrade, they have to wait + // for the wrapper app to ship a new release. + HERMES_WEB_UI_DISABLE_UPDATE_CHECK: 'true', + // Single-user desktop install: open the gateway's user allowlist by + // default. Otherwise the gateway silently drops every inbound platform + // message (DingTalk/Slack/Telegram) with a startup warning. Users can + // still override by setting GATEWAY_ALLOW_ALL_USERS=false in their + // HERMES_HOME/.env or by configuring per-platform allowlists. + GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true', + // Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers + // on the same data directory. Native Windows uses an existing + // %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep + // the standard ~/.hermes layout. + HERMES_HOME: agentHome, + HERMES_WEB_UI_HOME: home, + HERMES_WEBUI_STATE_DIR: home, + AUTH_TOKEN: token, + PORT: String(port), + // Prepend bundled Python's bin to PATH so any incidental `python` resolution lands on ours + PATH: runtimePath, + } + + serverProc = spawn(process.execPath, [entry], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + + const bridgeStartup = createAgentBridgeStartupTracker() + + serverProc.stdout?.on('data', (chunk: Buffer) => { + bridgeStartup.observe(chunk) + process.stdout.write(`[webui] ${chunk}`) + }) + serverProc.stderr?.on('data', (chunk: Buffer) => { + bridgeStartup.observe(chunk) + process.stderr.write(`[webui] ${chunk}`) + }) + serverProc.on('exit', (code, signal) => { + console.error(`[webui] server exited code=${code} signal=${signal}`) + serverProc = null + if (!app.isReady() || code !== 0) { + // Best-effort: if server dies abnormally during startup, surface to user + } + }) + + const timeoutMs = readyTimeoutMs() + void bridgeStartup.wait(timeoutMs).catch(err => { + console.warn(`[webui] agent bridge was not ready during startup: ${err instanceof Error ? err.message : String(err)}`) + }) + await waitForReady(port, timeoutMs) + return getServerUrl(port) +} + +async function waitForReady(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + const url = `http://127.0.0.1:${port}/api/health` + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(1000) }) + if (res.ok || res.status === 401) return // 401 = up but auth-gated, server is alive + } catch { + /* not ready yet */ + } + await new Promise(r => setTimeout(r, 300)) + } + throw new Error(`Web UI server did not become ready within ${timeoutMs}ms`) +} + +export function stopWebUiServer(): Promise { + return new Promise(resolve => { + if (!serverProc || serverProc.killed) return resolve() + const proc = serverProc + const timer = setTimeout(() => { + killProcessTree(proc) + resolve() + }, 3000) + proc.once('exit', () => { + clearTimeout(timer) + resolve() + }) + try { proc.kill('SIGTERM') } catch { resolve() } + }) +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts new file mode 100644 index 0000000..547db69 --- /dev/null +++ b/packages/desktop/src/preload/index.ts @@ -0,0 +1,134 @@ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('hermesDesktop', { + getToken: (): Promise => ipcRenderer.invoke('hermes-desktop:get-token'), + retryBootstrap: (): Promise => ipcRenderer.invoke('hermes-desktop:retry-bootstrap'), + platform: process.platform, + isDesktop: true, +}) + +const API_KEY_LS = 'hermes_api_key' +const DEFAULT_USERNAME = 'admin' +const DEFAULT_PASSWORD = '123456' + +// Auto-login the bundled web UI so users don't see a login screen on launch. +// We POST to /api/auth/login with the well-known default credentials, using +// the server's AUTH_TOKEN as the bearer (the server requires *some* auth on +// /api/auth/login from a packaged client). The returned JWT is dropped into +// localStorage where the Vue client expects it. +async function autoLogin(token: string): Promise { + if (localStorage.getItem(API_KEY_LS)) return + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD }), + }) + if (!res.ok) return + const body = await res.json().catch(() => null) as { token?: string; jwt?: string } | null + const jwt = body?.token || body?.jwt + if (jwt) localStorage.setItem(API_KEY_LS, jwt) + } catch { + /* ignore — first-load race or server still starting */ + } +} + +// Silently strip the "你必须修改默认密码" flag from /api/auth/me responses on +// desktop. Users on a single-machine install don't benefit from a managed +// password. The Web UI client uses BOTH fetch and axios (which goes through +// XMLHttpRequest), so we patch both code paths. +function isAuthMeUrl(url: string): boolean { + return /\/api\/auth\/me(?:\?|$)/.test(url) +} + +function stripCredentialFlag(text: string): string { + try { + const data = JSON.parse(text) + if (data?.user && data.user.requiresCredentialChange) { + data.user.requiresCredentialChange = false + return JSON.stringify(data) + } + } catch { /* not JSON */ } + return text +} + +function installFetchPatch(): void { + const origFetch = window.fetch.bind(window) + const patchedFetch = (async (input, init) => { + const res = await origFetch(input, init) + try { + const url = typeof input === 'string' ? input : (input as Request).url + if (url && isAuthMeUrl(url) && res.ok) { + const text = await res.clone().text() + const patched = stripCredentialFlag(text) + if (patched !== text) { + return new Response(patched, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }) + } + } + } catch { /* fall through */ } + return res + }) as typeof window.fetch + window.fetch = patchedFetch + + const OrigXHR = window.XMLHttpRequest + type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string } + const origOpen = OrigXHR.prototype.open + OrigXHR.prototype.open = function ( + this: XHRWithDesktop, + method: string, + url: string | URL, + ...rest: unknown[] + ) { + this.__hermesDesktopUrl = String(url) + // @ts-expect-error — forwarding variadic + return origOpen.call(this, method, url, ...rest) + } + const origGetResponse = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'response') + const origGetResponseText = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'responseText') + if (origGetResponse?.get && origGetResponseText?.get) { + Object.defineProperty(OrigXHR.prototype, 'responseText', { + configurable: true, + get(this: XHRWithDesktop) { + const raw = origGetResponseText.get!.call(this) as string + if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl) && typeof raw === 'string') { + return stripCredentialFlag(raw) + } + return raw + }, + }) + Object.defineProperty(OrigXHR.prototype, 'response', { + configurable: true, + get(this: XHRWithDesktop) { + const raw = origGetResponse.get!.call(this) + if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl)) { + if (typeof raw === 'string') return stripCredentialFlag(raw) + if (raw && typeof raw === 'object' && (raw as { user?: { requiresCredentialChange?: boolean } }).user?.requiresCredentialChange) { + return { ...(raw as object), user: { ...(raw as { user: object }).user, requiresCredentialChange: false } } + } + } + return raw + }, + }) + } +} + +installFetchPatch() + +window.addEventListener('DOMContentLoaded', async () => { + try { + const token = await ipcRenderer.invoke('hermes-desktop:get-token') + if (token) { + try { localStorage.setItem('AUTH_TOKEN', token) } catch { /* */ } + await autoLogin(token) + } + } catch { + /* ignore */ + } +}) diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json new file mode 100644 index 0000000..12d5809 --- /dev/null +++ b/packages/desktop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "sourceMap": true, + "declaration": false + }, + "include": ["src/**/*"] +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 0000000..b5d8d1a --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,59 @@ +import { join, resolve } from 'path' +import { homedir } from 'os' + +/** + * Web UI environment variables. + * + * Server/listen: + * - PORT: Web UI listen port. Default: 8648. + * - BIND_HOST: Web UI bind host. Default: 0.0.0.0. + * - CORS_ORIGINS: Koa CORS origin setting. Default: *. + * + * Web UI storage: + * - HERMES_WEB_UI_HOME: Web UI data home for auth token, credentials, logs, DB, and default uploads. + * - HERMES_WEBUI_STATE_DIR: Compatibility alias for HERMES_WEB_UI_HOME. + * Default: join(homedir(), '.hermes-web-ui'). + * - UPLOAD_DIR: Upload directory override. Default: join(HERMES_WEB_UI_HOME, 'upload'). + * - dataDir: Development-only internal Web UI runtime data directory. + * + * Auth: + * - AUTH_TOKEN: Explicit bearer token. If unset, Web UI stores an auto-generated token under HERMES_WEB_UI_HOME. + * + * Runtime behavior: + * - PROFILE: Initial Hermes profile name. Default: default. + * - GATEWAY_HOST: Default gateway host written into profile config. Default: 127.0.0.1. + * - HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN: Whether Web UI shutdown also stops gateways. + * - WORKSPACE_BASE: Base directory for workspace browsing. Default: /opt/data/workspace. + * + * Limits/logging: + * - MAX_DOWNLOAD_SIZE: Max file download size. Default: 200MB. + * - MAX_EDIT_SIZE: Max editable file size. Default: 10MB. + * - LOG_LEVEL: Server log level. Default: info. + * - BRIDGE_LOG_LEVEL: Bridge log level. Default: LOG_LEVEL or info. + */ + +export function getListenHost(env: Record = process.env): string { + const host = env.BIND_HOST?.trim() + return host || '0.0.0.0' +} + +export function getWebUiHome(env: Record = process.env): string { + const appHome = env.HERMES_WEB_UI_HOME?.trim() || env.HERMES_WEBUI_STATE_DIR?.trim() + return appHome ? resolve(appHome) : join(homedir(), '.hermes-web-ui') +} + +export function shouldCreateWebUiDataDir(env: Record = process.env): boolean { + return env.NODE_ENV !== 'production' +} + +const appHome = getWebUiHome() + +export const config = { + port: parseInt(process.env.PORT || '8648', 10), + // Default to IPv4 for stable WSL/Windows browser access. Use BIND_HOST=:: explicitly for IPv6. + host: getListenHost(), + appHome, + uploadDir: process.env.UPLOAD_DIR || join(appHome, 'upload'), + dataDir: resolve(__dirname, '..', 'data'), + corsOrigins: process.env.CORS_ORIGINS || '*', +} diff --git a/packages/server/src/controllers/auth.ts b/packages/server/src/controllers/auth.ts new file mode 100644 index 0000000..2be258d --- /dev/null +++ b/packages/server/src/controllers/auth.ts @@ -0,0 +1,423 @@ +import type { Context } from 'koa' +import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter' +import { + DEFAULT_PASSWORD, + DEFAULT_USERNAME, + bootstrapDefaultSuperAdmin, + countActiveSuperAdmins, + countUsers, + createUser, + deleteUser, + findUserById, + findUserByUsername, + listUsers, + updateUser, + updateUsername, + updateUserPassword, + verifyPassword, + type UserRole, + type UserStatus, +} from '../db/hermes/users-store' +import { issueUserJwt } from '../middleware/user-auth' +import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile' + +/** + * GET /api/auth/status + * Check if username/password login is configured (public). + */ +export async function authStatus(ctx: Context) { + ctx.body = { + hasPasswordLogin: true, + hasUsers: countUsers() > 0, + } +} + +/** + * GET /api/auth/me + * Return the authenticated account. + */ +export async function currentUser(ctx: Context) { + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + ctx.body = { + user: { + id: user.id, + username: user.username, + role: user.role, + status: user.status, + created_at: user.created_at, + updated_at: user.updated_at, + last_login_at: user.last_login_at, + requiresCredentialChange: process.env.HERMES_DESKTOP === 'true' + ? false + : user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash), + }, + } +} + +/** + * POST /api/auth/login + * Authenticate with username/password (public). + * Returns a user-scoped JWT on success. + */ +export async function login(ctx: Context) { + const { username, password } = ctx.request.body as { username?: string; password?: string } + if (!username || !password) { + ctx.status = 400 + ctx.body = { error: 'Username and password are required' } + return + } + + const ip = extractIp(ctx) + const result = checkPassword(ip) + if (!result.allowed) { + ctx.status = result.status + ctx.body = { error: 'Too many login attempts, please try again later' } + return + } + + const existingUserCount = countUsers() + const user = existingUserCount === 0 + ? bootstrapDefaultSuperAdmin(username, password) + : findUserByUsername(username) + + if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) { + recordPasswordFailure(ip) + ctx.status = 401 + ctx.body = { error: 'Invalid username or password' } + return + } + + let token: string + try { + token = await issueUserJwt(user) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err?.message || 'Failed to issue login token' } + return + } + + recordPasswordSuccess(ip) + ctx.body = { token } +} + +/** + * POST /api/auth/setup + * Set up username/password (protected). + */ +export async function setupPassword(ctx: Context) { + ctx.status = 400 + ctx.body = { error: 'Password login is managed by user accounts' } +} + +/** + * POST /api/auth/change-password + * Change password (protected). + */ +export async function changePassword(ctx: Context) { + const { currentPassword, newPassword } = ctx.request.body as { currentPassword?: string; newPassword?: string } + if (!currentPassword || !newPassword) { + ctx.status = 400 + ctx.body = { error: 'Current password and new password are required' } + return + } + if (newPassword.length < 6) { + ctx.status = 400 + ctx.body = { error: 'New password must be at least 6 characters' } + return + } + + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user || !verifyPassword(currentPassword, user.password_hash)) { + ctx.status = 400 + ctx.body = { error: 'Current password is incorrect' } + return + } + + updateUserPassword(user.id, newPassword) + ctx.body = { success: true } +} + +/** + * POST /api/auth/change-username + * Change username (protected). + */ +export async function changeUsername(ctx: Context) { + const { currentPassword, newUsername } = ctx.request.body as { currentPassword?: string; newUsername?: string } + if (!currentPassword || !newUsername) { + ctx.status = 400 + ctx.body = { error: 'Current password and new username are required' } + return + } + if (newUsername.length < 2) { + ctx.status = 400 + ctx.body = { error: 'Username must be at least 2 characters' } + return + } + + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user || !verifyPassword(currentPassword, user.password_hash)) { + ctx.status = 400 + ctx.body = { error: 'Current password is incorrect' } + return + } + + const existing = findUserByUsername(newUsername) + if (existing && existing.id !== user.id) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + + updateUsername(user.id, newUsername) + ctx.body = { success: true } +} + +/** + * DELETE /api/auth/password + * Remove username/password login (protected). + */ +export async function removePassword(ctx: Context) { + ctx.status = 400 + ctx.body = { error: 'Password login cannot be removed for user accounts' } +} + +function normalizeRole(value: unknown): UserRole | null { + return value === 'super_admin' || value === 'admin' ? value : null +} + +function normalizeStatus(value: unknown): UserStatus | null { + return value === 'active' || value === 'disabled' ? value : null +} + +function normalizeProfiles(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))] +} + +function validateProfiles(profiles: string[]): string | null { + const available = new Set(listProfileNamesFromDisk()) + const missing = profiles.find(profile => !available.has(profile)) + return missing || null +} + +/** + * GET /api/auth/users + * Super admin user management list. + */ +export async function listManagedUsers(ctx: Context) { + ctx.body = { + users: listUsers(), + profiles: listProfileNamesFromDisk(), + } +} + +/** + * POST /api/auth/users + * Create a user account. Super admin only. + */ +export async function createManagedUser(ctx: Context) { + const body = ctx.request.body as { + username?: string + password?: string + role?: unknown + status?: unknown + profiles?: unknown + defaultProfile?: string | null + } + const username = String(body.username || '').trim() + const password = String(body.password || '') + const role = normalizeRole(body.role || 'admin') + const status = normalizeStatus(body.status || 'active') + const profiles = normalizeProfiles(body.profiles) + + if (username.length < 2) { + ctx.status = 400 + ctx.body = { error: 'Username must be at least 2 characters' } + return + } + if (password.length < 6) { + ctx.status = 400 + ctx.body = { error: 'Password must be at least 6 characters' } + return + } + if (!role || !status) { + ctx.status = 400 + ctx.body = { error: 'Invalid role or status' } + return + } + if (findUserByUsername(username)) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + + const missingProfile = validateProfiles(profiles) + if (missingProfile) { + ctx.status = 400 + ctx.body = { error: `Profile "${missingProfile}" does not exist` } + return + } + + const user = createUser({ + username, + password, + role, + status, + profiles: role === 'super_admin' ? [] : profiles, + defaultProfile: body.defaultProfile, + }) + ctx.status = 201 + ctx.body = { user, users: listUsers() } +} + +/** + * PUT /api/auth/users/:id + * Update user account metadata, password, and profile bindings. + */ +export async function updateManagedUser(ctx: Context) { + const id = Number(ctx.params.id) + const user = Number.isInteger(id) ? findUserById(id) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + + const body = ctx.request.body as { + username?: string + password?: string + role?: unknown + status?: unknown + profiles?: unknown + defaultProfile?: string | null + } + const username = body.username == null ? undefined : String(body.username).trim() + const password = body.password == null ? undefined : String(body.password) + const role = body.role == null ? undefined : normalizeRole(body.role) + const status = body.status == null ? undefined : normalizeStatus(body.status) + const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles) + + if (username !== undefined && username.length < 2) { + ctx.status = 400 + ctx.body = { error: 'Username must be at least 2 characters' } + return + } + if (password !== undefined && password.length > 0 && password.length < 6) { + ctx.status = 400 + ctx.body = { error: 'Password must be at least 6 characters' } + return + } + if (body.role != null && !role || body.status != null && !status) { + ctx.status = 400 + ctx.body = { error: 'Invalid role or status' } + return + } + if (username && username !== user.username) { + const existing = findUserByUsername(username) + if (existing && existing.id !== user.id) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + } + + const nextRole = role || user.role + const nextStatus = status || user.status + const currentUserId = ctx.state.user?.id + if (user.id === currentUserId && nextStatus !== 'active') { + ctx.status = 400 + ctx.body = { error: 'You cannot disable your own account' } + return + } + if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) { + ctx.status = 400 + ctx.body = { error: 'At least one active super administrator is required' } + return + } + + if (profiles) { + const missingProfile = validateProfiles(profiles) + if (missingProfile) { + ctx.status = 400 + ctx.body = { error: `Profile "${missingProfile}" does not exist` } + return + } + } + + updateUser({ + userId: user.id, + username, + password: password || undefined, + role: role || undefined, + status: status || undefined, + profiles: nextRole === 'super_admin' ? [] : profiles, + defaultProfile: body.defaultProfile, + }) + ctx.body = { user: findUserById(user.id), users: listUsers() } +} + +/** + * DELETE /api/auth/users/:id + * Delete a user account. Super admin only. + */ +export async function deleteManagedUser(ctx: Context) { + const id = Number(ctx.params.id) + const user = Number.isInteger(id) ? findUserById(id) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + + if (ctx.state.user?.id === user.id) { + ctx.status = 400 + ctx.body = { error: 'You cannot delete your own account' } + return + } + if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) { + ctx.status = 400 + ctx.body = { error: 'At least one active super administrator is required' } + return + } + + deleteUser(user.id) + ctx.body = { success: true, users: listUsers() } +} + +/** + * GET /api/auth/locked-ips + * List all currently locked IPs (protected). + */ +export async function listLockedIps(ctx: Context) { + const locks = getLockedIps() + ctx.body = { locks } +} + +/** + * DELETE /api/auth/locked-ips?ip=xxx + * Unlock a specific IP. No ip param = unlock all. + */ +export async function unlockIpHandler(ctx: Context) { + const ip = ctx.query.ip as string + if (ip) { + const found = unlockIp(ip) + if (!found) { + ctx.status = 404 + ctx.body = { error: 'IP not locked' } + return + } + ctx.body = { success: true } + return + } + // No IP specified — unlock all + const count = unlockAll() + ctx.body = { success: true, count } +} diff --git a/packages/server/src/controllers/coding-agents.ts b/packages/server/src/controllers/coding-agents.ts new file mode 100644 index 0000000..1ae5d64 --- /dev/null +++ b/packages/server/src/controllers/coding-agents.ts @@ -0,0 +1,119 @@ +import type { Context } from 'koa' +import { + deleteCodingAgent, + getCodingAgentsStatus, + installCodingAgent, + openCodingAgentNativeTerminal, + prepareCodingAgentLaunch, + readCodingAgentConfigFile, + writeCodingAgentConfigFile, + type CodingAgentConfigScope, +} from '../services/coding-agents' + +function configScope(ctx: Context): CodingAgentConfigScope { + const body = ctx.request.body as { profile?: unknown; provider?: unknown } | undefined + return { + profile: ctx.state.profile?.name || (typeof ctx.query.profile === 'string' ? ctx.query.profile : '') || (typeof body?.profile === 'string' ? body.profile : ''), + provider: (typeof ctx.query.provider === 'string' ? ctx.query.provider : '') || (typeof body?.provider === 'string' ? body.provider : ''), + } +} + +export async function status(ctx: Context) { + try { + ctx.body = await getCodingAgentsStatus() + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message || 'Failed to inspect coding agents' } + } +} + +export async function install(ctx: Context) { + try { + const result = await installCodingAgent(ctx.params.id) + ctx.body = result + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to install coding agent' } + } +} + +export async function remove(ctx: Context) { + try { + const result = await deleteCodingAgent(ctx.params.id) + ctx.body = result + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to delete coding agent' } + } +} + +export async function readConfigFile(ctx: Context) { + try { + ctx.body = await readCodingAgentConfigFile(ctx.params.id, ctx.params.key, configScope(ctx)) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to read coding agent config file' } + } +} + +export async function writeConfigFile(ctx: Context) { + try { + const { content } = ctx.request.body as { content?: string } + ctx.body = await writeCodingAgentConfigFile(ctx.params.id, ctx.params.key, content || '', configScope(ctx)) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to write coding agent config file' } + } +} + +export async function prepareLaunch(ctx: Context) { + try { + const body = ctx.request.body as { + mode?: any + profile?: string + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: any + } + ctx.body = await prepareCodingAgentLaunch(ctx.params.id, { + mode: body.mode, + profile: ctx.state.profile?.name || body.profile, + provider: body.provider, + model: body.model, + baseUrl: body.baseUrl, + apiKey: body.apiKey, + apiMode: body.apiMode, + }) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to prepare coding agent launch' } + } +} + +export async function nativeLaunch(ctx: Context) { + try { + const body = ctx.request.body as { + mode?: any + profile?: string + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: any + } + ctx.body = await openCodingAgentNativeTerminal(ctx.params.id, { + mode: body.mode, + profile: ctx.state.profile?.name || body.profile, + provider: body.provider, + model: body.model, + baseUrl: body.baseUrl, + apiKey: body.apiKey, + apiMode: body.apiMode, + }) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to launch native terminal' } + } +} diff --git a/packages/server/src/controllers/health.ts b/packages/server/src/controllers/health.ts new file mode 100644 index 0000000..62ce153 --- /dev/null +++ b/packages/server/src/controllers/health.ts @@ -0,0 +1,100 @@ +import { existsSync, readFileSync } from 'fs' +import { resolve } from 'path' +import * as hermesCli from '../services/hermes/hermes-cli' + +declare const __APP_VERSION__: string + +type PackageInfo = { + name: string + version: string +} + +function readPackageInfo(): PackageInfo | null { + const candidatePaths = [ + // ts-node dev: packages/server/src/controllers -> repo root + resolve(__dirname, '../../../../package.json'), + // bundled server: dist/server -> repo root/package root + resolve(__dirname, '../../package.json'), + // fallback for dev/test processes started at the repo root + resolve(process.cwd(), 'package.json'), + ] + + for (const packagePath of candidatePaths) { + if (!existsSync(packagePath)) continue + + try { + const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')) + if (pkg?.name && pkg?.version) { + return { + name: String(pkg.name), + version: String(pkg.version), + } + } + } catch { + // Try the next candidate path. + } + } + + return null +} + +const PACKAGE_INFO = readPackageInfo() +const LOCAL_VERSION = typeof __APP_VERSION__ !== 'undefined' + ? __APP_VERSION__ + : PACKAGE_INFO?.version || '' + +let cachedLatestVersion = '' + +/** + * Whether the periodic npm-registry version check is disabled. + * + * Useful when hermes-web-ui is bundled inside a packaged distribution + * (e.g. a desktop app) where the user can't `npm install -g hermes-web-ui@latest` + * to upgrade — the "update available" prompt would be misleading and + * the periodic outbound HTTP request to the npm registry is unnecessary. + * + * Set HERMES_WEB_UI_DISABLE_UPDATE_CHECK=true (or 1, on, yes) to disable. + */ +function isUpdateCheckDisabled(): boolean { + const raw = (process.env.HERMES_WEB_UI_DISABLE_UPDATE_CHECK || '').trim().toLowerCase() + return raw === 'true' || raw === '1' || raw === 'on' || raw === 'yes' +} + +export async function checkLatestVersion(): Promise { + if (isUpdateCheckDisabled()) return + try { + const packageName = PACKAGE_INFO?.name || 'hermes-web-ui' + const registryName = encodeURIComponent(packageName) + const res = await fetch(`https://registry.npmjs.org/${registryName}/latest`, { signal: AbortSignal.timeout(10000) }) + if (res.ok) { + const data = await res.json() as { version: string } + cachedLatestVersion = data.version + if (LOCAL_VERSION && cachedLatestVersion !== LOCAL_VERSION) { + console.log(`Update available: ${LOCAL_VERSION} → ${cachedLatestVersion}`) + } + } + } catch { /* ignore */ } +} + +export function startVersionCheck(): void { + if (isUpdateCheckDisabled()) return + setTimeout(checkLatestVersion, 5000) + setInterval(checkLatestVersion, 30 * 60 * 1000) +} + +export async function healthCheck(ctx: any) { + const raw = await hermesCli.getVersion() + const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || '' + ctx.body = { + status: 'ok', + platform: 'hermes-agent', + version: hermesVersion, + gateway: 'running', + webui_version: LOCAL_VERSION, + webui_latest: isUpdateCheckDisabled() ? '' : cachedLatestVersion, + webui_update_available: isUpdateCheckDisabled() + ? false + : Boolean(LOCAL_VERSION && cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION), + node_version: process.versions.node, + } +} diff --git a/packages/server/src/controllers/hermes/codex-auth.ts b/packages/server/src/controllers/hermes/codex-auth.ts new file mode 100644 index 0000000..4760a51 --- /dev/null +++ b/packages/server/src/controllers/hermes/codex-auth.ts @@ -0,0 +1,243 @@ +import { randomUUID } from 'crypto' +import { join, dirname } from 'path' +import { homedir } from 'os' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { logger } from '../../services/logger' + +// --- OAuth Constants --- +const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode' +const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token' +const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' +const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex' +const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback' +const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device' +const CODEX_HOME = join(homedir(), '.codex') +const POLL_MAX_DURATION = 15 * 60 * 1000 +const POLL_DEFAULT_INTERVAL = 5000 + +// --- Session Store --- +interface CodexSession { + id: string; userCode: string; deviceAuthId: string + profile: string + status: 'pending' | 'approved' | 'expired' | 'error' + error?: string; accessToken?: string; refreshToken?: string; createdAt: number +} + +const sessions = new Map() + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((session, id) => { if (now - session.createdAt > POLL_MAX_DURATION + 60000) { sessions.delete(id) } }) +} + +// --- Auth file helpers --- +interface AuthJson { version?: number; active_provider?: string; providers?: Record; credential_pool?: Record; updated_at?: string } +interface CodexCredentialRef { + accessToken: string + refreshToken?: string + lastRefresh?: string + provider?: any + poolEntry?: any +} + +function loadAuthJson(authPath: string): AuthJson { + try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } +} + +function saveAuthJson(authPath: string, data: AuthJson): void { + data.updated_at = new Date().toISOString() + const dir = dirname(authPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +function saveCodexCliTokens(accessToken: string, refreshToken: string): void { + const codexHome = process.env.CODEX_HOME || CODEX_HOME + const codexAuthPath = join(codexHome, 'auth.json') + const dir = dirname(codexAuthPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(codexAuthPath, JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 }) +} + +function requestedProfile(ctx: any): string { + const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : '' + const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : '' + const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : '' + return ctx.state?.profile?.name || + headerProfile.trim() || + queryProfile.trim() || + bodyProfile.trim() || + getActiveProfileName() || + 'default' +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +function decodeJwtExp(token: string): number | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8') + const claims = JSON.parse(payload) + return typeof claims.exp === 'number' ? claims.exp : null + } catch { return null } +} + +function getCodexCredential(auth: AuthJson): CodexCredentialRef | null { + const provider = auth.providers?.['openai-codex'] + const providerTokens = provider?.tokens + const providerAccessToken = providerTokens?.access_token || provider?.access_token + const pool = auth.credential_pool?.['openai-codex'] + const poolEntry = Array.isArray(pool) ? pool.find(entry => entry?.access_token) : undefined + + if (providerAccessToken) { + return { + accessToken: providerAccessToken, + refreshToken: providerTokens?.refresh_token || provider?.refresh_token, + lastRefresh: provider.last_refresh, + provider, + poolEntry, + } + } + + if (poolEntry?.access_token) { + return { + accessToken: poolEntry.access_token, + refreshToken: poolEntry.refresh_token, + lastRefresh: poolEntry.last_refresh, + poolEntry, + } + } + + return null +} + +// --- Background login worker --- +export function saveCodexOAuthTokensForProfile(profile: string, accessToken: string, refreshToken: string): void { + const authPath = authPathForProfile(profile) + const auth = loadAuthJson(authPath) + if (!auth.providers) auth.providers = {} + auth.providers['openai-codex'] = { tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString(), auth_mode: 'chatgpt' } + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['openai-codex'] = [{ id: `openai-codex-${Date.now()}`, label: 'OpenAI Codex', base_url: CODEX_DEFAULT_BASE_URL, access_token: accessToken, last_status: null }] + saveAuthJson(authPath, auth) + saveCodexCliTokens(accessToken, refreshToken) +} + +async function codexLoginWorker(session: CodexSession): Promise { + const startTime = Date.now() + const interval = POLL_DEFAULT_INTERVAL + while (Date.now() - startTime < POLL_MAX_DURATION) { + await new Promise(resolve => setTimeout(resolve, interval)) + if (session.status !== 'pending') return + try { + const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_auth_id: session.deviceAuthId, user_code: session.userCode }), + signal: AbortSignal.timeout(10000), + }) + if (pollRes.status === 200) { + const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string } + const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code: pollData.authorization_code, redirect_uri: CODEX_REDIRECT_URI, client_id: CODEX_CLIENT_ID, code_verifier: pollData.code_verifier }).toString(), + signal: AbortSignal.timeout(15000), + }) + if (!tokenRes.ok) { const errText = await tokenRes.text(); logger.error('Token exchange failed: %d %s', tokenRes.status, errText); session.status = 'error'; session.error = `Token exchange failed: ${tokenRes.status}`; return } + const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string } + const refreshToken = tokenData.refresh_token || '' + session.accessToken = tokenData.access_token; session.refreshToken = refreshToken; session.status = 'approved' + saveCodexOAuthTokensForProfile(session.profile, tokenData.access_token, refreshToken) + logger.info('Login successful') + return + } + if (pollRes.status === 403 || pollRes.status === 404) { continue } + logger.error('Poll failed: %d', pollRes.status); session.status = 'error'; session.error = `Poll failed: ${pollRes.status}`; return + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') { continue } + logger.error(err, 'Poll error'); session.status = 'error'; session.error = err.message; return + } + } + session.status = 'expired' +} + +// --- Controller functions --- + +export async function start(ctx: any) { + try { + cleanupExpiredSessions() + const res = await fetch(CODEX_DEVICE_AUTH_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'node-fetch' }, + body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), signal: AbortSignal.timeout(10000), + }) + if (!res.ok) { + let errorBody: any = null; try { errorBody = await res.json() } catch { } + logger.error('Device code request failed: %d %s', res.status, errorBody) + let errorMessage = `Device code request failed: ${res.status}` + if (errorBody?.error?.code === 'unsupported_country_region_territory') { errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' } + ctx.status = 502; ctx.body = { error: errorMessage, code: errorBody?.error?.code }; return + } + const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string } + const sessionId = randomUUID() + const session: CodexSession = { id: sessionId, userCode: data.user_code, deviceAuthId: data.device_auth_id, profile: requestedProfile(ctx), status: 'pending', createdAt: Date.now() } + sessions.set(sessionId, session) + codexLoginWorker(session).catch(err => { logger.error(err, 'Worker error'); session.status = 'error'; session.error = err.message }) + ctx.body = { session_id: sessionId, user_code: data.user_code, verification_url: CODEX_VERIFICATION_URL, expires_in: 900 } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function poll(ctx: any) { + const session = sessions.get(ctx.params.sessionId) + if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return } + ctx.body = { status: session.status, error: session.error || null } +} + +export async function status(ctx: any) { + try { + const authPath = authPathForProfile(requestedProfile(ctx)) + const auth = loadAuthJson(authPath) + const credential = getCodexCredential(auth) + if (!credential) { ctx.body = { authenticated: false }; return } + const exp = decodeJwtExp(credential.accessToken) + if (exp && exp <= Date.now() / 1000 + 120) { + if (credential.refreshToken) { + try { + const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: credential.refreshToken, client_id: CODEX_CLIENT_ID }).toString(), + signal: AbortSignal.timeout(15000), + }) + if (refreshRes.ok) { + const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string } + const lastRefresh = new Date().toISOString() + if (credential.provider?.tokens) { + credential.provider.tokens.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.provider.tokens.refresh_token = newTokens.refresh_token } + credential.provider.last_refresh = lastRefresh + } else if (credential.provider) { + credential.provider.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.provider.refresh_token = newTokens.refresh_token } + credential.provider.last_refresh = lastRefresh + } + if (credential.poolEntry) { + credential.poolEntry.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.poolEntry.refresh_token = newTokens.refresh_token } + credential.poolEntry.last_refresh = lastRefresh + } + saveAuthJson(authPath, auth) + saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || credential.refreshToken) + ctx.body = { authenticated: true, last_refresh: lastRefresh }; return + } + } catch { } + } + ctx.body = { authenticated: false }; return + } + ctx.body = { authenticated: true, last_refresh: credential.lastRefresh } + } catch { ctx.body = { authenticated: false } } +} diff --git a/packages/server/src/controllers/hermes/config.ts b/packages/server/src/controllers/hermes/config.ts new file mode 100644 index 0000000..2133fc1 --- /dev/null +++ b/packages/server/src/controllers/hermes/config.ts @@ -0,0 +1,236 @@ +import { readFile } from 'fs/promises' +import { join } from 'path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart' +import { saveEnvValueForProfile } from '../../services/config-helpers' +import { logger } from '../../services/logger' +import { safeFileStore } from '../../services/safe-file-store' + +const PLATFORM_SECTIONS = new Set([ + 'telegram', 'discord', 'slack', 'whatsapp', 'matrix', + 'weixin', 'wecom', 'feishu', 'dingtalk', 'qqbot', + 'approvals', +]) + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +const configPath = (profile: string) => join(getProfileDir(profile), 'config.yaml') +const envPath = (profile: string) => join(getProfileDir(profile), '.env') + +const envPlatformMap: Record = { + TELEGRAM_BOT_TOKEN: ['telegram', 'token'], + DISCORD_BOT_TOKEN: ['discord', 'token'], + SLACK_BOT_TOKEN: ['slack', 'token'], + MATRIX_ACCESS_TOKEN: ['matrix', 'token'], + MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'], + FEISHU_APP_ID: ['feishu', 'extra.app_id'], + FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'], + DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'], + DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'], + DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'], + DINGTALK_CARD_TEMPLATE_ID: ['dingtalk', 'extra.card_template_id'], + DINGTALK_ALLOWED_USERS: ['dingtalk', 'allowed_users'], + DINGTALK_ALLOW_ALL_USERS: ['dingtalk', 'allow_all_users'], + QQ_APP_ID: ['qqbot', 'extra.app_id'], + QQ_CLIENT_SECRET: ['qqbot', 'extra.client_secret'], + QQ_ALLOWED_USERS: ['qqbot', 'allowed_users'], + QQ_ALLOW_ALL_USERS: ['qqbot', 'allow_all_users'], + WECOM_BOT_ID: ['wecom', 'extra.bot_id'], + WECOM_SECRET: ['wecom', 'extra.secret'], + WEIXIN_TOKEN: ['weixin', 'token'], + WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'], + WEIXIN_BASE_URL: ['weixin', 'extra.base_url'], + WHATSAPP_ENABLED: ['whatsapp', 'enabled'], +} + +const platformEnvMap: Record> = {} +for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) { + if (!platformEnvMap[platform]) platformEnvMap[platform] = {} + platformEnvMap[platform][cfgPath] = envVar +} + +function parseEnv(raw: string): Record { + const env: Record = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const val = trimmed.slice(eqIdx + 1).trim() + if (val) env[key] = val + } + return env +} + +function setNested(obj: Record, path: string, value: any) { + const parts = path.split('.') + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]] } + cur[parts[parts.length - 1]] = value +} + +function deepMerge(target: Record, source: Record): Record { + for (const key of Object.keys(source)) { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && + target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { + target[key] = deepMerge(target[key], source[key]) + } else { + target[key] = source[key] + } + } + return target +} + +async function readEnvPlatforms(profile: string): Promise> { + try { + const raw = await readFile(envPath(profile), 'utf-8') + const env = parseEnv(raw) + const platforms: Record = {} + for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) { + const val = env[envKey] + if (val === undefined || val === '') continue + if (!platforms[platform]) platforms[platform] = {} + let finalVal: any = val + if (cfgPath === 'enabled' || cfgPath === 'allow_all_users') finalVal = val === 'true' + setNested(platforms[platform], cfgPath, finalVal) + } + return platforms + } catch { return {} } +} + +async function readConfig(profile: string): Promise> { + return safeFileStore.readYaml(configPath(profile)) +} + +export async function getConfig(ctx: any) { + try { + const profile = requestedProfile(ctx) + const config = await readConfig(profile) + const envPlatforms = await readEnvPlatforms(profile) + if (Object.keys(envPlatforms).length > 0) { + const existing = config.platforms || {} + for (const [platform, vals] of Object.entries(envPlatforms)) { + existing[platform] = deepMerge(existing[platform] || {}, vals as Record) + } + config.platforms = existing + } + const { section, sections } = ctx.query + if (section) { + ctx.body = { [section as string]: config[section as string] || {} } + } else if (sections) { + const keys = (sections as string).split(',') + const result: Record = {} + for (const key of keys) { result[key.trim()] = config[key.trim()] || {} } + ctx.body = result + } else { + ctx.body = config + } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function updateConfig(ctx: any) { + const { section, values, restart } = ctx.request.body as { section: string; values: Record; restart?: boolean } + if (!section || !values) { + ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return + } + try { + const profile = requestedProfile(ctx) + await safeFileStore.updateYaml(configPath(profile), (config) => { + config[section] = deepMerge(config[section] || {}, values) + return config + }, { + backup: true, + dumpOptions: { + forceQuotes: true, + }, + }) + + // Platform adapters run through Hermes gateway; restart it so channel + // config changes (Feishu/Weixin/etc.) are applied. + if (restart !== false && PLATFORM_SECTIONS.has(section)) { + try { + const restartResult = await restartGatewayForProfile(profile) + logger.info('[config] gateway restarted after config update section=%s profile=%s result=%j', section, profile, restartResult) + } catch (err) { + logger.error(err, 'Gateway restart failed') + ctx.status = 500 + ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' } + return + } + } + + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function updateCredentials(ctx: any) { + const { platform, values } = ctx.request.body as { platform: string; values: Record } + if (!platform || !values) { + ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return + } + try { + const profile = requestedProfile(ctx) + const envMap = platformEnvMap[platform] + if (!envMap) { + ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return + } + const flatValues: Record = {} + for (const [key, val] of Object.entries(values)) { + if (key === 'extra' && val && typeof val === 'object') { + for (const [subKey, subVal] of Object.entries(val as Record)) { flatValues[`extra.${subKey}`] = subVal } + } else { flatValues[key] = val } + } + await safeFileStore.updateYaml(configPath(profile), async (config) => { + for (const [cfgPath, val] of Object.entries(flatValues)) { + const envVar = envMap[cfgPath] + if (!envVar) continue + if (val === undefined || val === null || val === '') { + await saveEnvValueForProfile(profile, envVar, '') + const parts = cfgPath.split('.') + let obj: any = config.platforms?.[platform] + if (obj) { + if (parts.length === 1) { delete obj[parts[0]] } + else { + let cur = obj + for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] } + delete cur[parts[parts.length - 1]] + if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra + } + if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] } + } + } else { + await saveEnvValueForProfile(profile, envVar, String(val)) + } + } + return config + }, { + backup: true, + dumpOptions: { + forceQuotes: true, + }, + }) + + // Platform adapters run through Hermes gateway; restart it so channel + // credentials are applied. + try { + const restartResult = await restartGatewayForProfile(profile) + logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%j', platform, profile, restartResult) + } catch (err) { + logger.error(err, 'Gateway restart failed') + ctx.status = 500 + ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' } + return + } + + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/copilot-auth.ts b/packages/server/src/controllers/hermes/copilot-auth.ts new file mode 100644 index 0000000..168a2bf --- /dev/null +++ b/packages/server/src/controllers/hermes/copilot-auth.ts @@ -0,0 +1,238 @@ +import { randomUUID } from 'crypto' +import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow' +import { saveEnvValue, updateConfigYaml } from '../../services/config-helpers' +import { + invalidateAllCaches, + resolveCopilotOAuthTokenWithSource, + type CopilotTokenSource, +} from '../../services/hermes/copilot-models' +import { getActiveEnvPath } from '../../services/hermes/hermes-profile' +import { readAppConfig, writeAppConfig } from '../../services/app-config' +import { readFile } from 'fs/promises' +import { logger } from '../../services/logger' + +const POLL_MAX_DURATION_MS = 15 * 60 * 1000 // 15 minutes hard ceiling +const SESSION_GC_GRACE_MS = 60 * 1000 + +interface CopilotLoginSession { + id: string + deviceCode: string + userCode: string + verificationUrl: string + expiresIn: number + interval: number + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error?: string + createdAt: number +} + +const sessions = new Map() + +function cleanupSessions(): void { + const now = Date.now() + sessions.forEach((s, id) => { + if (now - s.createdAt > POLL_MAX_DURATION_MS + SESSION_GC_GRACE_MS) { + sessions.delete(id) + } + }) +} + +async function persistToken(token: string): Promise { + // 与 disable 对称:只动 ~/.hermes/.env,apps.json 是 VS Code 的文件不要碰。 + // 同时把 enabled 置 true —— device flow 完成后用户已显式同意启用 Copilot。 + // NOTE: 故意不写 process.env.COPILOT_GITHUB_TOKEN —— 否则该值会跨 profile 持续覆盖 + // resolveCopilotOAuthTokenWithSource 的 .env 读取,导致切到别的 profile 仍解析到当前 + // profile 的 token。invalidateAllCaches() + .env 文件本身已能保证下次解析读到新 token。 + await saveEnvValue('COPILOT_GITHUB_TOKEN', token) + await writeAppConfig({ copilotEnabled: true }) + invalidateAllCaches() +} + +async function readEnvContent(): Promise { + try { return await readFile(getActiveEnvPath(), 'utf-8') } catch { return '' } +} + +async function loginWorker(session: CopilotLoginSession): Promise { + const startTime = Date.now() + let interval = Math.max(1, session.interval) * 1000 + + while (Date.now() - startTime < POLL_MAX_DURATION_MS) { + await new Promise((resolve) => setTimeout(resolve, interval)) + if (session.status !== 'pending') return + + const result = await pollDeviceFlow(session.deviceCode) + + if (result.kind === 'success') { + try { + await persistToken(result.access_token) + session.status = 'approved' + logger.info('Copilot OAuth login successful') + } catch (err: any) { + logger.error(err, 'Copilot OAuth: failed to persist token') + session.status = 'error' + session.error = err?.message ?? 'failed to persist token' + } + return + } + + if (result.kind === 'pending') continue + if (result.kind === 'slow_down') { + interval += 5000 + continue + } + if (result.kind === 'denied') { + session.status = 'denied' + return + } + if (result.kind === 'expired') { + session.status = 'expired' + return + } + logger.error('Copilot OAuth poll error: %s %s', result.error, result.description ?? '') + session.status = 'error' + session.error = result.description ?? result.error + return + } + + session.status = 'expired' +} + +export async function start(ctx: any): Promise { + cleanupSessions() + try { + const data = await startDeviceFlow() + const sessionId = randomUUID() + const session: CopilotLoginSession = { + id: sessionId, + deviceCode: data.device_code, + userCode: data.user_code, + verificationUrl: data.verification_uri, + expiresIn: data.expires_in, + interval: data.interval, + status: 'pending', + createdAt: Date.now(), + } + sessions.set(sessionId, session) + + loginWorker(session).catch((err) => { + logger.error(err, 'Copilot login worker error') + session.status = 'error' + session.error = err?.message ?? String(err) + }) + + ctx.body = { + session_id: sessionId, + user_code: data.user_code, + verification_url: data.verification_uri, + expires_in: data.expires_in, + interval: data.interval, + } + } catch (err: any) { + logger.error(err, 'Copilot OAuth start failed') + if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { + ctx.status = 504 + ctx.body = { error: 'GitHub timeout' } + return + } + ctx.status = 502 + ctx.body = { error: err?.message ?? 'GitHub OAuth start failed' } + } +} + +export async function poll(ctx: any): Promise { + const session = sessions.get(ctx.params.sessionId) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + ctx.body = { status: session.status, error: session.error || null } +} + +/** + * Reports current token resolution and whether Copilot is opt-in enabled. + * Frontend Add Provider flow uses this to decide whether to show the + * "token detected, click Add" confirmation or kick off device flow. + * + * Side effect: invalidates the model list cache so a subsequent listing + * picks up gh-cli logout / VS Code sign-out without server restart. + */ +export async function checkToken(ctx: any): Promise { + invalidateAllCaches() + const env = await readEnvContent() + const { token, source } = await resolveCopilotOAuthTokenWithSource(env) + const cfg = await readAppConfig() + ctx.body = { + has_token: Boolean(token), + source: source as CopilotTokenSource, + enabled: cfg.copilotEnabled === true, + } +} + +export async function enable(ctx: any): Promise { + await writeAppConfig({ copilotEnabled: true }) + invalidateAllCaches() + ctx.body = { ok: true } +} + +/** + * "Soft delete" Copilot from the web-ui provider list. + * - Always: copilotEnabled = false (hides provider regardless of token source). + * - source='env' → also clear ~/.hermes/.env COPILOT_GITHUB_TOKEN + * (this token belongs to the hermes ecosystem). + * - source='gh-cli' → leave gh CLI alone (user's terminal sessions). + * - source='apps-json' → leave VS Code Copilot plugin alone. + * The user can re-add Copilot any time via "Add Provider". + */ +export async function disable(ctx: any): Promise { + const env = await readEnvContent() + const { source } = await resolveCopilotOAuthTokenWithSource(env) + + // 步骤 1:先清掉默认模型(最容易失败的一步:写 yaml 可能失败)。 + // 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。 + let clearedDefault = false + try { + clearedDefault = await updateConfigYaml((cfg) => { + const modelSection = cfg.model + if (typeof modelSection === 'object' && modelSection !== null) { + const provider = String(modelSection.provider || '').trim().toLowerCase() + if (provider === 'copilot') { + cfg.model = {} + return { data: cfg, result: true } + } + } + return { data: cfg, result: false, write: false } + }) || false + } catch (err: any) { + logger.error(err, 'Copilot disable failed: cannot clear default model') + ctx.status = 500 + ctx.body = { error: `failed to clear default model: ${err?.message ?? 'unknown error'}` } + return + } + + // 步骤 2:清 .env(仅当 source='env')。失败也不能让 enabled flag 偷偷置 false。 + try { + if (source === 'env') { + await saveEnvValue('COPILOT_GITHUB_TOKEN', '') + delete process.env.COPILOT_GITHUB_TOKEN + } + } catch (err: any) { + logger.error(err, 'Copilot disable failed: cannot clear .env') + ctx.status = 500 + ctx.body = { error: `failed to clear .env: ${err?.message ?? 'unknown error'}` } + return + } + + // 步骤 3:最后翻 enabled flag。前两步成功才执行。 + try { + await writeAppConfig({ copilotEnabled: false }) + invalidateAllCaches() + } catch (err: any) { + logger.error(err, 'Copilot disable failed: cannot persist enabled flag') + ctx.status = 500 + ctx.body = { error: `failed to persist enabled flag: ${err?.message ?? 'unknown error'}` } + return + } + + ctx.body = { ok: true, cleared_env: source === 'env', cleared_default: clearedDefault } +} diff --git a/packages/server/src/controllers/hermes/cron-history.ts b/packages/server/src/controllers/hermes/cron-history.ts new file mode 100644 index 0000000..3045936 --- /dev/null +++ b/packages/server/src/controllers/hermes/cron-history.ts @@ -0,0 +1,308 @@ +import type { Context } from 'koa' +import { readdir, stat, readFile } from 'fs/promises' +import { join } from 'path' +import { existsSync } from 'fs' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' + +const SYNTHETIC_RUN_FILE = '__scheduler_metadata__.md' + +function requestedProfile(ctx: Context): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function getCronOutputDir(profile: string): string { + const profileDir = getProfileDir(profile) + return join(profileDir, 'cron', 'output') +} + +function getCronJobsFile(profile: string): string { + const profileDir = getProfileDir(profile) + return join(profileDir, 'cron', 'jobs.json') +} + +export interface RunEntry { + jobId: string + fileName: string + runTime: string + size: number + hasOutput?: boolean + synthetic?: boolean + runCount?: number + status?: string | null + error?: string | null +} + +export interface RunDetail { + jobId: string + fileName: string + runTime: string + content: string +} + +interface CronJobMetadata { + id?: string + job_id?: string + name?: string + last_run_at?: string | null + last_status?: string | null + last_error?: string | null + run_count?: number | string | null + no_agent?: boolean + script?: string | null +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function getJobId(job: CronJobMetadata): string | null { + return stringOrNull(job.job_id) || stringOrNull(job.id) +} + +function isCronJobMetadata(value: unknown): value is CronJobMetadata { + return Boolean(value && typeof value === 'object') +} + +function normaliseJobsPayload(payload: unknown): CronJobMetadata[] { + if (Array.isArray(payload)) return payload.filter(isCronJobMetadata) + if (payload && typeof payload === 'object') { + const maybeJobs = (payload as { jobs?: unknown }).jobs + if (Array.isArray(maybeJobs)) return maybeJobs.filter(isCronJobMetadata) + } + return [] +} + +async function readCronJobs(profile: string): Promise { + const jobsFile = getCronJobsFile(profile) + if (!existsSync(jobsFile)) return [] + + try { + const raw = await readFile(jobsFile, 'utf-8') + return normaliseJobsPayload(JSON.parse(raw)) + } catch { + return [] + } +} + +function coerceRunCount(value: CronJobMetadata['run_count']): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function toDisplayTime(value: string): string { + const isoLike = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/) + if (isoLike) return `${isoLike[1]} ${isoLike[2]}:${isoLike[3]}:${isoLike[4]}` + + const legacy = value.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})$/) + if (legacy) return `${legacy[1]} ${legacy[2].replace(/-/g, ':')}` + + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return new Date(parsed).toISOString().replace('T', ' ').slice(0, 19) + } + + return value +} + +function parseRunTimeFromFileName(fileName: string): string { + const base = fileName.endsWith('.md') ? fileName.slice(0, -3) : fileName + return toDisplayTime(base) +} + +function syntheticRunEntry(job: CronJobMetadata): RunEntry | null { + const jobId = getJobId(job) + const lastRunAt = stringOrNull(job.last_run_at) + if (!jobId || !lastRunAt) return null + + return { + jobId, + fileName: SYNTHETIC_RUN_FILE, + runTime: toDisplayTime(lastRunAt), + size: 0, + hasOutput: false, + synthetic: true, + runCount: coerceRunCount(job.run_count), + status: stringOrNull(job.last_status), + error: stringOrNull(job.last_error), + } +} + +function hasRunForJobAtOrAfter(runs: RunEntry[], jobId: string, runTime: string): boolean { + return runs.some(run => run.jobId === jobId && run.runTime >= runTime) +} + +function inlineCode(value: unknown): string { + const text = String(value) + let longestBacktickRun = 0 + let currentBacktickRun = 0 + + for (const char of text) { + if (char === '`') { + currentBacktickRun += 1 + if (currentBacktickRun > longestBacktickRun) longestBacktickRun = currentBacktickRun + } else { + currentBacktickRun = 0 + } + } + + const delimiter = '`'.repeat(longestBacktickRun + 1) + return `${delimiter} ${text} ${delimiter}` +} + +function buildSyntheticContent(job: CronJobMetadata, runTime: string): string { + const explanation = job.no_agent || stringOrNull(job.script) + ? 'This is expected for script-only/no-agent watchdog jobs when the script exits successfully with empty stdout: Hermes treats the run as silent, so there is nothing to deliver and no output file to display.' + : 'This can happen when a cron run updates scheduler metadata but does not produce a markdown output artifact to display.' + + const lines = [ + '# Scheduler run recorded', + '', + 'Hermes recorded this cron job as having run, but no markdown output artifact was written for this job.', + '', + explanation, + '', + `- Job: ${inlineCode(job.name || getJobId(job) || 'unknown')}`, + `- Last run: ${inlineCode(runTime)}`, + ] + + const runCount = coerceRunCount(job.run_count) + const lastStatus = stringOrNull(job.last_status) + const lastError = stringOrNull(job.last_error) + const script = stringOrNull(job.script) + if (runCount !== undefined) lines.push(`- Recorded runs: ${inlineCode(runCount)}`) + if (lastStatus) lines.push(`- Last status: ${inlineCode(lastStatus)}`) + if (lastError) lines.push(`- Last error: ${inlineCode(lastError)}`) + if (script) lines.push(`- Script: ${inlineCode(script)}`) + if (job.no_agent) lines.push('- Mode: `no-agent/script-only`') + + return `${lines.join('\n')}\n` +} + +/** List all run output files, optionally filtered by job ID */ +export async function listRuns(ctx: Context) { + const jobId = ctx.query.jobId as string | undefined + const profile = requestedProfile(ctx) + const cronOutput = getCronOutputDir(profile) + + try { + const runs: RunEntry[] = [] + + if (existsSync(cronOutput)) { + const dirs = await readdir(cronOutput) + const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs + + for (const dir of targetDirs) { + const dirPath = join(cronOutput, dir) + try { + const dirStat = await stat(dirPath) + if (!dirStat.isDirectory()) continue + + const files = await readdir(dirPath) + // Sort by filename descending (newest first, since filenames are timestamps) + const sorted = files.sort().reverse() + + for (const file of sorted) { + if (!file.endsWith('.md')) continue + const filePath = join(dirPath, file) + try { + const fileStat = await stat(filePath) + + runs.push({ + jobId: dir, + fileName: file, + runTime: parseRunTimeFromFileName(file), + size: fileStat.size, + hasOutput: true, + }) + } catch { /* skip unreadable files */ } + } + } catch { /* skip unreadable dirs */ } + } + } + + const jobs = await readCronJobs(profile) + const targetJobs = jobId ? jobs.filter(job => getJobId(job) === jobId) : jobs + for (const job of targetJobs) { + const id = getJobId(job) + if (!id) continue + const synthetic = syntheticRunEntry(job) + if (synthetic && !hasRunForJobAtOrAfter(runs, id, synthetic.runTime)) runs.push(synthetic) + } + + // Sort all runs by runTime descending + runs.sort((a, b) => b.runTime.localeCompare(a.runTime)) + + ctx.body = { runs } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +/** Read a specific run output file */ +export async function readRun(ctx: Context) { + const { jobId, fileName } = ctx.params + const profile = requestedProfile(ctx) + + if (!jobId || !fileName) { + ctx.status = 400 + ctx.body = { error: 'jobId and fileName are required' } + return + } + + // Prevent path traversal + if ( + jobId.includes('..') + || fileName.includes('..') + || jobId.includes('/') + || fileName.includes('/') + || jobId.includes('\\') + || fileName.includes('\\') + ) { + ctx.status = 400 + ctx.body = { error: 'Invalid path' } + return + } + + if (fileName === SYNTHETIC_RUN_FILE) { + const jobs = await readCronJobs(profile) + const job = jobs.find(candidate => getJobId(candidate) === jobId) + const synthetic = job ? syntheticRunEntry(job) : null + if (!job || !synthetic) { + ctx.status = 404 + ctx.body = { error: 'Run output not found' } + return + } + + ctx.body = { + jobId, + fileName, + runTime: synthetic.runTime, + content: buildSyntheticContent(job, synthetic.runTime), + } satisfies RunDetail + return + } + + const cronOutput = getCronOutputDir(profile) + const filePath = join(cronOutput, jobId, fileName) + + if (!existsSync(filePath)) { + ctx.status = 404 + ctx.body = { error: 'Run output not found' } + return + } + + try { + const content = await readFile(filePath, 'utf-8') + const runTime = parseRunTimeFromFileName(fileName) + + ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/jobs.ts b/packages/server/src/controllers/hermes/jobs.ts new file mode 100644 index 0000000..96a4b98 --- /dev/null +++ b/packages/server/src/controllers/hermes/jobs.ts @@ -0,0 +1,308 @@ +import type { Context } from 'koa' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { getHermesBin } from '../../services/hermes/hermes-path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { execHermesWithBin } from '../../services/hermes/hermes-process' + +const TIMEOUT_MS = 60_000 + +type JobRecord = Record + +function resolveProfile(ctx: Context): string { + const requestedProfile = ctx.state?.profile?.name + return requestedProfile || getActiveProfileName() +} + +function resolveProfileDir(profile: string): string { + return getProfileDir(profile || 'default') +} + +function getJobsPath(profile: string): string { + return join(resolveProfileDir(profile), 'cron', 'jobs.json') +} + +function normalizeJob(job: JobRecord): JobRecord { + const id = job.job_id || job.id + const skills = Array.isArray(job.skills) + ? job.skills + : (job.skill ? [job.skill] : []) + + return { + ...job, + id, + job_id: id, + skills, + skill: job.skill ?? skills[0] ?? null, + model: job.model ?? null, + provider: job.provider ?? null, + base_url: job.base_url ?? null, + script: job.script ?? null, + schedule_display: job.schedule_display ?? job.schedule?.display ?? job.schedule?.expr ?? '', + repeat: job.repeat ?? { times: null, completed: 0 }, + enabled: job.enabled ?? true, + state: job.state ?? ((job.enabled ?? true) ? 'scheduled' : 'paused'), + paused_at: job.paused_at ?? null, + paused_reason: job.paused_reason ?? null, + created_at: job.created_at ?? '', + next_run_at: job.next_run_at ?? null, + last_run_at: job.last_run_at ?? null, + last_status: job.last_status ?? null, + last_error: job.last_error ?? null, + deliver: job.deliver ?? 'local', + origin: job.origin ?? null, + last_delivery_error: job.last_delivery_error ?? null, + } +} + +function readJobs(profile: string, includeDisabled = true): JobRecord[] { + const jobsPath = getJobsPath(profile) + if (!existsSync(jobsPath)) return [] + + const parsed = JSON.parse(readFileSync(jobsPath, 'utf-8')) + const rawJobs = Array.isArray(parsed) ? parsed : parsed?.jobs + const jobs = Array.isArray(rawJobs) ? rawJobs.map(normalizeJob) : [] + + if (includeDisabled) return jobs + return jobs.filter((job) => job.enabled !== false) +} + +function findJob(profile: string, jobId: string): JobRecord | null { + return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null +} + +function boolQuery(value: unknown, defaultValue: boolean): boolean { + if (value == null) return defaultValue + const text = String(value).toLowerCase() + return text === '1' || text === 'true' || text === 'yes' +} + +function getBody(ctx: Context): Record { + return (ctx.request.body && typeof ctx.request.body === 'object') + ? ctx.request.body as Record + : {} +} + +function getRepeatValue(repeat: unknown): number | null { + if (repeat == null || repeat === '') return null + if (typeof repeat === 'number' && Number.isFinite(repeat)) return repeat + if (typeof repeat === 'object') { + const times = (repeat as any).times + if (typeof times === 'number' && Number.isFinite(times)) return times + if (typeof times === 'string' && times.trim()) { + const parsed = Number(times) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + const parsed = Number(repeat) + return Number.isFinite(parsed) ? parsed : null +} + +function hasRepeatField(body: Record): boolean { + return Object.prototype.hasOwnProperty.call(body, 'repeat') +} + +function getSkills(body: Record): string[] | null { + if (Array.isArray(body.skills)) { + return body.skills.map((skill) => String(skill || '').trim()).filter(Boolean) + } + if (typeof body.skill === 'string') { + const skill = body.skill.trim() + return skill ? [skill] : [] + } + return null +} + +async function runHermesCron(profile: string, args: string[]): Promise { + const profileDir = resolveProfileDir(profile) + try { + await execHermesWithBin(getHermesBin(), args, { + cwd: process.cwd(), + env: { ...process.env, HERMES_HOME: profileDir }, + timeout: TIMEOUT_MS, + maxBuffer: 1024 * 1024, + windowsHide: true, + }) + } catch (error: any) { + const stderr = String(error?.stderr || '').trim() + const stdout = String(error?.stdout || '').trim() + throw new Error(stderr || stdout || error?.message || 'Hermes cron command failed') + } +} + +function sendJobNotFound(ctx: Context): void { + ctx.status = 404 + ctx.body = { error: { message: 'Job not found' } } +} + +function sendCommandError(ctx: Context, error: any): void { + ctx.status = 500 + ctx.body = { error: { message: error?.message || 'Hermes cron command failed' } } +} + +function findCreatedJob(beforeJobs: JobRecord[], afterJobs: JobRecord[]): JobRecord | null { + const beforeIds = new Set(beforeJobs.map((job) => job.job_id || job.id)) + const created = afterJobs.find((job) => !beforeIds.has(job.job_id || job.id)) + if (created) return created + + return [...afterJobs].sort((a, b) => { + const aTime = Date.parse(a.created_at || '') || 0 + const bTime = Date.parse(b.created_at || '') || 0 + return bTime - aTime + })[0] ?? null +} + +export async function list(ctx: Context) { + const profile = resolveProfile(ctx) + const includeDisabled = boolQuery(ctx.query.include_disabled, false) + ctx.body = { jobs: readJobs(profile, includeDisabled) } +} + +export async function get(ctx: Context) { + const profile = resolveProfile(ctx) + const job = findJob(profile, ctx.params.id) + if (!job) return sendJobNotFound(ctx) + ctx.body = { job } +} + +export async function create(ctx: Context) { + const profile = resolveProfile(ctx) + const body = getBody(ctx) + const schedule = String(body.schedule || body.schedule_display || '').trim() + const prompt = String(body.prompt || '').trim() + + if (!schedule) { + ctx.status = 400 + ctx.body = { error: { message: 'Schedule is required' } } + return + } + + const beforeJobs = readJobs(profile, true) + const args = ['cron', 'create'] + const name = String(body.name || '').trim() + if (name) args.push('--name', name) + if (body.deliver != null && String(body.deliver).trim()) args.push('--deliver', String(body.deliver).trim()) + + const repeat = getRepeatValue(body.repeat) + if (repeat != null) { + args.push('--repeat', String(repeat)) + } else if (hasRepeatField(body)) { + // Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat. + args.push('--repeat', '0') + } + + const skills = getSkills(body) + for (const skill of skills || []) args.push('--skill', skill) + + if (body.script != null && String(body.script).trim()) args.push('--script', String(body.script).trim()) + if (body.workdir != null) args.push('--workdir', String(body.workdir)) + if (body.no_agent === true) args.push('--no-agent') + + args.push(schedule) + if (prompt) args.push(prompt) + + try { + await runHermesCron(profile, args) + const job = findCreatedJob(beforeJobs, readJobs(profile, true)) + ctx.body = { job } + } catch (error: any) { + sendCommandError(ctx, error) + } +} + +export async function update(ctx: Context) { + const profile = resolveProfile(ctx) + const body = getBody(ctx) + if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx) + + const args = ['cron', 'edit', ctx.params.id] + if (body.schedule != null || body.schedule_display != null) { + args.push('--schedule', String(body.schedule ?? body.schedule_display)) + } + if (body.prompt != null) args.push('--prompt', String(body.prompt)) + if (body.name != null) args.push('--name', String(body.name)) + if (body.deliver != null) args.push('--deliver', String(body.deliver)) + + const repeat = getRepeatValue(body.repeat) + if (repeat != null) { + args.push('--repeat', String(repeat)) + } else if (hasRepeatField(body)) { + // Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat. + args.push('--repeat', '0') + } + + const skills = getSkills(body) + if (skills) { + if (skills.length === 0) { + args.push('--clear-skills') + } else { + for (const skill of skills) args.push('--skill', skill) + } + } + + if (body.script != null) args.push('--script', String(body.script)) + if (body.workdir != null) args.push('--workdir', String(body.workdir)) + if (body.no_agent === true) args.push('--no-agent') + if (body.no_agent === false) args.push('--agent') + + try { + await runHermesCron(profile, args) + const job = findJob(profile, ctx.params.id) + if (!job) return sendJobNotFound(ctx) + ctx.body = { job } + } catch (error: any) { + sendCommandError(ctx, error) + } +} + +export async function remove(ctx: Context) { + const profile = resolveProfile(ctx) + if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx) + + try { + await runHermesCron(profile, ['cron', 'remove', ctx.params.id]) + ctx.body = { ok: true } + } catch (error: any) { + sendCommandError(ctx, error) + } +} + +export async function pause(ctx: Context) { + const profile = resolveProfile(ctx) + if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx) + + try { + await runHermesCron(profile, ['cron', 'pause', ctx.params.id]) + const job = findJob(profile, ctx.params.id) + ctx.body = { job } + } catch (error: any) { + sendCommandError(ctx, error) + } +} + +export async function resume(ctx: Context) { + const profile = resolveProfile(ctx) + if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx) + + try { + await runHermesCron(profile, ['cron', 'resume', ctx.params.id]) + const job = findJob(profile, ctx.params.id) + ctx.body = { job } + } catch (error: any) { + sendCommandError(ctx, error) + } +} + +export async function run(ctx: Context) { + const profile = resolveProfile(ctx) + if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx) + + try { + await runHermesCron(profile, ['cron', 'run', ctx.params.id]) + const job = findJob(profile, ctx.params.id) + ctx.body = { job } + } catch (error: any) { + sendCommandError(ctx, error) + } +} diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts new file mode 100644 index 0000000..0cc8b6b --- /dev/null +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -0,0 +1,784 @@ +import type { Context } from 'koa' +import { readFile } from 'fs/promises' +import { resolve, normalize } from 'path' +import { homedir } from 'os' +import * as kanbanCli from '../../services/hermes/hermes-kanban' +import { isPathWithin } from '../../services/hermes/hermes-path' +import { listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' +import { + searchSessionSummariesWithProfile, + getSessionDetailFromDbWithProfile, + getExactSessionDetailFromDbWithProfile, + findLatestExactSessionIdWithProfile, +} from '../../db/hermes/sessions-db' +import { listUserProfiles } from '../../db/hermes/users-store' + +const DEFAULT_PROFILE = 'default' + +function profileName(value: string | null | undefined): string { + return value?.trim() || DEFAULT_PROFILE +} + +function requestedProfile(ctx: Context): string | null { + return ctx.state?.profile?.name || null +} + +function allowedProfileSet(ctx: Context): Set | null { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return null + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function visibleProfileSet(ctx: Context): Set | null { + return allowedProfileSet(ctx) +} + +function canUseProfile(ctx: Context, profile: string | null | undefined): boolean { + const allowed = allowedProfileSet(ctx) + return !allowed || allowed.has(profileName(profile)) +} + +function denyProfileAccess(ctx: Context, profile: string | null | undefined): boolean { + if (canUseProfile(ctx, profile)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${profileName(profile)}" is not available for this user` } + return true +} + +function taskAssigneeProfile(task: { assignee: string | null }): string { + return profileName(task.assignee) +} + +function filterTasksByVisibleProfiles(ctx: Context, tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanTask[] { + const visible = visibleProfileSet(ctx) + if (!visible) return tasks + return tasks.filter(task => visible.has(taskAssigneeProfile(task))) +} + +function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats { + const by_status: Record = {} + const by_assignee: Record = {} + for (const task of tasks) { + by_status[task.status] = (by_status[task.status] || 0) + 1 + const assignee = taskAssigneeProfile(task) + by_assignee[assignee] = (by_assignee[assignee] || 0) + 1 + } + return { by_status, by_assignee, total: tasks.length } +} + +function assignableProfileNames(ctx: Context): Set | null { + const user = ctx.state?.user + if (!user) return null + if (user.role === 'super_admin') return new Set(listProfileNamesFromDisk()) + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function assigneesForUser(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] { + const assignable = assignableProfileNames(ctx) + if (!assignable) return assignees + + const byName = new Map() + for (const assignee of assignees) { + const name = profileName(assignee.name) + if (assignable.has(name)) byName.set(name, { ...assignee, name }) + } + for (const name of [...assignable].sort()) { + if (!byName.has(name)) byName.set(name, { name, on_disk: true, counts: null }) + } + return [...byName.values()] +} + +async function getVisibleTasksForBoard(ctx: Context, board: string, opts: { + status?: string + assignee?: string + tenant?: string + includeArchived?: boolean +} = {}): Promise { + if (opts.assignee && denyProfileAccess(ctx, opts.assignee)) return [] + const tasks = await kanbanCli.listTasks({ + board, + status: opts.status, + assignee: opts.assignee, + tenant: opts.tenant, + includeArchived: opts.includeArchived, + }) + return filterTasksByVisibleProfiles(ctx, tasks) +} + +function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null { + return [...detail.runs].reverse().find(run => run.profile)?.profile || null +} + +function firstQueryValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function requestBoard(ctx: Context): string | null { + const rawBoard = firstQueryValue(ctx.query.board as string | string[] | undefined) + if (rawBoard !== undefined && !rawBoard.trim()) { + ctx.status = 400 + ctx.body = { error: 'invalid board slug' } + return null + } + try { + return kanbanCli.normalizeBoardSlug(rawBoard) + } catch { + ctx.status = 400 + ctx.body = { error: 'invalid board slug' } + return null + } +} + +function validSeverity(value?: string): value is 'warning' | 'error' | 'critical' { + return value === undefined || value === 'warning' || value === 'error' || value === 'critical' +} + +const MAX_LOG_TAIL_BYTES = 1_000_000 +const MAX_DISPATCH_TASKS = 100 +const MAX_DISPATCH_FAILURE_LIMIT = 100 +const MAX_BULK_TASKS = 100 + +type PositiveIntegerResult = { value?: number; error?: string } +type StringResult = { value?: string; error?: string } +type BooleanResult = { value?: boolean; error?: string } +type BodyResult = { body: Record; error?: string } + +function optionalPositiveInteger(value: unknown, name: string, max: number): PositiveIntegerResult { + if (value === undefined || value === null || value === '') return {} + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + return { error: `${name} must be a positive integer` } + } + if (value > max) { + return { error: `${name} must be <= ${max}` } + } + return { value } +} + +function optionalPositiveIntegerQuery(value: string | undefined, name: string, max: number): PositiveIntegerResult { + if (value === undefined || value === '') return {} + const numeric = Number(value) + if (!Number.isInteger(numeric) || numeric <= 0) { + return { error: `${name} must be a positive integer` } + } + if (numeric > max) { + return { error: `${name} must be <= ${max}` } + } + return { value: numeric } +} + +function requestBody(ctx: Context): BodyResult { + const body = ctx.request.body + if (body === undefined || body === null) return { body: {} } + if (typeof body !== 'object' || Array.isArray(body)) { + return { body: {}, error: 'request body must be an object' } + } + return { body: body as Record } +} + +function optionalString(value: unknown, name: string): StringResult { + if (value === undefined || value === null) return {} + if (typeof value !== 'string') return { error: `${name} must be a string` } + return { value } +} + +function optionalNullableString(value: unknown, name: string): { value?: string | null; error?: string } { + if (value === undefined) return {} + if (value === null) return { value: null } + if (typeof value !== 'string') return { error: `${name} must be a string` } + return { value } +} + +function hasOwn(body: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(body, key) +} + +function optionalTaskStatus(value: unknown, name: string): { value?: kanbanCli.KanbanTaskStatus; error?: string } { + if (value === undefined || value === null) return {} + if (value !== 'triage' && value !== 'todo' && value !== 'ready' && value !== 'running' && value !== 'blocked' && value !== 'done' && value !== 'archived') { + return { error: `${name} must be a valid kanban task status` } + } + return { value } +} + +function requiredNonEmptyString(value: unknown, name: string): StringResult { + if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` } + return { value } +} + +function requiredNonEmptyStringArray(value: unknown, name: string): { value?: string[]; error?: string } { + if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.trim())) { + return { error: `${name} is required` } + } + return { value } +} + +function optionalBoolean(value: unknown, name: string): BooleanResult { + if (value === undefined || value === null) return {} + if (typeof value !== 'boolean') return { error: `${name} must be boolean` } + return { value } +} + +function optionalInteger(value: unknown, name: string): PositiveIntegerResult { + if (value === undefined || value === null || value === '') return {} + if (typeof value !== 'number' || !Number.isInteger(value)) { + return { error: `${name} must be an integer` } + } + return { value } +} + +function rejectBadRequest(ctx: Context, error?: string): boolean { + if (!error) return false + ctx.status = 400 + ctx.body = { error } + return true +} + +export async function listBoards(ctx: Context) { + const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true' + try { + const boards = await kanbanCli.listBoards({ includeArchived }) + ctx.body = { boards } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function createBoard(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const slug = requiredNonEmptyString(body.slug, 'slug') + const name = optionalString(body.name, 'name') + const description = optionalString(body.description, 'description') + const icon = optionalString(body.icon, 'icon') + const color = optionalString(body.color, 'color') + const switchCurrent = optionalBoolean(body.switchCurrent, 'switchCurrent') + if (rejectBadRequest(ctx, slug.error || name.error || description.error || icon.error || color.error || switchCurrent.error)) return + try { + const board = await kanbanCli.createBoard({ + slug: slug.value!, + name: name.value, + description: description.value, + icon: icon.value, + color: color.value, + switchCurrent: switchCurrent.value, + }) + ctx.body = { board } + } catch (err: any) { + ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500 + ctx.body = { error: err.message } + } +} + +export async function archiveBoard(ctx: Context) { + const slug = ctx.params.slug + if (!slug?.trim()) { + ctx.status = 400 + ctx.body = { error: 'slug is required' } + return + } + try { + await kanbanCli.archiveBoard(slug) + ctx.body = { ok: true } + } catch (err: any) { + ctx.status = err.message?.includes('default') || err.message?.includes('Invalid kanban board slug') ? 400 : 500 + ctx.body = { error: err.message } + } +} + +export async function capabilities(ctx: Context) { + try { + const capabilities = await kanbanCli.getCapabilities() + ctx.body = { capabilities } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function list(ctx: Context) { + const status = firstQueryValue(ctx.query.status as string | string[] | undefined) + const assignee = firstQueryValue(ctx.query.assignee as string | string[] | undefined) + const tenant = firstQueryValue(ctx.query.tenant as string | string[] | undefined) + const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true' + const board = requestBoard(ctx) + if (!board) return + try { + const tasks = await getVisibleTasksForBoard(ctx, board, { status, assignee, tenant, includeArchived }) + if (ctx.status === 403) return + ctx.body = { tasks } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function get(ctx: Context) { + const board = requestBoard(ctx) + if (!board) return + try { + const detail = await kanbanCli.getTask(ctx.params.id, { board }) + if (!detail) { + ctx.status = 404 + ctx.body = { error: 'Task not found' } + return + } + if (!filterTasksByVisibleProfiles(ctx, [detail.task]).length) { + ctx.status = 404 + ctx.body = { error: 'Task not found' } + return + } + + // For terminal tasks, find related session from the worker's profile DB. + // Archived tasks can still carry the worker result/session users need to inspect. + if ((detail.task.status === 'done' || detail.task.status === 'archived') && detail.runs.length > 0) { + const profile = getLatestRunProfile(detail) + if (profile) { + try { + const exactSessionId = await findLatestExactSessionIdWithProfile(detail.task.id, profile) + if (exactSessionId) { + const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile) + if (sessionDetail) { + ;(detail as any).session = { + id: exactSessionId, + title: sessionDetail.title, + source: sessionDetail.source, + model: sessionDetail.model, + started_at: sessionDetail.started_at, + ended_at: sessionDetail.ended_at, + messages: sessionDetail.messages, + } + } + } else { + const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5) + if (results.length > 0) { + const sessionId = results[0].id + const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile) + if (sessionDetail) { + ;(detail as any).session = { + id: sessionId, + title: sessionDetail.title, + source: sessionDetail.source, + model: sessionDetail.model, + started_at: sessionDetail.started_at, + ended_at: sessionDetail.ended_at, + messages: sessionDetail.messages, + } + } + } + } + } catch { + // Session lookup is best-effort, don't fail the whole request + } + } + } + + ctx.body = detail + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function create(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const payload = bodyResult.body + const title = requiredNonEmptyString(payload.title, 'title') + const body = optionalString(payload.body, 'body') + const assignee = optionalString(payload.assignee, 'assignee') + const priority = optionalInteger(payload.priority, 'priority') + const tenant = optionalString(payload.tenant, 'tenant') + if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return + const targetAssignee = assignee.value || requestedProfile(ctx) || undefined + if (targetAssignee && denyProfileAccess(ctx, targetAssignee)) return + const board = requestBoard(ctx) + if (!board) return + try { + const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: targetAssignee, priority: priority.value, tenant: tenant.value }) + ctx.body = { task } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function complete(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const payload = bodyResult.body + const taskIds = requiredNonEmptyStringArray(payload.task_ids, 'task_ids') + const summary = optionalString(payload.summary, 'summary') + if (rejectBadRequest(ctx, taskIds.error || summary.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + await kanbanCli.completeTasks(taskIds.value!, summary.value, { board }) + ctx.body = { ok: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function block(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const reason = requiredNonEmptyString(bodyResult.body.reason, 'reason') + if (rejectBadRequest(ctx, reason.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + await kanbanCli.blockTask(ctx.params.id, reason.value!, { board }) + ctx.body = { ok: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function unblock(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const taskIds = requiredNonEmptyStringArray(bodyResult.body.task_ids, 'task_ids') + if (rejectBadRequest(ctx, taskIds.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + await kanbanCli.unblockTasks(taskIds.value!, { board }) + ctx.body = { ok: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function assign(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile') + if (rejectBadRequest(ctx, profile.error)) return + if (denyProfileAccess(ctx, profile.value)) return + const board = requestBoard(ctx) + if (!board) return + try { + await kanbanCli.assignTask(ctx.params.id, profile.value!, { board }) + ctx.body = { ok: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function addComment(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const bodyPayload = bodyResult.body + const body = requiredNonEmptyString(bodyPayload.body, 'body') + const author = optionalString(bodyPayload.author, 'author') + if (rejectBadRequest(ctx, body.error || author.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.addComment(ctx.params.id, body.value!, { board, author: author.value }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function linkTasks(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const parentId = requiredNonEmptyString(bodyResult.body.parent_id, 'parent_id') + const childId = requiredNonEmptyString(bodyResult.body.child_id, 'child_id') + if (rejectBadRequest(ctx, parentId.error || childId.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.linkTasks(parentId.value!.trim(), childId.value!.trim(), { board }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function unlinkTasks(ctx: Context) { + const parentId = requiredNonEmptyString(firstQueryValue(ctx.query.parent_id as string | string[] | undefined), 'parent_id') + const childId = requiredNonEmptyString(firstQueryValue(ctx.query.child_id as string | string[] | undefined), 'child_id') + if (rejectBadRequest(ctx, parentId.error || childId.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.unlinkTasks(parentId.value!.trim(), childId.value!.trim(), { board }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function bulkUpdateTasks(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const ids = requiredNonEmptyStringArray(body.ids, 'ids') + const status = optionalTaskStatus(body.status, 'status') + const assignee = optionalNullableString(body.assignee, 'assignee') + const archive = optionalBoolean(body.archive, 'archive') + const summary = optionalString(body.summary, 'summary') + const reason = optionalString(body.reason, 'reason') + if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return + if (assignee.value && denyProfileAccess(ctx, assignee.value)) return + if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) { + ctx.status = 400 + ctx.body = { error: 'at least one bulk action is required' } + return + } + if (ids.value!.length > MAX_BULK_TASKS) { + ctx.status = 400 + ctx.body = { error: `ids must contain <= ${MAX_BULK_TASKS} tasks` } + return + } + if (archive.value && status.value !== undefined) { + ctx.status = 400 + ctx.body = { error: 'archive cannot be combined with status' } + return + } + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.bulkUpdateTasks({ + board, + ids: ids.value!.map(id => id.trim()), + status: status.value, + assignee: assignee.value, + archive: archive.value, + summary: summary.value, + reason: reason.value, + }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function taskLog(ctx: Context) { + const board = requestBoard(ctx) + if (!board) return + const tailRaw = firstQueryValue(ctx.query.tail as string | string[] | undefined) + const tail = optionalPositiveIntegerQuery(tailRaw, 'tail', MAX_LOG_TAIL_BYTES) + if (rejectBadRequest(ctx, tail.error)) return + try { + ctx.body = await kanbanCli.getTaskLog(ctx.params.id, { board, tail: tail.value }) + } catch (err: any) { + ctx.status = err.message?.includes('not found') ? 404 : 500 + ctx.body = { error: err.message } + } +} + +export async function diagnostics(ctx: Context) { + const board = requestBoard(ctx) + if (!board) return + const task = firstQueryValue(ctx.query.task as string | string[] | undefined) + const severity = firstQueryValue(ctx.query.severity as string | string[] | undefined) + if (!validSeverity(severity)) { + ctx.status = 400 + ctx.body = { error: 'severity must be warning, error, or critical' } + return + } + try { + const diagnostics = await kanbanCli.getDiagnostics({ board, task, severity }) + ctx.body = { diagnostics } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function reclaim(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const reason = optionalString(body.reason, 'reason') + if (rejectBadRequest(ctx, reason.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.reclaimTask(ctx.params.id, { board, reason: reason.value }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function reassign(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const profile = requiredNonEmptyString(body.profile, 'profile') + const reclaim = optionalBoolean(body.reclaim, 'reclaim') + const reason = optionalString(body.reason, 'reason') + if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return + if (denyProfileAccess(ctx, profile.value)) return + const board = requestBoard(ctx) + if (!board) return + try { + ctx.body = await kanbanCli.reassignTask(ctx.params.id, profile.value!, { board, reclaim: reclaim.value, reason: reason.value }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function specify(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const author = optionalString(body.author, 'author') + if (rejectBadRequest(ctx, author.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + const results = await kanbanCli.specifyTask(ctx.params.id, { board, author: author.value }) + ctx.body = { results } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function dispatch(ctx: Context) { + const bodyResult = requestBody(ctx) + if (rejectBadRequest(ctx, bodyResult.error)) return + const body = bodyResult.body + const dryRun = optionalBoolean(body.dryRun, 'dryRun') + const max = optionalPositiveInteger(body.max, 'max', MAX_DISPATCH_TASKS) + const failureLimit = optionalPositiveInteger(body.failureLimit, 'failureLimit', MAX_DISPATCH_FAILURE_LIMIT) + if (rejectBadRequest(ctx, dryRun.error || max.error || failureLimit.error)) return + const board = requestBoard(ctx) + if (!board) return + try { + const result = await kanbanCli.dispatch({ board, dryRun: dryRun.value, max: max.value, failureLimit: failureLimit.value }) + ctx.body = { result } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function stats(ctx: Context) { + const board = requestBoard(ctx) + if (!board) return + try { + const visible = visibleProfileSet(ctx) + const stats = visible + ? statsForTasks(await getVisibleTasksForBoard(ctx, board, { includeArchived: true })) + : await kanbanCli.getStats({ board }) + ctx.body = { stats } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function assignees(ctx: Context) { + const board = requestBoard(ctx) + if (!board) return + try { + const assignees = assigneesForUser(ctx, await kanbanCli.getAssignees({ board })) + ctx.body = { assignees } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function readArtifact(ctx: Context) { + const filePath = ctx.query.path as string | undefined + if (!filePath) { + ctx.status = 400 + ctx.body = { error: 'path is required' } + return + } + + const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces') + const resolved = resolve(normalize(filePath)) + + if (!isPathWithin(resolved, kanbanDir)) { + ctx.status = 403 + ctx.body = { error: 'Path must be within kanban workspaces' } + return + } + + try { + const data = await readFile(resolved, 'utf-8') + ctx.body = { content: data, path: filePath } + } catch (err: any) { + if (err.code === 'ENOENT') { + ctx.status = 404 + ctx.body = { error: 'File not found' } + } else { + ctx.status = 500 + ctx.body = { error: err.message } + } + } +} + +export async function searchSessions(ctx: Context) { + const { task_id, profile, q } = ctx.query as { + task_id?: string + profile?: string + q?: string + } + if (!task_id || !profile) { + ctx.status = 400 + ctx.body = { error: 'task_id and profile are required' } + return + } + if (denyProfileAccess(ctx, profile)) return + try { + if (!q) { + const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile) + if (exactSessionId) { + const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile) + if (sessionDetail) { + ctx.body = { + results: [{ + id: exactSessionId, + source: sessionDetail.source, + title: sessionDetail.title, + preview: sessionDetail.preview, + model: sessionDetail.model, + started_at: sessionDetail.started_at, + ended_at: sessionDetail.ended_at, + last_active: sessionDetail.last_active, + message_count: sessionDetail.message_count, + tool_call_count: sessionDetail.tool_call_count, + input_tokens: sessionDetail.input_tokens, + output_tokens: sessionDetail.output_tokens, + cache_read_tokens: sessionDetail.cache_read_tokens, + cache_write_tokens: sessionDetail.cache_write_tokens, + reasoning_tokens: sessionDetail.reasoning_tokens, + billing_provider: sessionDetail.billing_provider, + estimated_cost_usd: sessionDetail.estimated_cost_usd, + actual_cost_usd: sessionDetail.actual_cost_usd, + cost_status: sessionDetail.cost_status, + matched_message_id: null, + snippet: sessionDetail.preview, + rank: 0, + }], + } + return + } + } + } + + const searchQuery = q || task_id + const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10) + ctx.body = { results } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/logs.ts b/packages/server/src/controllers/hermes/logs.ts new file mode 100644 index 0000000..05bd3a2 --- /dev/null +++ b/packages/server/src/controllers/hermes/logs.ts @@ -0,0 +1,123 @@ +import { existsSync, statSync } from 'fs' +import { readFile } from 'fs/promises' +import { join } from 'path' +import * as hermesCli from '../../services/hermes/hermes-cli' +import { config } from '../../config' + +const WEBUI_LOG_FILE = join(config.appHome, 'logs', 'server.log') +const BRIDGE_LOG_FILE = join(config.appHome, 'logs', 'bridge.log') + +interface LogEntry { + timestamp: string; level: string; logger: string; message: string; raw: string +} + +function appendPinoContext(message: string, obj: any): string { + const parts: string[] = [] + const runtime = obj.runtime && typeof obj.runtime === 'object' ? obj.runtime : null + if (runtime) { + if (runtime.profile) parts.push(`profile=${runtime.profile}`) + if (runtime.cwd) parts.push(`cwd=${runtime.cwd}`) + if (runtime.profile_dir) parts.push(`profile_dir=${runtime.profile_dir}`) + if (runtime.config_path) parts.push(`config=${runtime.config_path}`) + } else if (obj.profile) { + parts.push(`profile=${obj.profile}`) + } + if (obj.request?.action) parts.push(`action=${obj.request.action}`) + if (obj.err?.message) parts.push(`error=${obj.err.message}`) + if (obj.sessionId) parts.push(`session=${obj.sessionId}`) + if (obj.runId) parts.push(`run=${obj.runId}`) + if (obj.status) parts.push(`status=${obj.status}`) + return parts.length > 0 ? `${message} ${parts.join(' ')}` : message +} + +function parseLine(line: string): LogEntry { + try { + const obj = JSON.parse(line) + if (obj.level && obj.time) { + const ts = new Date(obj.time).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-') + const levelMap: Record = { 10: 'TRACE', 20: 'DEBUG', 30: 'INFO', 40: 'WARN', 50: 'ERROR', 60: 'FATAL' } + // Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... } + const loggerName = obj.name || obj.logger || 'app' + const message = obj.msg || (obj.err ? obj.err.message : '') + const baseMessage = typeof message === 'string' ? message : JSON.stringify(message) + return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: appendPinoContext(baseMessage, obj), raw: line } + } + } catch {} + let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/) + if (match) { return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } } + match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/) + if (match) { return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } } + return { timestamp: '', level: '', logger: '', message: line, raw: line } +} + +export async function list(ctx: any) { + const files = await hermesCli.listLogFiles() + if (existsSync(WEBUI_LOG_FILE)) { + try { + const stat = statSync(WEBUI_LOG_FILE) + const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB` + const modified = stat.mtime.toLocaleString() + files.push({ name: 'webui', size, modified }) + } catch { } + } + if (existsSync(BRIDGE_LOG_FILE)) { + try { + const stat = statSync(BRIDGE_LOG_FILE) + const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB` + const modified = stat.mtime.toLocaleString() + files.push({ name: 'bridge', size, modified }) + } catch { } + } + ctx.body = { files } +} + +export async function read(ctx: any) { + const logName = ctx.params.name + const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100 + const level = (ctx.query.level as string) || undefined + const session = (ctx.query.session as string) || undefined + const since = (ctx.query.since as string) || undefined + + if (logName === 'webui') { + try { + if (!existsSync(WEBUI_LOG_FILE)) { ctx.body = { entries: [] }; return } + const content = await readFile(WEBUI_LOG_FILE, 'utf-8') + const rawLines = content.split('\n') + const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines + const entries: LogEntry[] = [] + for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) } + ctx.body = { entries: entries.reverse() } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } + return + } + + if (logName === 'bridge') { + try { + if (!existsSync(BRIDGE_LOG_FILE)) { ctx.body = { entries: [] }; return } + const content = await readFile(BRIDGE_LOG_FILE, 'utf-8') + const rawLines = content.split('\n') + const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines + const entries: LogEntry[] = [] + for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) } + ctx.body = { entries: entries.reverse() } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } + return + } + + try { + const content = await hermesCli.readLogs(logName, lines, level, session, since) + const rawLines = content.split('\n') + const entries: (LogEntry | null)[] = [] + for (const line of rawLines) { + if (line.startsWith('---') || line.trim() === '') continue + entries.push(parseLine(line)) + } + ctx.body = { entries: entries.reverse() } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/mcp.ts b/packages/server/src/controllers/hermes/mcp.ts new file mode 100644 index 0000000..3bd7124 --- /dev/null +++ b/packages/server/src/controllers/hermes/mcp.ts @@ -0,0 +1,120 @@ +import type { Context } from 'koa' +import { bridgeMcpAction } from '../../services/hermes/mcp' + +function getProfile(ctx: Context): string | undefined { + return (ctx.state as any)?.profile?.name || undefined +} + +/** Validate server name: non-empty, no control chars, no path separators */ +function isValidServerName(name: string): boolean { + if (!name || name.trim().length === 0) return false + if (name.length > 128) return false + // Reject path separators and control characters + if (/[/\\\x00-\x1f]/.test(name)) return false + return true +} + +export async function listServers(ctx: Context) { + try { + ctx.body = await bridgeMcpAction('mcp_list', {}, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'MCP bridge not available' } + } +} + +export async function addServer(ctx: Context) { + try { + const { name, config } = (ctx.request.body || {}) as Record + if (typeof name !== 'string' || !isValidServerName(name)) { + ctx.status = 400 + ctx.body = { error: 'Valid server name is required' } + return + } + if (!config || typeof config !== 'object') { + ctx.status = 400 + ctx.body = { error: 'config object is required' } + return + } + ctx.body = await bridgeMcpAction('mcp_server_add', { name: name.trim(), config }, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'Failed to add MCP server' } + } +} + +export async function updateServer(ctx: Context) { + try { + const name = ctx.params.name as string + const { config } = (ctx.request.body || {}) as Record + if (!name || !isValidServerName(name)) { + ctx.status = 400 + ctx.body = { error: 'Valid server name is required' } + return + } + if (!config || typeof config !== 'object') { + ctx.status = 400 + ctx.body = { error: 'config object is required' } + return + } + ctx.body = await bridgeMcpAction('mcp_server_update', { name, config }, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'Failed to update MCP server' } + } +} + +export async function removeServer(ctx: Context) { + try { + const name = ctx.params.name as string + if (!name || !isValidServerName(name)) { + ctx.status = 400 + ctx.body = { error: 'Valid server name is required' } + return + } + ctx.body = await bridgeMcpAction('mcp_server_remove', { name }, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'Failed to remove MCP server' } + } +} + +export async function testServer(ctx: Context) { + try { + const name = ctx.params.name as string + if (!name || !isValidServerName(name)) { + ctx.status = 400 + ctx.body = { error: 'Valid server name is required' } + return + } + ctx.body = await bridgeMcpAction('mcp_server_test', { name }, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'Failed to test MCP server' } + } +} + +export async function listTools(ctx: Context) { + try { + const server = ctx.query.server as string | undefined + const raw = ctx.query.raw === '1' || ctx.query.raw === 'true' + const payload: Record = {} + if (server) payload.server = server + if (raw) payload.raw = true + ctx.body = await bridgeMcpAction('mcp_tools_list', payload, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'MCP bridge not available' } + } +} + +export async function reloadMcp(ctx: Context) { + try { + const server = ctx.query.server as string | undefined + const payload = server ? { server } : {} + ctx.body = await bridgeMcpAction('mcp_reload', payload, getProfile(ctx)) + } catch (err: any) { + ctx.status = 503 + ctx.body = { error: err.message || 'Failed to reload MCP' } + } +} diff --git a/packages/server/src/controllers/hermes/media.ts b/packages/server/src/controllers/hermes/media.ts new file mode 100644 index 0000000..6070583 --- /dev/null +++ b/packages/server/src/controllers/hermes/media.ts @@ -0,0 +1,614 @@ +import type { Context } from 'koa' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname, extname, isAbsolute, join, resolve } from 'path' +import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' +import { config } from '../../config' +import { readConfigYamlForProfile } from '../../services/config-helpers' + +const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations' +const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos' +const XAI_VIDEO_MODEL = 'grok-imagine-video' +const APIKEY_IMAGE_PROVIDER = 'fun-codex' +const APIKEY_IMAGE_MODEL = 'gpt-image-2' +const APIKEY_IMAGE_TO_IMAGE_MODEL = 'gpt-5.4-mini' +const MAX_IMAGE_BYTES = 25 * 1024 * 1024 +const DEFAULT_POLL_INTERVAL_MS = 5000 +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 + +type AuthJson = { + providers?: Record + credential_pool?: Record +} + +type ApiKeyImageMode = 'text' | 'image' | 'edit' + +type FunCodexProvider = { + apiKey: string + baseUrl: string + model: string +} + +function requestedProfileName(ctx: Context): string { + const headerProfile = ctx.get('x-hermes-profile') + const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : '' + const body = ctx.request.body as { profile?: unknown } | undefined + const bodyProfile = typeof body?.profile === 'string' ? body.profile : '' + return (ctx.state.profile?.name || headerProfile || queryProfile || bodyProfile || '').trim() +} + +function resolveMediaProfile(ctx: Context): string { + let requested = requestedProfileName(ctx) + if (!requested && ctx.state.user?.role !== 'super_admin' && !ctx.state.serverTokenAuth) { + const profiles = ctx.state.user?.profiles || [] + if (profiles.length === 1) { + requested = profiles[0] + } else { + const err: any = new Error('Profile is required') + err.status = 400 + err.code = 'profile_required' + throw err + } + } + + const profile = requested || getActiveProfileName() || 'default' + if (!listProfileNamesFromDisk().includes(profile)) { + const err: any = new Error(`Profile "${profile}" does not exist`) + err.status = 404 + err.code = 'profile_not_found' + throw err + } + return profile +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +function readJsonFile(path: string): any { + try { + return JSON.parse(readFileSync(path, 'utf-8')) + } catch { + return null + } +} + +function buildApiUrl(baseUrl: string, pathWithV1: string): string { + const base = (baseUrl || 'https://api.apikey.fun/v1').replace(/\/+$/, '') + const apiPath = pathWithV1.startsWith('/') ? pathWithV1 : `/${pathWithV1}` + if (base.endsWith('/v1') && apiPath.startsWith('/v1/')) return `${base}${apiPath.slice(3)}` + return `${base}${apiPath}` +} + +async function resolveFunCodexProvider(profile: string): Promise { + const hermesConfig = await readConfigYamlForProfile(profile) + const customProviders = Array.isArray(hermesConfig.custom_providers) + ? hermesConfig.custom_providers as any[] + : [] + const provider = customProviders.find(entry => String(entry?.name || '').trim() === APIKEY_IMAGE_PROVIDER) + const apiKey = String(provider?.api_key || '').trim() + const baseUrl = String(provider?.base_url || '').trim() + if (!provider || !apiKey || !baseUrl) return null + return { + apiKey, + baseUrl, + model: String(provider?.model || '').trim(), + } +} + +function resolveXaiToken(profile: string): { token: string; source: string } | null { + const envToken = String(process.env.XAI_API_KEY || '').trim() + if (envToken) return { token: envToken, source: 'XAI_API_KEY' } + + const auth = readJsonFile(authPathForProfile(profile)) as AuthJson | null + const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim() + if (providerToken) return { token: providerToken, source: 'xai-oauth' } + + const pool = auth?.credential_pool?.['xai-oauth'] + if (Array.isArray(pool)) { + const poolToken = String(pool.find(entry => entry?.access_token)?.access_token || '').trim() + if (poolToken) return { token: poolToken, source: 'xai-oauth' } + } + + return null +} + +function mimeFromPath(path: string): string | null { + const ext = extname(path).toLowerCase() + if (ext === '.png') return 'image/png' + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.webp') return 'image/webp' + return null +} + +function mimeFromMagic(buffer: Buffer): string | null { + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png' + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg' + if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp' + return null +} + +function imagePathToDataUri(imagePath: string): string { + const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath) + const image = readFileSync(resolvedPath) + if (image.length > MAX_IMAGE_BYTES) { + const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`) + err.status = 413 + throw err + } + const mime = mimeFromMagic(image) || mimeFromPath(resolvedPath) + if (!mime) { + const err: any = new Error('unsupported image type; use png, jpeg, or webp') + err.status = 400 + throw err + } + return `data:${mime};base64,${image.toString('base64')}` +} + +function normalizeImageInput(body: any): string { + const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : '' + if (imageUrl) return imageUrl + + const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : '' + if (imageBase64) { + if (imageBase64.startsWith('data:image/')) return imageBase64 + const mime = typeof body.mime_type === 'string' ? body.mime_type.trim() : '' + if (!mime.startsWith('image/')) { + const err: any = new Error('mime_type is required when image_base64 is not a data URI') + err.status = 400 + throw err + } + return `data:${mime};base64,${imageBase64}` + } + + const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : '' + if (!imagePath) { + const err: any = new Error('image_path, image_url, or image_base64 is required') + err.status = 400 + throw err + } + if (!existsSync(isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath))) { + const err: any = new Error('image_path does not exist') + err.status = 404 + throw err + } + return imagePathToDataUri(imagePath) +} + +function imageDataUriToBytes(dataUri: string): { buffer: Buffer; mime: string; name: string } { + const match = dataUri.match(/^data:([^;,]+);base64,(.+)$/) + if (!match) { + const err: any = new Error('image_base64 must be a valid image data URI for edit mode') + err.status = 400 + throw err + } + const mime = match[1] + if (!mime.startsWith('image/')) { + const err: any = new Error('image data URI must use an image mime type') + err.status = 400 + throw err + } + return { + buffer: Buffer.from(match[2], 'base64'), + mime, + name: `source.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`, + } +} + +async function fetchImageBytes(url: string): Promise<{ buffer: Buffer; mime: string; name: string }> { + const res = await fetch(url) + if (!res.ok) { + const err: any = new Error(`image_url fetch failed: ${res.status} ${res.statusText}`) + err.status = 400 + throw err + } + const mime = String(res.headers.get('content-type') || '').split(';')[0] || 'image/png' + if (!mime.startsWith('image/')) { + const err: any = new Error('image_url did not return an image') + err.status = 400 + throw err + } + const buffer = Buffer.from(await res.arrayBuffer()) + if (buffer.length > MAX_IMAGE_BYTES) { + const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`) + err.status = 413 + throw err + } + const name = new URL(url).pathname.split('/').pop() || 'source.png' + return { buffer, mime, name } +} + +async function normalizeImageFile(body: any): Promise<{ buffer: Buffer; mime: string; name: string }> { + const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : '' + if (imageUrl) return fetchImageBytes(imageUrl) + + const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : '' + if (imageBase64) { + const dataUri = imageBase64.startsWith('data:image/') + ? imageBase64 + : `data:${String(body.mime_type || '').trim()};base64,${imageBase64}` + return imageDataUriToBytes(dataUri) + } + + const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : '' + if (!imagePath) { + const err: any = new Error('image_path, image_url, or image_base64 is required') + err.status = 400 + throw err + } + const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath) + if (!existsSync(resolvedPath)) { + const err: any = new Error('image_path does not exist') + err.status = 404 + throw err + } + const buffer = readFileSync(resolvedPath) + if (buffer.length > MAX_IMAGE_BYTES) { + const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`) + err.status = 413 + throw err + } + const mime = mimeFromMagic(buffer) || mimeFromPath(resolvedPath) + if (!mime) { + const err: any = new Error('unsupported image type; use png, jpeg, or webp') + err.status = 400 + throw err + } + return { buffer, mime, name: resolvedPath.split(/[\\/]/).pop() || 'source.png' } +} + +function normalizeDuration(value: unknown): number { + const duration = Number(value || 8) + if (!Number.isFinite(duration) || duration < 1 || duration > 15) { + const err: any = new Error('duration must be between 1 and 15 seconds') + err.status = 400 + throw err + } + return duration +} + +export function defaultMediaOutputPath(requestId: string, now = new Date()): string { + const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `video_${now.getTime()}` + return join(config.appHome, 'media', `${safeRequestId}.mp4`) +} + +export function defaultImageOutputPath(requestId: string, index = 0): string { + const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `image_${Date.now()}` + const suffix = index > 0 ? `-${index + 1}` : '' + return join(config.appHome, 'media', `${safeRequestId}${suffix}.png`) +} + +function normalizeImageMode(value: unknown): ApiKeyImageMode { + const mode = String(value || 'text').trim().toLowerCase() + if (mode === 'text' || mode === 'image' || mode === 'edit') return mode + const err: any = new Error('mode must be one of text, image, or edit') + err.status = 400 + throw err +} + +function normalizePositiveInt(value: unknown, fallback: number, key: string): number { + const parsed = Number(value || fallback) + if (!Number.isFinite(parsed) || parsed < 1) { + const err: any = new Error(`${key} must be a positive number`) + err.status = 400 + throw err + } + return Math.floor(parsed) +} + +function collectImageBase64(event: any, images: string[] = []): string[] { + if (!event || typeof event !== 'object') return images + for (const key of ['b64_json', 'base64', 'image_base64', 'partial_image_b64']) { + if (typeof event[key] === 'string' && event[key]) images.push(event[key]) + } + for (const item of event.data || []) collectImageBase64(item, images) + for (const item of event.response?.output || []) { + if (typeof item?.result === 'string' && item.result) images.push(item.result) + collectImageBase64(item, images) + } + if (typeof event.item?.result === 'string' && event.item.result) images.push(event.item.result) + return images +} + +function isPartialImageEvent(event: any): boolean { + return event?.type === 'image_generation.partial_image' || + event?.type === 'response.image_generation_call.partial_image' +} + +function throwIfImageStreamError(event: any): void { + if (event?.type !== 'error' && event?.type !== 'response.failed') return + const err: any = new Error(event?.response?.error?.message || event?.error?.message || 'image generation failed') + err.status = 502 + throw err +} + +async function readSseImageResults(res: Response, limit: number): Promise { + if (!res.body) throw new Error('image generation response is not readable') + const reader = res.body.getReader() + const decoder = new TextDecoder() + const images: string[] = [] + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const frames = buffer.split(/\r?\n\r?\n/) + buffer = frames.pop() || '' + for (const frame of frames) { + const data = frame + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) + .join('\n') + .trim() + if (!data || data === '[DONE]') continue + const event = JSON.parse(data) + throwIfImageStreamError(event) + if (isPartialImageEvent(event)) continue + collectImageBase64(event, images) + if (images.length >= limit) return images.slice(0, limit) + } + } + return images.slice(0, limit) +} + +async function requestApiKeyImage(provider: FunCodexProvider, mode: ApiKeyImageMode, body: any): Promise { + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '' + if (!prompt) { + const err: any = new Error('prompt is required') + err.status = 400 + throw err + } + + const n = normalizePositiveInt(body.n, 1, 'n') + const timeoutMs = normalizePositiveInt(body.timeout_ms, DEFAULT_TIMEOUT_MS, 'timeout_ms') + const headers = { + Accept: 'text/event-stream', + Authorization: `Bearer ${provider.apiKey}`, + } + + let res: Response + if (mode === 'text') { + res = await fetch(buildApiUrl(provider.baseUrl, '/v1/images/generations'), { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(timeoutMs), + body: JSON.stringify({ + model: body.model || APIKEY_IMAGE_MODEL, + prompt, + n, + size: body.size || '1024x1024', + quality: body.quality || 'auto', + stream: true, + response_format: 'b64_json', + }), + }) + } else if (mode === 'image') { + res = await fetch(buildApiUrl(provider.baseUrl, '/v1/responses'), { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(timeoutMs), + body: JSON.stringify({ + model: body.model || provider.model || APIKEY_IMAGE_TO_IMAGE_MODEL, + stream: true, + input: [{ + role: 'user', + content: [ + { type: 'input_text', text: prompt }, + { type: 'input_image', image_url: normalizeImageInput(body) }, + ], + }], + tools: [{ + type: 'image_generation', + model: body.image_model || APIKEY_IMAGE_MODEL, + size: body.size || '1024x1024', + quality: body.quality || 'auto', + output_format: body.output_format || 'png', + }], + tool_choice: { type: 'image_generation' }, + }), + }) + } else { + const image = await normalizeImageFile(body) + const imageBytes = new Uint8Array(image.buffer.byteLength) + imageBytes.set(image.buffer) + const form = new FormData() + form.append('image', new Blob([imageBytes.buffer], { type: image.mime }), image.name) + form.append('prompt', prompt) + form.append('model', body.model || APIKEY_IMAGE_MODEL) + form.append('n', String(n)) + form.append('quality', body.quality || 'auto') + form.append('size', body.size || '1024x1024') + form.append('stream', 'true') + form.append('response_format', 'b64_json') + res = await fetch(buildApiUrl(provider.baseUrl, '/v1/images/edits'), { + method: 'POST', + headers, + signal: AbortSignal.timeout(timeoutMs), + body: form, + }) + } + + if (!res.ok) { + const detail = await res.text().catch(() => '') + const err: any = new Error(`image generation request failed: ${res.status} ${detail || res.statusText}`) + err.status = res.status === 401 || res.status === 403 ? 502 : 502 + throw err + } + const images = await readSseImageResults(res, n) + if (images.length === 0) { + const err: any = new Error('image generation stream ended without image data') + err.status = 502 + throw err + } + return images +} + +function saveGeneratedImages(images: string[], requestedOutputPath?: string): string[] { + return images.map((image, index) => { + const outputPath = requestedOutputPath && images.length === 1 + ? requestedOutputPath + : requestedOutputPath + ? requestedOutputPath.replace(/(\.[^.\\/]+)?$/, `${index > 0 ? `-${index + 1}` : ''}$1`) + : defaultImageOutputPath(`image_${Date.now()}`, index) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, Buffer.from(image, 'base64')) + return outputPath + }) +} + +export async function apiKeyImageGenerate(ctx: Context) { + let profile: string + try { + profile = resolveMediaProfile(ctx) + } catch (err: any) { + ctx.status = err.status || 400 + ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' } + return + } + + const provider = await resolveFunCodexProvider(profile) + if (!provider) { + ctx.status = 401 + ctx.body = { + error: `Missing fun-codex provider in profile "${profile}" config.yaml.`, + code: 'missing_fun_codex_provider', + } + return + } + + const body = ctx.request.body as any + try { + const mode = normalizeImageMode(body.mode) + const images = await requestApiKeyImage(provider, mode, body) + const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : '' + const outputPaths = saveGeneratedImages(images, requestedOutputPath || undefined) + ctx.body = { + ok: true, + mode, + output_paths: outputPaths, + provider: APIKEY_IMAGE_PROVIDER, + base_url: provider.baseUrl, + profile, + } + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { + error: err.message || String(err), + code: err.code || 'image_generation_failed', + } + } +} + +async function requestXaiJson(url: string, token: string, init: RequestInit = {}): Promise { + const res = await fetch(url, { + ...init, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...(init.headers || {}), + }, + }) + const text = await res.text() + let data: any = null + try { data = text ? JSON.parse(text) : null } catch {} + if (!res.ok) { + const detail = data?.error?.message || data?.error || text || res.statusText + const err: any = new Error(`xAI request failed: ${res.status} ${detail}`) + err.status = res.status === 401 || res.status === 403 ? 502 : 502 + throw err + } + return data +} + +async function downloadVideo(url: string, outputPath: string): Promise { + const res = await fetch(url) + if (!res.ok) throw new Error(`failed to download generated video: ${res.status} ${res.statusText}`) + const arrayBuffer = await res.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, buffer) +} + +export async function grokImageToVideo(ctx: Context) { + let profile: string + try { + profile = resolveMediaProfile(ctx) + } catch (err: any) { + ctx.status = err.status || 400 + ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' } + return + } + + const tokenInfo = resolveXaiToken(profile) + if (!tokenInfo) { + ctx.status = 401 + ctx.body = { + error: `Missing xAI token for profile "${profile}". Set XAI_API_KEY or complete xAI OAuth login first.`, + code: 'missing_xai_token', + } + return + } + + const body = ctx.request.body as any + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '' + if (!prompt) { + ctx.status = 400 + ctx.body = { error: 'prompt is required', code: 'missing_prompt' } + return + } + + try { + const image = normalizeImageInput(body) + const duration = normalizeDuration(body.duration) + const rawTimeoutMs = Number(body.timeout_ms || DEFAULT_TIMEOUT_MS) + const timeoutMs = Number.isFinite(rawTimeoutMs) + ? Math.max(10000, Math.min(rawTimeoutMs, 30 * 60 * 1000)) + : DEFAULT_TIMEOUT_MS + const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : '' + + const started = await requestXaiJson(XAI_VIDEO_GENERATIONS_URL, tokenInfo.token, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: XAI_VIDEO_MODEL, + prompt, + image: { url: image }, + duration, + }), + }) + const requestId = String(started?.request_id || '').trim() + if (!requestId) throw new Error('xAI response missing request_id') + + const deadline = Date.now() + timeoutMs + let latest: any = null + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS)) + latest = await requestXaiJson(`${XAI_VIDEO_STATUS_URL}/${encodeURIComponent(requestId)}`, tokenInfo.token) + if (latest?.status === 'done') { + const videoUrl = String(latest?.video?.url || '').trim() + const outputPath = requestedOutputPath || defaultMediaOutputPath(requestId) + if (videoUrl) await downloadVideo(videoUrl, outputPath) + ctx.body = { + request_id: requestId, + status: latest.status, + video_url: videoUrl, + output_path: outputPath, + token_source: tokenInfo.source, + profile, + } + return + } + if (latest?.status === 'expired' || latest?.status === 'failed' || latest?.status === 'error') { + ctx.status = 502 + ctx.body = { request_id: requestId, status: latest.status, error: latest?.error || 'xAI video generation failed' } + return + } + } + + ctx.status = 504 + ctx.body = { request_id: requestId, status: latest?.status || 'pending', error: 'Timed out waiting for xAI video generation' } + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || String(err) } + } +} diff --git a/packages/server/src/controllers/hermes/memory.ts b/packages/server/src/controllers/hermes/memory.ts new file mode 100644 index 0000000..7a06b7a --- /dev/null +++ b/packages/server/src/controllers/hermes/memory.ts @@ -0,0 +1,57 @@ +import { mkdir, writeFile } from 'fs/promises' +import { join } from 'path' +import { safeReadFile, safeStat } from '../../services/config-helpers' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function requestProfileDir(ctx: any): string { + return getProfileDir(requestedProfile(ctx)) +} + +export async function get(ctx: any) { + const hd = requestProfileDir(ctx) + const memoryPath = join(hd, 'memories', 'MEMORY.md') + const userPath = join(hd, 'memories', 'USER.md') + const soulPath = join(hd, 'SOUL.md') + const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([ + safeReadFile(memoryPath), safeReadFile(userPath), safeReadFile(soulPath), + safeStat(memoryPath), safeStat(userPath), safeStat(soulPath), + ]) + ctx.body = { + memory: memory || '', user: user || '', soul: soul || '', + memory_mtime: memoryStat?.mtime || null, user_mtime: userStat?.mtime || null, soul_mtime: soulStat?.mtime || null, + } +} + +export async function save(ctx: any) { + const { section, content } = ctx.request.body as { section: string; content: string } + if (!section || !content) { + ctx.status = 400 + ctx.body = { error: 'Missing section or content' } + return + } + if (section !== 'memory' && section !== 'user' && section !== 'soul') { + ctx.status = 400 + ctx.body = { error: 'Section must be "memory", "user", or "soul"' } + return + } + let filePath: string + const hd = requestProfileDir(ctx) + if (section === 'soul') { + filePath = join(hd, 'SOUL.md') + } else { + const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md' + await mkdir(join(hd, 'memories'), { recursive: true }) + filePath = join(hd, 'memories', fileName) + } + try { + await writeFile(filePath, content, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts new file mode 100644 index 0000000..b52fa0f --- /dev/null +++ b/packages/server/src/controllers/hermes/models.ts @@ -0,0 +1,1086 @@ +import { readFile } from 'fs/promises' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' +import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, updateConfigYamlForProfile, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers' +import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models' +import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config' +import { getDb } from '../../db' +import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas' +import { listUserProfiles } from '../../db/hermes/users-store' + +const PROVIDER_MODEL_CATALOG = buildProviderModelMap() + +type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string } +type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record; available_models?: string[]; base_url_env?: string } +type ModelVisibility = Record +type CustomModels = Record + +const RESERVED_ALIAS_KEYS = new Set(['__proto__', 'prototype', 'constructor']) + +function isSafeAliasKey(value: string): boolean { + const trimmed = value.trim() + return !!trimmed && trimmed.length <= 512 && !RESERVED_ALIAS_KEYS.has(trimmed) +} + +function createAliasMap(): Record { + return Object.create(null) as Record +} + +function createProviderAliasMap(): Record> { + return Object.create(null) as Record> +} + +function normalizeAliases(value: unknown): Record> { + const normalized = createProviderAliasMap() + if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized + for (const [provider, models] of Object.entries(value as Record)) { + if (!isSafeAliasKey(provider) || !models || typeof models !== 'object' || Array.isArray(models)) continue + for (const [model, alias] of Object.entries(models as Record)) { + if (!isSafeAliasKey(model) || typeof alias !== 'string') continue + const trimmed = alias.trim() + if (!trimmed || trimmed.length > 512) continue + if (!Object.hasOwn(normalized, provider)) normalized[provider] = createAliasMap() + normalized[provider][model] = trimmed + } + } + return normalized +} + +function applyModelAliases }>(groups: T[], aliases: Record>): T[] { + return groups.map((group) => { + const providerAliases = aliases[group.provider] + if (!providerAliases) return group + const modelMeta: Record = { ...(group.model_meta || {}) } + let changed = false + for (const model of group.models) { + const alias = providerAliases[model] + if (!alias) continue + modelMeta[model] = { ...(modelMeta[model] || {}), alias } + changed = true + } + return changed ? { ...group, model_meta: modelMeta } : group + }) +} + +function uniqueStrings(values: unknown): string[] { + if (!Array.isArray(values)) return [] + return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean))) +} + +function normalizeCustomModels(input: unknown): CustomModels { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const out: CustomModels = {} + for (const [provider, rawModels] of Object.entries(input as Record)) { + const providerKey = String(provider || '').trim() + if (!providerKey) continue + const models = uniqueStrings(rawModels) + if (models.length > 0) out[providerKey] = models + } + return out +} + +function applyCustomModels(groups: AvailableGroup[], customModels: CustomModels): AvailableGroup[] { + return groups.map(group => { + const extra = customModels[group.provider] || [] + if (!extra.length) return group + const models = [...new Set([...group.models, ...extra])] + const availableModels = [...new Set([...(group.available_models || group.models), ...extra])] + return { ...group, models, available_models: availableModels } + }) +} + +function providerPresetToGroup(p: any, models?: string[]): AvailableGroup { + const envMapping = PROVIDER_ENV_MAP[p.value] + return { + provider: p.value, + label: p.label, + base_url: p.base_url, + models: models || p.models, + api_key: '', + ...(p.builtin ? { builtin: true } : {}), + ...(envMapping?.base_url_env ? { base_url_env: envMapping.base_url_env } : {}), + } +} + +function normalizeModelVisibility(input: unknown): ModelVisibility { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {} + const out: ModelVisibility = {} + for (const [provider, rawRule] of Object.entries(input as Record)) { + const providerKey = String(provider || '').trim() + if (!providerKey || !rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) continue + const rule = rawRule as { mode?: unknown; models?: unknown } + const mode = rule.mode === 'include' ? 'include' : 'all' + const models = uniqueStrings(rule.models) + if (mode === 'include') { + if (models.length > 0) out[providerKey] = { mode, models } + } else { + out[providerKey] = { mode: 'all', models: [] } + } + } + return out +} + +function filterModelsForProvider(provider: string, models: string[], visibility: ModelVisibility): string[] { + const rule = visibility[provider] + if (!rule || rule.mode !== 'include') return models + const allowed = new Set(rule.models) + const visible = models.filter(model => allowed.has(model)) + // If a stale hand-edited rule references models that are no longer present, + // fail open so the provider remains recoverable from the Web UI. + return visible.length > 0 ? visible : models +} + +function applyModelVisibility(groups: AvailableGroup[], visibility: ModelVisibility): AvailableGroup[] { + return groups + .map(group => { + const availableModels = group.available_models || group.models + return { + ...group, + available_models: availableModels, + models: filterModelsForProvider(group.provider, availableModels, visibility), + } + }) + .filter(group => group.models.length > 0) +} + +function resolveVisibleDefault(defaultModel: string, defaultProvider: string, groups: AvailableGroup[]) { + if (defaultModel) { + const explicit = groups.find(group => group.provider === defaultProvider && group.models.includes(defaultModel)) + if (explicit) return { defaultModel, defaultProvider } + const inferred = groups.find(group => group.models.includes(defaultModel)) + if (inferred) return { defaultModel, defaultProvider: inferred.provider } + } + const fallback = groups.find(group => group.models.length > 0) + return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' } +} + +function profileEnvPath(profile: string): string { + return join(getProfileDir(profile), '.env') +} + +function profileAuthPath(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +function envReader(envContent: string) { + const envHasValue = (key: string): boolean => { + if (!key) return false + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#') + } + const envGetValue = (key: string): string => { + if (!key) return '' + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return match?.[1]?.trim() || '' + } + return { envHasValue, envGetValue } +} + +function providerKeyForCustom(name: string): string { + return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` +} + +function providerKeyWithoutCustomPrefix(providerKey: string): string { + return providerKey.startsWith('custom:') ? providerKey.slice('custom:'.length) : providerKey +} + +function isBuiltinProviderKey(providerKey: string): boolean { + const normalized = providerKeyWithoutCustomPrefix(providerKey) + return PROVIDER_PRESETS.some((preset: any) => preset.value === normalized && preset.builtin === true) +} + +function providerShouldFetchLiveModels(providerKey: string): boolean { + return providerKey === 'openrouter' || + providerKey === 'cliproxyapi' || + providerKey === 'ollama-cloud' || + providerKey === 'lmstudio' +} + +function includeConfiguredDefaultModel(providerKey: string, modelsList: string[], currentDefault: string, currentDefaultProvider: string): string[] { + if (!currentDefault || providerKey !== currentDefaultProvider) return modelsList + return [...new Set([...modelsList, currentDefault])] +} + +function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] { + const byProvider = new Map() + for (const group of groups) { + const existing = byProvider.get(group.provider) + if (!existing) { + byProvider.set(group.provider, { + ...group, + models: [...new Set(group.models)], + available_models: [...new Set(group.available_models || group.models)], + model_meta: group.model_meta ? { ...group.model_meta } : undefined, + }) + continue + } + existing.models = [...new Set([...existing.models, ...group.models])] + existing.available_models = [...new Set([...(existing.available_models || existing.models), ...(group.available_models || group.models)])] + existing.api_key = existing.api_key || group.api_key + existing.base_url = existing.base_url || group.base_url + existing.builtin = existing.builtin || group.builtin + existing.model_meta = { ...(existing.model_meta || {}), ...(group.model_meta || {}) } + if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta + } + return [...byProvider.values()] +} + +type ProviderFetchCache = Map> + +function requestedProfileName(ctx: any): string { + const queryProfile = ctx.query?.profile + return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : '' +} + +function requestScopedProfileName(ctx: any): string { + const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : '' + const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : '' + const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : '' + return ctx.state?.profile?.name || + headerProfile.trim() || + queryProfile.trim() || + bodyProfile.trim() || + getActiveProfileName() || + 'default' +} + +function visibleProfileNamesForUser(ctx: any): string[] { + const diskProfiles = listProfileNamesFromDisk() + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return diskProfiles + const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) + return diskProfiles.filter(profile => allowed.has(profile)) +} + +function cachedProviderModels( + cache: ProviderFetchCache, + baseUrl: string, + apiKey: string, + freeOnly = false, +): Promise { + const key = `${baseUrl.replace(/\/+$/, '')}\n${apiKey}\n${freeOnly ? 'free' : 'all'}` + let pending = cache.get(key) + if (!pending) { + pending = fetchProviderModels(baseUrl, apiKey, freeOnly) + cache.set(key, pending) + } + return pending +} + + +// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json +// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。 +async function isCopilotAuthorized(envContent: string): Promise { + return !!(await resolveCopilotOAuthToken(envContent)) +} + +async function buildAvailableForProfile( + profile: string, + fetchCache: ProviderFetchCache, + appConfig: Awaited>, +): Promise<{ + profile: string + default: string + default_provider: string + groups: AvailableGroup[] +}> { + const config = await readConfigYamlForProfile(profile) + const modelSection = config.model + let currentDefault = '' + let currentDefaultProvider = '' + if (typeof modelSection === 'object' && modelSection !== null) { + currentDefault = String(modelSection.default || '').trim() + currentDefaultProvider = String(modelSection.provider || '').trim() + if (currentDefaultProvider === 'custom' && currentDefault) { + const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : [] + const match = cps.find( + (cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '') + && cp.model === currentDefault, + ) + if (match) currentDefaultProvider = providerKeyForCustom(String(match.name || '')) + } + } else if (typeof modelSection === 'string') { + currentDefault = modelSection.trim() + } + + let envContent = '' + try { envContent = await readFile(profileEnvPath(profile), 'utf-8') } catch {} + const { envHasValue, envGetValue } = envReader(envContent) + + const isOAuthAuthorized = (providerKey: string): boolean => { + try { + const authPath = profileAuthPath(profile) + if (!existsSync(authPath)) return false + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + const provider = auth.providers?.[providerKey] + const pool = auth.credential_pool?.[providerKey] + return !!( + provider?.tokens?.access_token || + provider?.access_token || + (Array.isArray(pool) && pool.some((entry: any) => entry?.access_token)) + ) + } catch { return false } + } + + let copilotLiveModels: CopilotModelMeta[] | null = null + const getCopilotLive = async (): Promise => { + if (copilotLiveModels !== null) return copilotLiveModels + try { copilotLiveModels = await getCopilotModelsDetailed(envContent) } + catch { copilotLiveModels = [] } + return copilotLiveModels + } + + const groups: AvailableGroup[] = [] + const seenProviders = new Set() + const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record) => { + if (seenProviders.has(provider)) return + seenProviders.add(provider) + const availableModels = [...new Set(models)] + groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) }) + } + + const copilotEnabled = appConfig.copilotEnabled === true + if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') { + currentDefault = '' + currentDefaultProvider = '' + } + + for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) { + if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue + if (!envMapping.api_key_env) { + if (providerKey === 'copilot') { + if (!copilotEnabled) continue + if (!(await isCopilotAuthorized(envContent))) continue + } else if (!isOAuthAuthorized(providerKey)) { + continue + } + } + const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey) + const label = preset?.label || providerKey.replace(/^custom:/, '') + let baseUrl = preset?.base_url || '' + if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) { + baseUrl = envGetValue(envMapping.base_url_env) || baseUrl + } + const catalogModels = PROVIDER_MODEL_CATALOG[providerKey] + let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : [] + let modelMeta: Record | undefined + if (providerKey === 'copilot') { + const live = await getCopilotLive() + if (live.length > 0) { + modelsList = live.map((m) => m.id) + modelMeta = {} + for (const m of live) { + if (m.preview || m.disabled) { + modelMeta[m.id] = { + ...(m.preview ? { preview: true } : {}), + ...(m.disabled ? { disabled: true } : {}), + } + } + } + if (Object.keys(modelMeta).length === 0) modelMeta = undefined + } + } else if (providerShouldFetchLiveModels(providerKey)) { + if (envMapping.api_key_env) { + const apiKey = envGetValue(envMapping.api_key_env) + if (apiKey) { + try { + const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter') + if (fetched.length > 0) modelsList = fetched + } catch { /* ignore live catalog failures */ } + } + } + } + modelsList = includeConfiguredDefaultModel(providerKey, modelsList, currentDefault, currentDefaultProvider) + if (modelsList.length > 0) { + const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' + addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta) + } + } + + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }> + : [] + const customFetches = await Promise.allSettled( + customProviders.map(async cp => { + if (!cp.base_url) return null + const providerKey = providerKeyForCustom(cp.name) + const baseUrl = cp.base_url.replace(/\/+$/, '') + let models = [cp.model].filter(Boolean) + if (cp.api_key) { + const fetched = await cachedProviderModels(fetchCache, baseUrl, cp.api_key) + if (fetched.length > 0) models = [...new Set([...models, ...fetched])] + } + return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '', builtin: isBuiltinProviderKey(providerKey) } + }), + ) + for (const result of customFetches) { + if (result.status === 'fulfilled' && result.value?.models.length) { + const { providerKey, label, base_url, models, api_key, builtin } = result.value + addGroup(providerKey, label, base_url, models, api_key, builtin) + } + } + + if (groups.length === 0) { + const fallback = buildModelGroups(config) + for (const group of fallback.groups) { + const models = group.models.map(model => model.id) + if (models.length) addGroup(group.provider, group.provider, '', models, '') + } + currentDefault = currentDefault || fallback.default + } + + for (const g of groups) { + g.models = Array.from(new Set(g.models)) + g.available_models = Array.from(new Set(g.available_models || g.models)) + } + const groupsWithCustomModels = applyCustomModels(groups, normalizeCustomModels(appConfig.customModels)) + + return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups: groupsWithCustomModels } +} + +export async function getAvailable(ctx: any) { + try { + const requestedProfile = requestedProfileName(ctx) + if (!requestedProfile) { + const appConfig = await readAppConfig() + const modelAliases = normalizeAliases(appConfig.modelAliases) + const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) + const customModels = normalizeCustomModels(appConfig.customModels) + const fetchCache: ProviderFetchCache = new Map() + const visibleProfiles = visibleProfileNamesForUser(ctx) + const profileResults = await Promise.all( + visibleProfiles.map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)), + ) + const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups)) + const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases) + const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility) + const activeProfile = requestScopedProfileName(ctx) + const defaultProfile = profileResults.find(result => result.profile === activeProfile && (result.default || result.default_provider)) + || profileResults.find(result => result.default && result.default_provider) + || profileResults.find(result => result.default) + const visibleDefault = resolveVisibleDefault( + defaultProfile?.default || '', + defaultProfile?.default_provider || '', + visibleGroups, + ) + const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p)) + ctx.body = { + default: visibleDefault.defaultModel, + default_provider: visibleDefault.defaultProvider, + groups: visibleGroups, + allProviders: applyModelAliases(allProvidersBase, modelAliases), + model_aliases: modelAliases, + model_visibility: modelVisibility, + custom_models: customModels, + profiles: profileResults.map(result => ({ + profile: result.profile, + default: result.default, + default_provider: result.default_provider, + groups: applyModelVisibility(applyModelAliases(result.groups, modelAliases), modelVisibility), + })), + } + return + } + + const appConfigForProfile = await readAppConfig() + const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases) + const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility) + const customModelsForProfile = normalizeCustomModels(appConfigForProfile.customModels) + const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile) + const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile) + const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile) + const visibleProfileDefault = resolveVisibleDefault(profileResult.default, profileResult.default_provider, visibleProfileGroups) + ctx.body = { + default: visibleProfileDefault.defaultModel, + default_provider: visibleProfileDefault.defaultProvider, + groups: visibleProfileGroups, + allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => providerPresetToGroup(p)), modelAliasesForProfile), + model_aliases: modelAliasesForProfile, + model_visibility: modelVisibilityForProfile, + custom_models: customModelsForProfile, + profiles: [{ + profile: profileResult.profile, + default: profileResult.default, + default_provider: profileResult.default_provider, + groups: visibleProfileGroups, + }], + } + return + + const config = await readConfigYaml() + const modelSection = config.model + let currentDefault = '' + let currentDefaultProvider = '' + if (typeof modelSection === 'object' && modelSection !== null) { + currentDefault = String(modelSection.default || '').trim() + currentDefaultProvider = String(modelSection.provider || '').trim() + // When hermes CLI sets provider: custom, resolve to custom:name + // by matching base_url + model against custom_providers + if (currentDefaultProvider === 'custom' && currentDefault) { + const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : [] + const match = cps.find( + (cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '') + && cp.model === currentDefault, + ) + if (match) { + currentDefaultProvider = `custom:${match.name.trim().toLowerCase().replace(/ /g, '-')}` + } + } + } else if (typeof modelSection === 'string') { + currentDefault = modelSection.trim() + } + + const groups: AvailableGroup[] = [] + const seenProviders = new Set() + + let envContent = '' + try { envContent = await readFile(getActiveEnvPath(), 'utf-8') } catch { } + + const envHasValue = (key: string): boolean => { + if (!key) return false + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#') + } + const envGetValue = (key: string): string => { + if (!key) return '' + const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + return match?.[1]?.trim() || '' + } + const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record) => { + if (seenProviders.has(provider)) return + seenProviders.add(provider) + const availableModels = [...models] + groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) }) + } + + const isOAuthAuthorized = (providerKey: string): boolean => { + try { + const authPath = getActiveAuthPath() + if (!existsSync(authPath)) return false + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + const provider = auth.providers?.[providerKey] + const pool = auth.credential_pool?.[providerKey] + // Legacy OAuth providers are stored under providers.*; newer Hermes + // credential pools store Codex-style OAuth entries under + // credential_pool.*. Treat either shape as an authorized provider. + return !!( + provider?.tokens?.access_token || + provider?.access_token || + (Array.isArray(pool) && pool.some((entry: any) => entry?.access_token)) + ) + } catch { return false } + } + + // 同一请求内复用 copilot 动态模型(getCopilotModelsDetailed 内部有 inflight + 缓存, + // 这里再缓存到局部变量进一步减少分支) + let copilotLiveModels: CopilotModelMeta[] | null = null + const getCopilotLive = async (): Promise => { + if (copilotLiveModels !== null) return copilotLiveModels + try { copilotLiveModels = await getCopilotModelsDetailed(envContent) } + catch { copilotLiveModels = [] } + return copilotLiveModels + } + + // Copilot 显式 opt-in:即便能解析到 token,未通过 web-ui Add Provider 显式启用 + // 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。 + const appConfig = await readAppConfig() + const copilotEnabled = appConfig.copilotEnabled === true + const modelAliases = normalizeAliases(appConfig.modelAliases) + const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) + const customModels = normalizeCustomModels(appConfig.customModels) + + // 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的 + // config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不 + // 启用,把返回的 default 清掉,让前端兜底自动选剩余 provider 的第一个 model。 + if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') { + currentDefault = '' + currentDefaultProvider = '' + } + + for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) { + if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue + if (!envMapping.api_key_env) { + if (providerKey === 'copilot') { + if (!copilotEnabled) continue + if (!(await isCopilotAuthorized(envContent))) continue + } else if (!isOAuthAuthorized(providerKey)) { + continue + } + } + const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey) + const label = preset?.label || providerKey.replace(/^custom:/, '') + let baseUrl = preset?.base_url || '' + if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) { + baseUrl = envGetValue(envMapping.base_url_env) || baseUrl + } + const catalogModels = PROVIDER_MODEL_CATALOG[providerKey] + let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : [] + let modelMeta: Record | undefined + if (providerKey === 'copilot') { + const live = await getCopilotLive() + if (live.length > 0) { + modelsList = live.map((m) => m.id) + const nextModelMeta: Record = {} + for (const m of live) { + if (m.preview || m.disabled) { + nextModelMeta[m.id] = { + ...(m.preview ? { preview: true } : {}), + ...(m.disabled ? { disabled: true } : {}), + } + } + } + modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined + } + } else if (providerShouldFetchLiveModels(providerKey)) { + // These providers expose dynamic OpenAI-compatible /models catalogs. + if (envMapping.api_key_env) { + const apiKey = envGetValue(envMapping.api_key_env) + if (apiKey) { + try { + const fetched = await fetchProviderModels(baseUrl, apiKey, providerKey === 'openrouter') + if (fetched.length > 0) modelsList = fetched + } catch { /* ignore — leave empty, won't show */ } + } + } + } + modelsList = includeConfiguredDefaultModel(providerKey, modelsList, currentDefault, currentDefaultProvider) + if (modelsList.length > 0) { + const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : '' + addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta) + } + } + + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }> + : [] + + const customFetches = await Promise.allSettled( + customProviders.map(async cp => { + if (!cp.base_url) return null + const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}` + const baseUrl = cp.base_url.replace(/\/+$/, '') + let models = [cp.model] + if (cp.api_key) { + try { const fetched = await fetchProviderModels(baseUrl, cp.api_key); if (fetched.length > 0) models = [...new Set([cp.model, ...fetched])] } catch { } + } + return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '', builtin: isBuiltinProviderKey(providerKey) } + }), + ) + + for (const result of customFetches) { + const value = (result as { value?: any }).value + if (value) { + const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = value + addGroup(providerKey, label, base_url, models, cpApiKey, cpBuiltin) + } + } + + for (const g of groups) { g.models = Array.from(new Set(g.models)) } + const groupsWithAliases = applyModelAliases(applyCustomModels(groups, customModels), modelAliases) + const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility) + const visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups) + + // 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存) + // 未启用 Copilot 时跳过拉取,避免空跑网络请求。 + const liveCopilotModels = copilotEnabled ? await getCopilotLive() : [] + const liveCopilotIds = liveCopilotModels.map((m) => m.id) + + const allProvidersBase = PROVIDER_PRESETS.map((p: any) => providerPresetToGroup( + p, + p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models, + )) + const allProviders = applyModelAliases(allProvidersBase, modelAliases) + + if (groups.length === 0) { + const fallback = buildModelGroups(config) + const fallbackGroups: AvailableGroup[] = fallback.groups.map(group => { + const models = group.models.map(model => model.id) + return { + provider: group.provider, + label: group.provider, + base_url: '', + models, + available_models: models, + api_key: '', + } + }) + const fallbackGroupsWithAliases = applyModelAliases(fallbackGroups, modelAliases) + const visibleFallbackGroups = applyModelVisibility(fallbackGroupsWithAliases, modelVisibility) + const fallbackDefault = resolveVisibleDefault(fallback.default, currentDefaultProvider, visibleFallbackGroups) + ctx.body = { + default: fallbackDefault.defaultModel, + default_provider: fallbackDefault.defaultProvider, + groups: visibleFallbackGroups, + allProviders, + model_aliases: modelAliases, + model_visibility: modelVisibility, + custom_models: customModels, + } + return + } + + ctx.body = { + default: visibleDefault.defaultModel, + default_provider: visibleDefault.defaultProvider, + groups: visibleGroups, + allProviders, + model_aliases: modelAliases, + model_visibility: modelVisibility, + custom_models: customModels, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function addCustomModel(ctx: any) { + const { provider, model } = (ctx.request.body || {}) as { provider?: string; model?: string } + const providerKey = String(provider || '').trim() + const modelId = String(model || '').trim() + if (!providerKey || !modelId) { + ctx.status = 400 + ctx.body = { error: 'Missing provider or model' } + return + } + + try { + const appConfig = await readAppConfig() + const customModels = normalizeCustomModels(appConfig.customModels) + customModels[providerKey] = Array.from(new Set([...(customModels[providerKey] || []), modelId])) + const saved = await writeAppConfig({ customModels }) + ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function removeCustomModel(ctx: any) { + const body = (ctx.request.body || {}) as { provider?: string; model?: string } + const provider = body.provider ?? ctx.query?.provider + const model = body.model ?? ctx.query?.model + const providerKey = String(provider || '').trim() + const modelId = String(model || '').trim() + if (!providerKey || !modelId) { + ctx.status = 400 + ctx.body = { error: 'Missing provider or model' } + return + } + + try { + const appConfig = await readAppConfig() + const customModels = normalizeCustomModels(appConfig.customModels) + const remaining = (customModels[providerKey] || []).filter(item => item !== modelId) + if (remaining.length > 0) customModels[providerKey] = remaining + else delete customModels[providerKey] + const saved = await writeAppConfig({ customModels }) + ctx.body = { success: true, custom_models: normalizeCustomModels(saved.customModels) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function fetchProviderModelList(ctx: any) { + try { + const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean } + const baseUrl = String(body?.base_url || '').trim() + const apiKey = String(body?.api_key || '').trim() + const freeOnly = body?.freeOnly === true + + if (!baseUrl) { + ctx.status = 400 + ctx.body = { error: 'Missing base_url' } + return + } + + let parsed: URL + try { + parsed = new URL(baseUrl) + } catch { + ctx.status = 400 + ctx.body = { error: 'Invalid base_url' } + return + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + ctx.status = 400 + ctx.body = { error: 'base_url must use http or https' } + return + } + + const base = baseUrl.replace(/\/+$/, '') + const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models` + const headers: Record = {} + if (apiKey) headers.Authorization = `Bearer ${apiKey}` + + const res = await fetch(modelsUrl, { + headers, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) { + ctx.status = 502 + ctx.body = { error: `Provider returned HTTP ${res.status}` } + return + } + + const data = await res.json() as { data?: Array<{ id?: unknown }> } + if (!Array.isArray(data.data)) { + ctx.status = 502 + ctx.body = { error: 'Provider returned unexpected format' } + return + } + + let models = data.data + .map(m => String(m?.id || '').trim()) + .filter(Boolean) + if (freeOnly) models = models.filter(m => m.endsWith(':free')) + ctx.body = { models: Array.from(new Set(models)).sort() } + } catch (err: any) { + ctx.status = err?.name === 'TimeoutError' ? 504 : 502 + ctx.body = { error: err?.message || 'Failed to fetch provider models' } + } +} + + +export async function setModelAlias(ctx: any) { + const body = ctx.request.body + const provider = body && typeof body === 'object' && !Array.isArray(body) ? body.provider : undefined + const model = body && typeof body === 'object' && !Array.isArray(body) ? body.model : undefined + const alias = body && typeof body === 'object' && !Array.isArray(body) ? body.alias : undefined + + if (typeof provider !== 'string' || typeof model !== 'string' || (alias !== undefined && typeof alias !== 'string')) { + ctx.status = 400 + ctx.body = { error: 'Invalid provider, model, or alias' } + return + } + + const cleanProvider = provider.trim() + const cleanModel = model.trim() + const cleanAlias = (alias || '').trim() + + if (!isSafeAliasKey(cleanProvider) || !isSafeAliasKey(cleanModel)) { + ctx.status = 400 + ctx.body = { error: 'Invalid provider or model' } + return + } + + if (cleanAlias.length > 512) { + ctx.status = 400 + ctx.body = { error: 'Alias is too long' } + return + } + + try { + const appConfig = await readAppConfig() + const modelAliases = normalizeAliases(appConfig.modelAliases) + if (cleanAlias) { + if (!Object.hasOwn(modelAliases, cleanProvider)) modelAliases[cleanProvider] = createAliasMap() + modelAliases[cleanProvider][cleanModel] = cleanAlias + } else { + if (Object.hasOwn(modelAliases, cleanProvider)) delete modelAliases[cleanProvider][cleanModel] + if (Object.hasOwn(modelAliases, cleanProvider) && Object.keys(modelAliases[cleanProvider]).length === 0) { + delete modelAliases[cleanProvider] + } + } + await writeAppConfig({ modelAliases }) + ctx.body = { success: true, model_aliases: modelAliases } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function getConfigModels(ctx: any) { + try { + const config = await readConfigYamlForProfile(requestScopedProfileName(ctx)) + ctx.body = buildModelGroups(config) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function setConfigModel(ctx: any) { + const { default: defaultModel, provider: reqProvider } = ctx.request.body as { default: string; provider?: string } + if (!defaultModel) { + ctx.status = 400 + ctx.body = { error: 'Missing default model' } + return + } + try { + const profile = requestScopedProfileName(ctx) + await updateConfigYamlForProfile(profile, (config) => { + config.model = {} + config.model.default = defaultModel + if (reqProvider) { config.model.provider = reqProvider } + return config + }) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +/** + * 设置模型上下文配置(UPSERT:存在则更新,不存在则插入) + * 支持路径参数和查询参数两种方式 + */ +export async function updateModelContext(ctx: any) { + // 支持两种方式: + // 1. 路径参数: /api/hermes/model-context/:provider/:model + // 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx + let provider: string | undefined + let model: string | undefined + + // 优先从路径参数获取 + if (ctx.params.provider && ctx.params.model) { + provider = ctx.params.provider + model = ctx.params.model + } else { + // 从查询参数获取 + const query = ctx.query as { provider?: string; model?: string } + provider = query.provider + model = query.model + } + + // 如果没有参数,从请求体获取 + if (!provider || !model) { + const body = ctx.request.body as { provider?: string; model?: string; context_limit?: number } + provider = body.provider + model = body.model + } + + const { context_limit } = ctx.request.body as { context_limit: number } + + if (!provider || !model || !context_limit) { + ctx.status = 400 + ctx.body = { error: 'Missing required fields: provider, model, context_limit' } + return + } + + if (typeof context_limit !== 'number' || context_limit <= 0) { + ctx.status = 400 + ctx.body = { error: 'Context limit must be a positive number' } + return + } + + try { + const db = getDb() + if (!db) { + ctx.status = 500 + ctx.body = { error: 'Database not available' } + return + } + + // 使用 REPLACE 实现 UPSERT:存在则替换,不存在则插入 + db.prepare( + `REPLACE INTO ${MODEL_CONTEXT_TABLE} (provider, model, context_limit) VALUES (?, ?, ?)` + ).run(provider, model, context_limit) + + // 查询并返回更新后的数据 + const row = db.prepare( + `SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?` + ).get(provider, model) as { id: number; provider: string; model: string; context_limit: number } + + ctx.body = { + success: true, + data: row + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +/** + * 查询模型上下文配置 + */ +export async function getModelContext(ctx: any) { + // 支持两种方式: + // 1. 路径参数: /api/hermes/model-context/:provider/:model + // 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx + let provider: string | undefined + let model: string | undefined + + // 优先从路径参数获取 + if (ctx.params.provider && ctx.params.model) { + provider = ctx.params.provider + model = ctx.params.model + } else { + // 从查询参数获取 + const query = ctx.query as { provider?: string; model?: string } + provider = query.provider + model = query.model + } + + if (!provider || !model) { + ctx.status = 400 + ctx.body = { error: 'Missing provider or model parameter' } + return + } + + try { + const db = getDb() + if (!db) { + ctx.status = 500 + ctx.body = { error: 'Database not available' } + return + } + + const row = db.prepare( + `SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?` + ).get(provider, model) as { id: number; provider: string; model: string; context_limit: number } | undefined + + if (!row) { + ctx.status = 404 + ctx.body = { error: 'Model context not found' } + return + } + + ctx.body = { data: { ...row, limit: row.context_limit } } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + + +export async function setModelVisibility(ctx: any) { + const { provider, mode, models } = ctx.request.body as { provider?: string; mode?: string; models?: string[] } + const providerKey = String(provider || '').trim() + if (!providerKey) { + ctx.status = 400 + ctx.body = { error: 'Missing provider' } + return + } + if (mode !== 'all' && mode !== 'include') { + ctx.status = 400 + ctx.body = { error: 'Invalid visibility mode' } + return + } + const selectedModels = uniqueStrings(models) + if (mode === 'include' && selectedModels.length === 0) { + ctx.status = 400 + ctx.body = { error: 'Select at least one model' } + return + } + + try { + const appConfig = await readAppConfig() + const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) + if (mode === 'all') { + delete modelVisibility[providerKey] + } else { + modelVisibility[providerKey] = { mode: 'include', models: selectedModels } + } + const saved = await writeAppConfig({ modelVisibility }) + ctx.body = { success: true, model_visibility: normalizeModelVisibility(saved.modelVisibility) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/nous-auth.ts b/packages/server/src/controllers/hermes/nous-auth.ts new file mode 100644 index 0000000..c473757 --- /dev/null +++ b/packages/server/src/controllers/hermes/nous-auth.ts @@ -0,0 +1,314 @@ +import { randomUUID } from 'crypto' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { dirname, join } from 'path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { logger } from '../../services/logger' + +// --- Nous Portal OAuth Constants --- +const NOUS_PORTAL_URL = 'https://portal.nousresearch.com' +const NOUS_CLIENT_ID = 'hermes-cli' +const NOUS_SCOPE = 'inference:mint_agent_key' +const POLL_MAX_DURATION = 15 * 60 * 1000 +const POLL_DEFAULT_INTERVAL = 5000 + +// --- Session Store --- +interface NousSession { + id: string + profile: string + deviceCode: string + userCode: string + verificationUrl: string + verificationUrlComplete: string + expiresIn: number + interval: number + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error?: string + createdAt: number +} + +const sessions = new Map() + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((s, id) => { if (now - s.createdAt > POLL_MAX_DURATION + 60000) sessions.delete(id) }) +} + +// --- Auth file helpers --- +interface AuthJson { + version?: number + active_provider?: string + providers?: Record + credential_pool?: Record + updated_at?: string +} + +function loadAuthJson(authPath: string): AuthJson { + try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } +} + +function saveAuthJson(authPath: string, data: AuthJson): void { + data.updated_at = new Date().toISOString() + const dir = dirname(authPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +function requestedProfile(ctx: any): string { + const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : '' + const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : '' + const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : '' + return ctx.state?.profile?.name || + headerProfile.trim() || + queryProfile.trim() || + bodyProfile.trim() || + getActiveProfileName() || + 'default' +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +export function saveNousOAuthTokensForProfile( + profile: string, + tokenData: { + access_token: string + refresh_token?: string + expires_in?: number + inference_base_url?: string + }, + agentKey = '', + agentKeyExpiresAt = '', +): void { + const inferenceBaseUrl = tokenData.inference_base_url || 'https://inference-api.nousresearch.com/v1' + const auth = loadAuthJson(authPathForProfile(profile)) + if (!auth.providers) auth.providers = {} + const now = new Date() + auth.providers['nous'] = { + portal_base_url: NOUS_PORTAL_URL, + inference_base_url: inferenceBaseUrl, + client_id: NOUS_CLIENT_ID, + scope: NOUS_SCOPE, + token_type: 'Bearer', + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || null, + obtained_at: now.toISOString(), + expires_at: tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000).toISOString() : null, + agent_key: agentKey || null, + agent_key_expires_at: agentKeyExpiresAt || null, + agent_key_obtained_at: agentKey ? now.toISOString() : null, + } + + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['nous'] = [{ + id: `nous-${Date.now()}`, + label: 'Nous Portal', + auth_type: 'oauth', + source: 'device_code', + priority: 0, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || null, + portal_base_url: NOUS_PORTAL_URL, + inference_base_url: inferenceBaseUrl, + agent_key: agentKey || null, + agent_key_expires_at: agentKeyExpiresAt || null, + base_url: inferenceBaseUrl, + }] + + saveAuthJson(authPathForProfile(profile), auth) +} + +// --- Background poll worker --- +async function nousLoginWorker(session: NousSession): Promise { + const startTime = Date.now() + let interval = session.interval || POLL_DEFAULT_INTERVAL + + while (Date.now() - startTime < POLL_MAX_DURATION) { + await new Promise(resolve => setTimeout(resolve, interval)) + if (session.status !== 'pending') return + + try { + const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: NOUS_CLIENT_ID, + device_code: session.deviceCode, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (res.ok) { + const tokenData = await res.json() as { + access_token: string + refresh_token?: string + expires_in?: number + inference_base_url?: string + } + + // Mint agent key + const inferenceBaseUrl = tokenData.inference_base_url || 'https://inference-api.nousresearch.com/v1' + let agentKey = '' + let agentKeyExpiresAt = '' + try { + const mintRes = await fetch(`${NOUS_PORTAL_URL}/api/oauth/agent-key`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokenData.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ min_ttl_seconds: 1800 }), + signal: AbortSignal.timeout(15000), + }) + if (mintRes.ok) { + const mintData = await mintRes.json() as { + api_key: string + expires_at: string + inference_base_url?: string + } + agentKey = mintData.api_key + agentKeyExpiresAt = mintData.expires_at + if (mintData.inference_base_url) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void mintData.inference_base_url + } + } + } catch (err: any) { + logger.warn(err, 'Nous agent key minting failed, proceeding without') + } + + saveNousOAuthTokensForProfile(session.profile, tokenData, agentKey, agentKeyExpiresAt) + session.status = 'approved' + logger.info('Nous login successful') + return + } + + // Parse error + const errData = await res.json().catch(() => ({})) + const errorCode = errData.error + + if (errorCode === 'authorization_pending') { + continue + } + if (errorCode === 'slow_down') { + interval = Math.min(interval + 1000, 30000) + continue + } + if (errorCode === 'access_denied' || errorCode === 'expired_token') { + session.status = errorCode === 'access_denied' ? 'denied' : 'expired' + return + } + + logger.error('Nous poll error: %s %s', res.status, errorCode) + session.status = 'error' + session.error = `OAuth error: ${errorCode}` + return + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') continue + logger.error(err, 'Nous poll error') + session.status = 'error' + session.error = err.message + return + } + } + + session.status = 'expired' +} + +// --- Controller functions --- + +export async function start(ctx: any) { + try { + cleanupExpiredSessions() + + const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: new URLSearchParams({ + client_id: NOUS_CLIENT_ID, + scope: NOUS_SCOPE, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (!res.ok) { + let errorBody: any = null + try { errorBody = await res.json() } catch { } + logger.error('Nous device code request failed: %d %s', res.status, errorBody) + ctx.status = 502 + ctx.body = { error: `Nous Portal error: ${res.status}` } + return + } + + const data = await res.json() as { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number + } + + const sessionId = randomUUID() + const session: NousSession = { + id: sessionId, + profile: requestedProfile(ctx), + deviceCode: data.device_code, + userCode: data.user_code, + verificationUrl: data.verification_uri, + verificationUrlComplete: data.verification_uri_complete, + expiresIn: data.expires_in, + interval: data.interval, + status: 'pending', + createdAt: Date.now(), + } + sessions.set(sessionId, session) + + nousLoginWorker(session).catch(err => { + logger.error(err, 'Nous login worker error') + session.status = 'error' + session.error = err.message + }) + + ctx.body = { + session_id: sessionId, + user_code: data.user_code, + verification_url: data.verification_uri_complete, + expires_in: data.expires_in, + } + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') { + ctx.status = 504 + ctx.body = { error: 'Nous Portal timeout' } + return + } + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function poll(ctx: any) { + const session = sessions.get(ctx.params.sessionId) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + ctx.body = { status: session.status, error: session.error || null } +} + +export async function status(ctx: any) { + try { + const authPath = authPathForProfile(requestedProfile(ctx)) + const auth = loadAuthJson(authPath) + const nousProvider = auth.providers?.['nous'] + if (!nousProvider?.access_token) { + ctx.body = { authenticated: false } + return + } + ctx.body = { authenticated: true } + } catch { + ctx.body = { authenticated: false } + } +} diff --git a/packages/server/src/controllers/hermes/performance-monitor.ts b/packages/server/src/controllers/hermes/performance-monitor.ts new file mode 100644 index 0000000..74dfb8d --- /dev/null +++ b/packages/server/src/controllers/hermes/performance-monitor.ts @@ -0,0 +1,9 @@ +import { createEmptyOpsRuntimeSnapshot, getOpsRuntimeSnapshot } from '../../services/hermes/ops-monitor' + +export async function runtime(ctx: any) { + try { + ctx.body = await getOpsRuntimeSnapshot() + } catch (err: any) { + ctx.body = createEmptyOpsRuntimeSnapshot(err?.message || 'Failed to read performance metrics') + } +} diff --git a/packages/server/src/controllers/hermes/plugins.ts b/packages/server/src/controllers/hermes/plugins.ts new file mode 100644 index 0000000..61b3c2f --- /dev/null +++ b/packages/server/src/controllers/hermes/plugins.ts @@ -0,0 +1,10 @@ +import { listHermesPlugins } from '../../services/hermes/plugins' + +export async function list(ctx: any) { + try { + ctx.body = await listHermesPlugins(ctx.state?.profile?.name) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message || 'Failed to discover Hermes plugins' } + } +} diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts new file mode 100644 index 0000000..1f23cd7 --- /dev/null +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -0,0 +1,825 @@ +import { createReadStream, existsSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import { basename, join } from 'path' +import { tmpdir } from 'os' +import { getWebUiHome } from '../../config' +import * as hermesCli from '../../services/hermes/hermes-cli' +import { SessionDeleter } from '../../services/hermes/session-deleter' +import { AgentBridgeClient } from '../../services/hermes/agent-bridge' +import { + getGatewayRuntimeStatusForProfile, + restartGatewayForProfile as restartGatewayRuntimeForProfile, +} from '../../services/hermes/gateway-autostart' +import { logger } from '../../services/logger' +import { smartCloneCleanup } from '../../services/hermes/profile-credentials' +import { detectHermesRootHome } from '../../services/hermes/hermes-path' +import { getActiveProfileName } from '../../services/hermes/hermes-profile' +import { HermesSkillInjector } from '../../services/hermes/skill-injector' +import type { HermesProfile } from '../../services/hermes/hermes-cli' +import { listUserProfiles } from '../../db/hermes/users-store' + +const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 }) + +interface ProfileAvatarMeta { + type: 'generated' | 'image' + seed?: string + file?: string + mime?: string + updatedAt?: number +} + +interface ProfileAvatarResponse { + type: 'generated' | 'image' + seed?: string + dataUrl?: string + updatedAt?: number +} + +type RuntimeStatus = Awaited> + +interface RuntimeStatusCacheEntry { + status: RuntimeStatus + updatedAt: number +} + +const runtimeStatusCache = new Map() +let runtimeStatusRefreshPromise: Promise | null = null +let runtimeStatusMinimumFreshAt = 0 + +const RESERVED_PROFILE_NAMES = new Set([ + 'hermes', 'default', 'test', 'tmp', 'root', 'sudo', +]) + +const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([ + 'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout', + 'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools', + 'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall', + 'profile', 'plugins', 'honcho', 'acp', +]) + +function normalizeProfileName(name: string): string { + return String(name || '').trim().toLowerCase() +} + +function isForbiddenProfileName(name: string): boolean { + const normalized = normalizeProfileName(name) + if (!normalized || normalized === 'default') return false + return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized) +} + +function getActiveProfileFile(): string { + return join(detectHermesRootHome(), 'active_profile') +} + +function listProfilesFromDisk(activeProfileName: string): HermesProfile[] { + const base = detectHermesRootHome() + const profiles: HermesProfile[] = [{ + name: 'default', + active: activeProfileName === 'default', + model: '—', + alias: '', + }] + const profilesDir = join(base, 'profiles') + if (!existsSync(profilesDir)) return profiles + for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const name = entry.name + const dir = join(profilesDir, name) + if (!existsSync(join(dir, 'config.yaml')) && !existsSync(dir)) continue + profiles.push({ + name, + active: name === activeProfileName, + model: '—', + alias: '', + }) + } + return profiles +} + +function profileExistsForManualSwitch(name: string): boolean { + const base = detectHermesRootHome() + if (!name || name === 'default') return true + return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name)) +} + +async function injectBundledSkillsForProfile(name: string): Promise { + try { + const targetDir = HermesSkillInjector.resolveTargetDirForProfile(name) + const result = await new HermesSkillInjector(undefined, targetDir).injectMissingSkills() + const target = result.targets[0] + if (target && (target.injected.length > 0 || target.updated.length > 0)) { + logger.info({ + profile: name, + targetDir, + injected: target.injected, + updated: target.updated, + }, '[profiles] synced bundled skills for profile') + } + } catch (err: any) { + logger.warn(err, '[profiles] failed to sync bundled skills for profile "%s"', name) + } +} + +function deleteForbiddenProfileFromDisk(name: string): boolean { + if (!isForbiddenProfileName(name)) return false + const base = detectHermesRootHome() + const profileDir = join(base, 'profiles', name) + if (!existsSync(profileDir)) return false + rmSync(profileDir, { recursive: true, force: true }) + try { + if (normalizeProfileName(getActiveProfileName()) === normalizeProfileName(name)) { + writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8') + } + } catch {} + logger.warn('[deleteProfile] removed reserved profile "%s" from disk after Hermes CLI rejected deletion', name) + return true +} + +function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] { + return profiles.filter(profile => !isForbiddenProfileName(profile.name)) +} + +function requestedProfileName(ctx: any): string { + return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName() +} + +function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return profiles + const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) + return profiles.filter(profile => allowed.has(profile.name)) +} + +function canAccessProfile(ctx: any, profileName: string): boolean { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return true + return listUserProfiles(user.id).some(profile => profile.profile_name === profileName) +} + +function denyProfile(ctx: any, profileName: string): boolean { + if (canAccessProfile(ctx, profileName)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${profileName}" is not available for this user` } + return true +} + +function profileMetadataRoot(): string { + return join(getWebUiHome(), 'profile-metadata') +} + +function profileMetadataDir(name: string): string { + const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url') + return join(profileMetadataRoot(), segment) +} + +function profileAvatarMetaPath(name: string): string { + return join(profileMetadataDir(name), 'avatar.json') +} + +function profileAvatarImagePath(name: string, file = 'avatar.bin'): string { + return join(profileMetadataDir(name), file) +} + +function readProfileAvatar(name: string): ProfileAvatarResponse | null { + const metaPath = profileAvatarMetaPath(name) + if (!existsSync(metaPath)) return null + try { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as ProfileAvatarMeta + if (meta.type === 'generated') { + return { + type: 'generated', + seed: typeof meta.seed === 'string' ? meta.seed : name, + updatedAt: meta.updatedAt, + } + } + if (meta.type === 'image' && meta.file && meta.mime) { + const imagePath = profileAvatarImagePath(name, meta.file) + if (!existsSync(imagePath)) return null + const data = readFileSync(imagePath).toString('base64') + return { + type: 'image', + dataUrl: `data:${meta.mime};base64,${data}`, + updatedAt: meta.updatedAt, + } + } + } catch (err) { + logger.warn(err, '[profiles] failed to read avatar metadata for profile "%s"', name) + } + return null +} + +function attachProfileAvatars(profiles: T[]): Array { + return profiles.map(profile => ({ + ...profile, + avatar: readProfileAvatar(profile.name), + })) +} + +function parseAvatarDataUrl(dataUrl: string): { mime: string; buffer: Buffer } { + const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/) + if (!match) throw new Error('Avatar image must be a PNG, JPEG, or WebP data URL') + const buffer = Buffer.from(match[2], 'base64') + if (buffer.length > 1024 * 1024) throw new Error('Avatar image must be 1MB or smaller') + return { mime: match[1], buffer } +} + +function removeProfileMetadata(name: string): void { + rmSync(profileMetadataDir(name), { recursive: true, force: true }) +} + +function renameProfileMetadata(oldName: string, newName: string): void { + const oldDir = profileMetadataDir(oldName) + const newDir = profileMetadataDir(newName) + if (!existsSync(oldDir) || oldDir === newDir) return + rmSync(newDir, { recursive: true, force: true }) + renameSync(oldDir, newDir) +} + +async function useProfileWithFallback(name: string): Promise { + if (isForbiddenProfileName(name)) { + throw new Error(`Profile name '${name}' is reserved and cannot be activated`) + } + try { + return await hermesCli.useProfile(name) + } catch (err: any) { + if (!profileExistsForManualSwitch(name)) throw err + + const base = detectHermesRootHome() + writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8') + logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name) + return `Switched to profile ${name}` + } +} + +async function readBridgeWorkers(): Promise<{ reachable: boolean; workers: Record; error?: string }> { + try { + const result = await new AgentBridgeClient({ timeoutMs: 5000 }).ping() + return { + reachable: true, + workers: ((result as any).workers || {}) as Record, + } + } catch (err: any) { + return { + reachable: false, + workers: {}, + error: err?.message || 'Bridge broker is not reachable', + } + } +} + +function gatewayStatusLooksRunning(status?: string): boolean { + const normalized = String(status || '').trim().toLowerCase() + if (!normalized || normalized === '—') return false + if (normalized.includes('not running') || normalized === 'stopped' || normalized === 'stop') return false + return normalized.includes('running') || normalized === 'active' +} + +async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?: Awaited>) { + const name = typeof profile === 'string' ? profile : profile.name + const bridge = bridgeState || await readBridgeWorkers() + let gateway: { running: boolean; profile: string; error?: string } + if (typeof profile !== 'string' && profile.gatewayStatus !== undefined) { + const profileListRunning = gatewayStatusLooksRunning(profile.gatewayStatus) + if (profileListRunning) { + gateway = { + running: true, + profile: name, + } + } else { + try { + gateway = await getGatewayRuntimeStatusForProfile(name) + } catch (err: any) { + gateway = { + running: false, + profile: name, + error: err?.message || 'Gateway status check failed', + } + } + } + } else { + try { + gateway = await getGatewayRuntimeStatusForProfile(name) + } catch (err: any) { + gateway = { + running: false, + profile: name, + error: err?.message || 'Gateway status check failed', + } + } + } + + return { + profile: name, + bridge: { + running: !!bridge.workers[name], + profile: name, + reachable: bridge.reachable, + error: bridge.reachable ? undefined : bridge.error, + }, + gateway, + } +} + +function setRuntimeStatusCache(status: RuntimeStatus, checkedAt = Date.now()): void { + runtimeStatusCache.set(status.profile, { + status, + updatedAt: checkedAt, + }) +} + +function listProfilesForStatusFast(): HermesProfile[] { + return filterVisibleProfiles(listProfilesFromDisk(getActiveProfileName())) +} + +async function refreshRuntimeStatusCache(checkedAt: number): Promise { + const profiles = await listProfilesForStatus() + const bridge = await readBridgeWorkers() + const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge))) + statuses.forEach(status => setRuntimeStatusCache(status, checkedAt)) +} + +function startRuntimeStatusRefresh(): void { + const startedAt = Date.now() + runtimeStatusRefreshPromise = refreshRuntimeStatusCache(startedAt) + .catch((err) => { + logger.warn(err, '[profiles] failed to refresh runtime status cache') + }) + .finally(() => { + runtimeStatusRefreshPromise = null + if (runtimeStatusMinimumFreshAt > startedAt) { + startRuntimeStatusRefresh() + } + }) +} + +function scheduleRuntimeStatusRefresh(): void { + runtimeStatusMinimumFreshAt = Math.max(runtimeStatusMinimumFreshAt, Date.now()) + if (runtimeStatusRefreshPromise) return + startRuntimeStatusRefresh() +} + +export async function list(ctx: any) { + try { + let profiles: HermesProfile[] + try { + profiles = await hermesCli.listProfiles() + } catch (err: any) { + const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') + const activeProfileName = getActiveProfileName() + if (!isForbiddenProfileName(activeProfileName)) throw err + + logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName) + writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8') + profiles = listProfilesFromDisk('default') + } + + const activeProfileName = requestedProfileName(ctx) + + profiles = filterVisibleProfiles(profiles) + profiles = filterProfilesForUser(ctx, profiles) + + // Web UI active profile is request-scoped and comes from X-Hermes-Profile. + profiles.forEach(p => { + p.active = (p.name === activeProfileName) + }) + + ctx.body = { profiles: attachProfileAvatars(profiles) } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function create(ctx: any) { + const { name, clone } = ctx.request.body as { name?: string; clone?: boolean } + if (!name) { + ctx.status = 400 + ctx.body = { error: 'Missing profile name' } + return + } + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` } + return + } + try { + const output = await hermesCli.createProfile(name, clone) + + // clone=true 时执行智能清理: + // - 删除 .env 中的独占平台凭据(Weixin / Telegram / Slack / ...) + // - 禁用 config.yaml 中对应的平台节点 + // 避免新 profile 与源 profile 共享同一个 bot token 导致互斥冲突。 + let strippedCredentials: string[] = [] + let disabledPlatforms: string[] = [] + let strippedConfigCredentials: string[] = [] + if (clone) { + try { + const cleanup = smartCloneCleanup(name) + strippedCredentials = cleanup.strippedCredentials + disabledPlatforms = cleanup.disabledPlatforms + strippedConfigCredentials = cleanup.strippedConfigCredentials + if ( + strippedCredentials.length > 0 || + disabledPlatforms.length > 0 || + strippedConfigCredentials.length > 0 + ) { + logger.info( + 'Smart clone cleanup for "%s": stripped %d env credentials (%s), disabled %d platforms (%s), stripped %d config credentials (%s)', + name, + strippedCredentials.length, strippedCredentials.join(','), + disabledPlatforms.length, disabledPlatforms.join(','), + strippedConfigCredentials.length, strippedConfigCredentials.join(','), + ) + } + } catch (err: any) { + // 清理失败不应阻断 profile 创建,仅记日志 + logger.error(err, 'Smart clone cleanup failed for "%s"', name) + } + } + + await injectBundledSkillsForProfile(name) + + ctx.body = { + success: true, + message: output.trim(), + strippedCredentials, + disabledPlatforms, + strippedConfigCredentials, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function get(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + try { + const profile = await hermesCli.getProfile(name) + ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } } + } catch (err: any) { + ctx.status = err.message.includes('not found') ? 404 : 500 + ctx.body = { error: err.message } + } +} + +export async function updateAvatar(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved` } + return + } + const body = ctx.request.body as { type?: string; seed?: string; dataUrl?: string } + try { + const dir = profileMetadataDir(name) + await mkdir(dir, { recursive: true }) + const updatedAt = Date.now() + + if (body.type === 'generated') { + const seed = String(body.seed || name).trim() || name + const meta: ProfileAvatarMeta = { type: 'generated', seed, updatedAt } + rmSync(profileAvatarImagePath(name), { force: true }) + await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 }) + ctx.body = { avatar: readProfileAvatar(name) } + return + } + + if (body.type === 'image' && typeof body.dataUrl === 'string') { + const { mime, buffer } = parseAvatarDataUrl(body.dataUrl) + const meta: ProfileAvatarMeta = { type: 'image', file: 'avatar.bin', mime, updatedAt } + await writeFile(profileAvatarImagePath(name), buffer, { mode: 0o600 }) + await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 }) + ctx.body = { avatar: readProfileAvatar(name) } + return + } + + ctx.status = 400 + ctx.body = { error: 'Invalid avatar payload' } + } catch (err: any) { + ctx.status = 400 + ctx.body = { error: err.message } + } +} + +export async function deleteAvatar(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + try { + removeProfileMetadata(name) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function runtimeStatus(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved` } + return + } + try { + const profiles = await listProfilesForStatus() + const profile = profiles.find(item => item.name === name) + const status = await buildRuntimeStatus(profile || name) + setRuntimeStatusCache(status) + ctx.body = status + } catch { + const status = await buildRuntimeStatus(name) + setRuntimeStatusCache(status) + ctx.body = status + } +} + +export async function runtimeStatuses(ctx: any) { + try { + const refreshParam = ctx.query?.refresh + const refreshRequested = refreshParam === undefined || (refreshParam !== '0' && refreshParam !== 'false') + if (refreshRequested) scheduleRuntimeStatusRefresh() + + const profiles = filterProfilesForUser(ctx, listProfilesForStatusFast()) + const statuses: RuntimeStatus[] = [] + profiles.forEach(profile => { + const cached = runtimeStatusCache.get(profile.name) + if (cached && cached.updatedAt >= runtimeStatusMinimumFreshAt) { + statuses.push(cached.status) + } + }) + + ctx.body = { + profiles: statuses, + refreshing: !!runtimeStatusRefreshPromise, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +async function listProfilesForStatus(): Promise { + let profiles: HermesProfile[] + try { + profiles = await hermesCli.listProfiles() + } catch { + profiles = listProfilesFromDisk(getActiveProfileName()) + } + return filterVisibleProfiles(profiles) +} + +export async function restartGatewayForProfile(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved` } + return + } + try { + const gateway = await restartGatewayRuntimeForProfile(name) + try { + const result = await bridgeCleanupClient().destroyProfile(name) + logger.info('[profiles] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', name, result.destroyed) + const cached = runtimeStatusCache.get(name)?.status + if (cached) { + setRuntimeStatusCache({ + ...cached, + bridge: { + ...cached.bridge, + running: false, + }, + gateway, + }) + } + } catch (err) { + logger.warn(err, '[profiles] failed to destroy bridge sessions after gateway restart profile=%s', name) + } + ctx.body = { success: true, gateway } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function restartProfileRuntime(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved` } + return + } + try { + const result = await bridgeCleanupClient().destroyProfile(name) + logger.info('[profiles] destroyed bridge sessions after profile restart profile=%s destroyed=%s', name, result.destroyed) + const profiles = await listProfilesForStatus() + const profile = profiles.find(item => item.name === name) + const status = await buildRuntimeStatus(profile || name) + setRuntimeStatusCache(status) + ctx.body = { + success: true, + destroyed: result.destroyed, + status, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function remove(ctx: any) { + const { name } = ctx.params + if (denyProfile(ctx, name)) return + if (name === 'default') { + ctx.status = 400 + ctx.body = { error: 'Cannot delete the default profile' } + return + } + try { + try { + const result = await bridgeCleanupClient().destroyProfile(name) + logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed) + } catch (err) { + logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name) + } + const ok = await hermesCli.deleteProfile(name) + if (ok) { + removeProfileMetadata(name) + ctx.body = { success: true } + } else if (deleteForbiddenProfileFromDisk(name)) { + removeProfileMetadata(name) + ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' } + } else { + ctx.status = 500 + ctx.body = { error: 'Failed to delete profile' } + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function rename(ctx: any) { + if (denyProfile(ctx, ctx.params.name)) return + const { new_name } = ctx.request.body as { new_name?: string } + if (!new_name) { + ctx.status = 400 + ctx.body = { error: 'Missing new_name' } + return + } + try { + const ok = await hermesCli.renameProfile(ctx.params.name, new_name) + if (ok) { + renameProfileMetadata(ctx.params.name, new_name) + ctx.body = { success: true } + } else { + ctx.status = 500 + ctx.body = { error: 'Failed to rename profile' } + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function switchProfile(ctx: any) { + const { name } = ctx.request.body as { name?: string } + if (!name) { + ctx.status = 400 + ctx.body = { error: 'Missing profile name' } + return + } + if (isForbiddenProfileName(name)) { + ctx.status = 400 + ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` } + return + } + try { + if (denyProfile(ctx, name)) return + + const output = await useProfileWithFallback(name) + + const actualActive = getActiveProfileName() + if (actualActive !== name) { + ctx.status = 500 + ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` } + return + } + + try { + const result = await bridgeCleanupClient().destroyProfile(name) + logger.info('[switchProfile] destroyed bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed) + } catch (err: any) { + logger.warn(err, '[switchProfile] failed to destroy bridge sessions for profile "%s"', name) + } + + try { + const detail = await hermesCli.getProfile(name) + logger.debug('Profile detail.path = %s', detail.path) + + const profileConfig = join(detail.path, 'config.yaml') + if (!existsSync(profileConfig)) { + writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8') + logger.info('Created config.yaml for: %s', detail.path) + } + + const profileEnv = join(detail.path, '.env') + if (!existsSync(profileEnv)) { + writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8') + logger.info('Created .env for: %s', detail.path) + } + } catch (err: any) { + logger.error(err, 'Ensure config failed') + } + + await injectBundledSkillsForProfile(name) + SessionDeleter.getInstance().switchProfile(name) + logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name) + + ctx.body = { + success: true, + message: output.trim(), + active: name, + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function exportProfile(ctx: any) { + const { name } = ctx.params + if (denyProfile(ctx, name)) return + const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`) + try { + await hermesCli.exportProfile(name, outputPath) + if (!existsSync(outputPath)) { + ctx.status = 500 + ctx.body = { error: 'Export file not found' } + return + } + const filename = basename(outputPath) + ctx.set('Content-Disposition', `attachment; filename="${filename}"`) + ctx.set('Content-Type', 'application/gzip') + ctx.body = createReadStream(outputPath) + ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } }) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function importProfile(ctx: any) { + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400 + ctx.body = { error: 'Expected multipart/form-data' } + return + } + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400 + ctx.body = { error: 'Missing boundary' } + return + } + const tmpDir = join(tmpdir(), 'hermes-import') + await mkdir(tmpDir, { recursive: true }) + const chunks: Buffer[] = [] + for await (const chunk of ctx.req) chunks.push(chunk) + const body = Buffer.concat(chunks).toString('latin1') + const parts = body.split(boundary).slice(1, -1) + let archivePath = '' + for (const part of parts) { + const headerEnd = part.indexOf('\r\n\r\n') + if (headerEnd === -1) continue + const header = part.substring(0, headerEnd) + const data = part.substring(headerEnd + 4, part.length - 2) + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + const filename = filenameMatch[1] + const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' + if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue + archivePath = join(tmpDir, filename) + await writeFile(archivePath, Buffer.from(data, 'binary')) + break + } + if (!archivePath) { + ctx.status = 400 + ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' } + return + } + try { + const result = await hermesCli.importProfile(archivePath) + try { unlinkSync(archivePath) } catch { } + ctx.body = { success: true, message: result.trim() } + } catch (err: any) { + try { unlinkSync(archivePath) } catch { } + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts new file mode 100644 index 0000000..da570e8 --- /dev/null +++ b/packages/server/src/controllers/hermes/providers.ts @@ -0,0 +1,247 @@ +import { existsSync, readFileSync } from 'fs' +import { writeFile } from 'fs/promises' +import { join } from 'path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { updateConfigYamlForProfile, saveEnvValueForProfile, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { PROVIDER_PRESETS } from '../../shared/providers' +import { logger } from '../../services/logger' + +const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth', 'openai-codex']) +const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth', 'openai-codex']) + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +async function clearStoredAuthProvider(profile: string, poolKey: string) { + try { + const authPath = authPathForProfile(profile) + if (!existsSync(authPath)) return + + const auth = JSON.parse(readFileSync(authPath, 'utf-8')) + let changed = false + if (auth.providers && Object.prototype.hasOwnProperty.call(auth.providers, poolKey)) { + delete auth.providers[poolKey] + changed = true + } + if (auth.credential_pool && Object.prototype.hasOwnProperty.call(auth.credential_pool, poolKey)) { + delete auth.credential_pool[poolKey] + changed = true + } + if (changed) { + await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8') + } + } catch (err: any) { logger.error(err, 'Failed to clear auth credentials for %s', poolKey) } +} + +function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number) { + const entry: any = { name, base_url, api_key, model } + if (context_length && context_length > 0) { + entry.models = { [model]: { context_length } } + } + return entry +} + +function normalizeBaseUrl(url: string): string { + return String(url || '').trim().replace(/\/+$/, '') +} + +function builtinBaseUrl(poolKey: string, requestedBaseUrl: string): string { + return requestedBaseUrl || PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || '' +} + +function shouldPersistBuiltinBaseUrl(poolKey: string, requestedBaseUrl: string): boolean { + const presetBaseUrl = PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || '' + if (!requestedBaseUrl || !presetBaseUrl) return !!requestedBaseUrl + return normalizeBaseUrl(requestedBaseUrl) !== normalizeBaseUrl(presetBaseUrl) +} + +export async function create(ctx: any) { + const { name, base_url, api_key, model, context_length, providerKey } = ctx.request.body as { + name: string; base_url: string; api_key: string; model: string; context_length?: number; providerKey?: string | null + } + const normalizedName = String(name || '').trim() + const poolKey = providerKey || `custom:${normalizedName.toLowerCase().replace(/ /g, '-')}` + const isBuiltin = poolKey in PROVIDER_ENV_MAP + const effectiveBaseUrl = isBuiltin ? builtinBaseUrl(poolKey, base_url) : base_url + if (!normalizedName || !effectiveBaseUrl || !model) { + ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return + } + if (!api_key && !OPTIONAL_API_KEY_PROVIDERS.has(String(providerKey || ''))) { + ctx.status = 400; ctx.body = { error: 'Missing API key' }; return + } + try { + const profile = requestedProfile(ctx) + await updateConfigYamlForProfile(profile, async (config) => { + if (typeof config.model !== 'object' || config.model === null) { config.model = {} } + if (!isBuiltin) { + if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } + const existing = (config.custom_providers as any[]).find( + (e: any) => `custom:${e.name}` === poolKey + ) + if (existing) { + existing.base_url = effectiveBaseUrl + existing.api_key = api_key + existing.model = model + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) + if (preset?.api_mode) existing.api_mode = preset.api_mode + if (context_length && context_length > 0) { + if (!existing.models) existing.models = {} + existing.models[model] = existing.models[model] || {} + existing.models[model].context_length = context_length + } + } else { + const entry = buildProviderEntry(normalizedName.toLowerCase().replace(/ /g, '-'), effectiveBaseUrl, api_key, model, context_length) + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', '')) + if (preset?.api_mode) entry.api_mode = preset.api_mode + config.custom_providers.push(entry) + } + config.model.default = model + config.model.provider = poolKey + } else { + if (PROVIDER_ENV_MAP[poolKey].api_key_env) { + await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) + if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) } + config.model.default = model + config.model.provider = poolKey + } else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) { + if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) } + config.model.default = model + config.model.provider = poolKey + } else { + if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } + const existing = (config.custom_providers as any[]).find( + (e: any) => `custom:${e.name}` === `custom:${poolKey}` + ) + if (existing) { + existing.base_url = effectiveBaseUrl + existing.api_key = api_key + existing.model = model + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + if (preset?.api_mode) existing.api_mode = preset.api_mode + if (context_length && context_length > 0) { + if (!existing.models) existing.models = {} + existing.models[model] = existing.models[model] || {} + existing.models[model].context_length = context_length + } + } else { + const entry = buildProviderEntry(poolKey, effectiveBaseUrl, api_key, model, context_length) + const preset = PROVIDER_PRESETS.find(p => p.value === poolKey) + if (preset?.api_mode) entry.api_mode = preset.api_mode + config.custom_providers.push(entry) + } + config.model.default = model + config.model.provider = `custom:${poolKey}` + } + } + delete config.model.base_url + delete config.model.api_key + return config + }) + // TODO: Test if provider works without gateway restart + // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function update(ctx: any) { + const poolKey = decodeURIComponent(ctx.params.poolKey) + const { name, base_url, api_key, model } = ctx.request.body as { + name?: string; base_url?: string; api_key?: string; model?: string + } + try { + const profile = requestedProfile(ctx) + const isCustom = poolKey.startsWith('custom:') + if (isCustom) { + const found = await updateConfigYamlForProfile(profile, (config) => { + if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false } + const entry = (config.custom_providers as any[]).find((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + if (!entry) return { data: config, result: false, write: false } + if (name !== undefined) entry.name = name + if (base_url !== undefined) entry.base_url = base_url + if (api_key !== undefined) entry.api_key = api_key + if (model !== undefined) entry.model = model + return { data: config, result: true } + }) + if (!found) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + } else { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (!envMapping?.api_key_env) { + ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return + } + if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) } + } + // TODO: Test if provider works without gateway restart + // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} + +export async function remove(ctx: any) { + const poolKey = decodeURIComponent(ctx.params.poolKey) + try { + const profile = requestedProfile(ctx) + const isCustom = poolKey.startsWith('custom:') + const removed = await updateConfigYamlForProfile(profile, async (config) => { + if (isCustom) { + const idx = Array.isArray(config.custom_providers) + ? (config.custom_providers as any[]).findIndex((e: any) => { + return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey + }) + : -1 + if (idx === -1) return { data: config, result: false, write: false } + ;(config.custom_providers as any[]).splice(idx, 1) + } else { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (envMapping?.api_key_env) { + await saveEnvValueForProfile(profile, envMapping.api_key_env, '') + } + if (envMapping?.base_url_env) { + await saveEnvValueForProfile(profile, envMapping.base_url_env, '') + } + } + if (config.model?.provider === poolKey) { + const remaining = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : [] + if (remaining.length > 0) { + const fallbackCp = remaining[0] + const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}` + if (typeof config.model !== 'object' || config.model === null) { config.model = {} } + config.model.default = fallbackCp.model + config.model.provider = fallbackKey + delete config.model.base_url + delete config.model.api_key + } else { + config.model = {} + } + } + return { data: config, result: true } + }) + if (!removed) { + ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return + } + if (!isCustom) { + const envMapping = PROVIDER_ENV_MAP[poolKey] + if (!envMapping) { + ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return + } + } + await clearStoredAuthProvider(profile, poolKey) + // TODO: Test if provider works without gateway restart + // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts new file mode 100644 index 0000000..e058256 --- /dev/null +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -0,0 +1,911 @@ +import * as hermesCli from '../../services/hermes/hermes-cli' +import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getSessionDetailPaginatedFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db' +import { + listSessions as localListSessions, + searchSessions as localSearchSessions, + getSession as localGetSession, + getSessionDetail as localGetSessionDetail, + deleteSession as localDeleteSession, + renameSession as localRenameSession, + createSession as localCreateSession, + addMessages as localAddMessages, + updateSession as localUpdateSession, + updateSessionStats as localUpdateSessionStats, +} from '../../db/hermes/session-store' +import { ExportCompressor } from '../../lib/context-compressor/export-compressor' +import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store' +import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store' +import { getModelContextLength } from '../../services/hermes/model-context' +import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' +import { isPathWithin } from '../../services/hermes/hermes-path' +import { getGroupChatServer } from '../../routes/hermes/group-chat' +import { logger } from '../../services/logger' +import type { ConversationSummary } from '../../services/hermes/conversations' +import { listUserProfiles } from '../../db/hermes/users-store' +import { readConfigYamlForProfile } from '../../services/config-helpers' + +function getPendingDeletedSessionIds(): Set { + return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set() +} + +function filterPendingDeletedSessions(items: T[]): T[] { + const pendingIds = getPendingDeletedSessionIds() + if (pendingIds.size === 0) return items + return items.filter(item => !pendingIds.has(item.id)) +} + +function filterPendingDeletedConversationSummaries(items: ConversationSummary[]): ConversationSummary[] { + return filterPendingDeletedSessions(items) +} + +function requestedProfile(ctx: any): string | undefined { + const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '') + return value || undefined +} + +function explicitProfileFilter(ctx: any): string | undefined { + const value = typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '' + return value || undefined +} + +function allowedProfileSet(ctx: any): Set | null { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return null + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function canAccessProfile(ctx: any, profile: string | null | undefined): boolean { + const allowed = allowedProfileSet(ctx) + return !allowed || allowed.has(profile || 'default') +} + +function filterByAllowedProfiles(ctx: any, items: T[]): T[] { + const allowed = allowedProfileSet(ctx) + if (!allowed) return items + return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default')) +} + +function denySessionAccess(ctx: any, session: any | null | undefined): boolean { + if (!session || canAccessProfile(ctx, session.profile)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` } + return true +} + +interface HermesDeleteResult { + attempted: boolean + deleted: boolean + profile?: string + error?: string +} + +interface BatchDeleteTarget { + id: string + profile?: string | null +} + +interface ProfileDefaultModel { + model: string + provider: string +} + +interface LocalImportMessage { + session_id: string + role: string + content: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + timestamp?: number + token_count?: number | null + finish_reason?: string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null +} + +function hasProfileOnDisk(profile: string): boolean { + return listProfileNamesFromDisk().includes(profile || 'default') +} + +async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise { + const targetProfile = profile || 'default' + if (!hasProfileOnDisk(targetProfile)) { + return { attempted: false, deleted: false, profile: targetProfile } + } + + try { + const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile) + if (!hermesSession) { + return { attempted: false, deleted: false, profile: targetProfile } + } + + const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile) + return { + attempted: true, + deleted, + profile: targetProfile, + error: deleted ? undefined : 'Failed to delete Hermes session', + } + } catch (err: any) { + const message = err?.message || 'Failed to inspect Hermes session' + logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped') + return { attempted: true, deleted: false, profile: targetProfile, error: message } + } +} + +async function getProfileDefaultModel(profile: string): Promise { + try { + const config = await readConfigYamlForProfile(profile) + const modelSection = config?.model + if (modelSection && typeof modelSection === 'object' && !Array.isArray(modelSection)) { + return { + model: String(modelSection.default || '').trim(), + provider: String(modelSection.provider || '').trim(), + } + } + if (typeof modelSection === 'string') { + return { model: modelSection.trim(), provider: '' } + } + } catch (err) { + logger.warn({ err, profile }, 'Hermes Session: failed to read profile default model for import') + } + return { model: '', provider: '' } +} + +function normalizeImportText(value: unknown): string { + if (value == null) return '' + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function normalizeImportNullableText(value: unknown): string | null { + const text = normalizeImportText(value) + return text ? text : null +} + +function normalizeImportToolCalls(value: unknown): any[] | null { + if (!Array.isArray(value)) return null + const calls = value + .map((call: any) => { + const id = String(call?.id || '').trim() + const fn = call?.function && typeof call.function === 'object' ? call.function : {} + const name = String(fn.name || call?.name || '').trim() + if (!id || !name) return null + const rawArgs = fn.arguments ?? call?.arguments ?? {} + const args = typeof rawArgs === 'string' ? rawArgs : normalizeImportText(rawArgs || {}) + return { + id, + type: String(call?.type || 'function'), + function: { name, arguments: args || '{}' }, + } + }) + .filter((call): call is { id: string; type: string; function: { name: string; arguments: string } } => Boolean(call)) + return calls.length > 0 ? calls : null +} + +function buildImportMessages(sessionId: string, messages: any[]): LocalImportMessage[] { + const result: LocalImportMessage[] = [] + const knownToolCallIds = new Set() + + for (const message of messages) { + const role = String(message?.role || '').trim() + if (role !== 'user' && role !== 'assistant' && role !== 'tool') continue + + const toolCalls = role === 'assistant' ? normalizeImportToolCalls(message.tool_calls) : null + if (toolCalls) { + for (const call of toolCalls) knownToolCallIds.add(call.id) + } + + if (role === 'tool') { + const callId = String(message?.tool_call_id || '').trim() + if (!callId || !knownToolCallIds.has(callId)) continue + result.push({ + session_id: sessionId, + role, + content: normalizeImportText(message?.content), + tool_call_id: callId, + tool_calls: null, + tool_name: normalizeImportNullableText(message?.tool_name), + timestamp: Number(message?.timestamp || 0), + token_count: message?.token_count == null ? null : Number(message.token_count), + finish_reason: normalizeImportNullableText(message?.finish_reason), + reasoning: null, + reasoning_details: null, + reasoning_content: null, + }) + continue + } + + const content = normalizeImportText(message?.content) + if (role === 'assistant' && !content.trim() && !toolCalls) continue + + result.push({ + session_id: sessionId, + role, + content, + tool_call_id: null, + tool_calls: toolCalls, + tool_name: null, + timestamp: Number(message?.timestamp || 0), + token_count: message?.token_count == null ? null : Number(message.token_count), + finish_reason: normalizeImportNullableText(message?.finish_reason), + reasoning: role === 'assistant' ? normalizeImportNullableText(message?.reasoning) : null, + reasoning_details: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_details) : null, + reasoning_content: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_content) : null, + }) + } + + return result +} + +export async function listConversations(ctx: any) { + const source = (ctx.query.source as string) || undefined + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + + const profile = explicitProfileFilter(ctx) + const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200) + const summaries: ConversationSummary[] = sessions.map(s => ({ + id: s.id, + profile: s.profile || null, + source: s.source, + model: s.model, + provider: s.provider, + title: s.title, + started_at: s.started_at, + ended_at: s.ended_at, + last_active: s.last_active, + message_count: s.message_count, + tool_call_count: s.tool_call_count, + input_tokens: s.input_tokens, + output_tokens: s.output_tokens, + cache_read_tokens: s.cache_read_tokens, + cache_write_tokens: s.cache_write_tokens, + reasoning_tokens: s.reasoning_tokens, + billing_provider: s.billing_provider, + estimated_cost_usd: s.estimated_cost_usd, + actual_cost_usd: s.actual_cost_usd, + cost_status: s.cost_status, + preview: s.preview, + workspace: s.workspace || null, + is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300, + thread_session_count: 1, + })) + ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) } +} + +export async function getConversationMessages(ctx: any) { + const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0' + + const detail = localGetSessionDetail(ctx.params.id) + if (!detail) { + ctx.status = 404 + ctx.body = { error: 'Conversation not found' } + return + } + if (denySessionAccess(ctx, detail)) return + const messages = detail.messages + .filter(m => { + if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false + if (!m.content) return false + return true + }) + .map(m => ({ + id: m.id, + session_id: m.session_id, + role: m.role as 'user' | 'assistant', + content: m.content, + timestamp: m.timestamp, + })) + ctx.body = { + session_id: ctx.params.id, + messages, + visible_count: messages.length, + thread_session_count: 1, + } +} + +export async function list(ctx: any) { + const source = (ctx.query.source as string) || undefined + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + const profile = explicitProfileFilter(ctx) + const effectiveLimit = limit && limit > 0 ? limit : 2000 + + const allSessions = localListSessions(profile, source, effectiveLimit) + const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk()) + ctx.body = { + sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => + (s.source === 'api_server' || s.source === 'cli') && + (!knownProfiles || knownProfiles.has(s.profile || 'default')), + )), + } +} + +/** + * List Hermes sessions only (exclude api_server source) + * GET /api/hermes/sessions/hermes?source=&limit= + */ +export async function listHermesSessions(ctx: any) { + const source = (ctx.query.source as string) || undefined + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + const profile = requestedProfile(ctx) + const effectiveLimit = limit && limit > 0 ? limit : 2000 + + const importedIds = new Set(localListSessions(profile, undefined, effectiveLimit).map(session => session.id)) + const allSessions = (await listSessionSummaries(source, effectiveLimit, profile)) + .map(session => ({ + ...(profile ? { ...session, profile } : session), + webui_imported: importedIds.has(session.id), + })) + ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) } +} + +export async function search(ctx: any) { + const q = typeof ctx.query.q === 'string' ? ctx.query.q : '' + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + const profile = explicitProfileFilter(ctx) + const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20) + const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk()) + ctx.body = { + results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s => + !knownProfiles || knownProfiles.has(s.profile || 'default'), + )), + } +} + +export async function get(ctx: any) { + const session = localGetSessionDetail(ctx.params.id) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + if (denySessionAccess(ctx, session)) return + ctx.body = { session } +} + +/** + * Get Hermes session detail only (exclude api_server source) + * GET /api/hermes/sessions/hermes/:id + */ +export async function getHermesSession(ctx: any) { + const profile = requestedProfile(ctx) + + // Prefer the Web UI local session store. Hermes state.db can lag behind or + // miss messages for Bridge-backed runs, while the local store is the source + // used by chat rendering and compression. + const localSession = localGetSessionDetail(ctx.params.id) + const localSessionProfile = (localSession?.profile || 'default') as string + if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) { + if (denySessionAccess(ctx, localSession)) return + ctx.body = { session: localSession } + return + } + + // Try Hermes state.db next (consistent with listHermesSessions) + try { + const session = profile + ? await getSessionDetailFromDbWithProfile(ctx.params.id, profile) + : await getSessionDetailFromDb(ctx.params.id) + if (session && session.source !== 'api_server') { + const sessionWithProfile = profile ? { ...session, profile } : session + if (denySessionAccess(ctx, sessionWithProfile)) return + ctx.body = { session: sessionWithProfile } + return + } + } catch (err) { + logger.warn(err, 'Hermes Session DB: detail query failed, falling back to CLI') + } + + // Fallback to CLI + const session = await hermesCli.getSession(ctx.params.id) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + // Filter out api_server sessions + if (session.source === 'api_server') { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + if (denySessionAccess(ctx, session)) return + ctx.body = { session } +} + +export async function importHermesSession(ctx: any) { + const sessionId = ctx.params.id + const profile = requestedProfile(ctx) || getActiveProfileName() + if (!canAccessProfile(ctx, profile)) { + ctx.status = 403 + ctx.body = { error: `Profile "${profile || 'default'}" is not available for this user` } + return + } + + const existing = localGetSessionDetail(sessionId) + if (existing) { + ctx.body = { ok: true, imported: false, session: existing } + return + } + + let detail + try { + detail = await getSessionDetailFromDbWithProfile(sessionId, profile) + } catch (err) { + logger.warn({ err, sessionId, profile }, 'Hermes Session: import query failed') + ctx.status = 500 + ctx.body = { error: 'Failed to read Hermes session' } + return + } + + if (!detail || detail.source === 'api_server') { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + + const profileDefault = await getProfileDefaultModel(profile) + const importTimestamp = Math.floor(Date.now() / 1000) + + localCreateSession({ + id: detail.id, + profile, + source: 'cli', + model: profileDefault.model, + provider: profileDefault.provider, + title: detail.title || undefined, + }) + + localUpdateSession(detail.id, { + source: 'cli', + user_id: detail.user_id, + model: profileDefault.model, + provider: profileDefault.provider, + title: detail.title, + started_at: detail.started_at, + ended_at: detail.ended_at, + end_reason: detail.end_reason, + message_count: detail.message_count, + tool_call_count: detail.tool_call_count, + input_tokens: detail.input_tokens, + output_tokens: detail.output_tokens, + cache_read_tokens: detail.cache_read_tokens, + cache_write_tokens: detail.cache_write_tokens, + reasoning_tokens: detail.reasoning_tokens, + billing_provider: detail.billing_provider, + estimated_cost_usd: detail.estimated_cost_usd, + actual_cost_usd: detail.actual_cost_usd, + cost_status: detail.cost_status, + preview: detail.preview, + last_active: importTimestamp, + }) + + const importMessages = buildImportMessages(detail.id, Array.isArray(detail.messages) ? detail.messages : []) + localAddMessages(importMessages) + localUpdateSessionStats(detail.id) + localUpdateSession(detail.id, { + tool_call_count: detail.tool_call_count, + input_tokens: detail.input_tokens, + output_tokens: detail.output_tokens, + cache_read_tokens: detail.cache_read_tokens, + cache_write_tokens: detail.cache_write_tokens, + reasoning_tokens: detail.reasoning_tokens, + billing_provider: detail.billing_provider, + estimated_cost_usd: detail.estimated_cost_usd, + actual_cost_usd: detail.actual_cost_usd, + cost_status: detail.cost_status, + last_active: importTimestamp, + ended_at: detail.ended_at, + }) + + ctx.body = { ok: true, imported: true, session: localGetSessionDetail(detail.id) } +} + +export async function remove(ctx: any) { + const sessionId = ctx.params.id + const existing = localGetSession(sessionId) + if (denySessionAccess(ctx, existing)) return + const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName() + const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile) + const localDeleted = existing ? localDeleteSession(sessionId) : true + if (!localDeleted) { + ctx.status = 500 + ctx.body = { error: 'Failed to delete session' } + return + } + deleteUsage(sessionId) + ctx.body = { ok: true, deleted: Boolean(existing), hermes } +} + +export async function batchRemove(ctx: any) { + const { ids, sessions } = ctx.request.body as { ids?: string[]; sessions?: BatchDeleteTarget[] } + const rawTargets = Array.isArray(sessions) && sessions.length > 0 ? sessions : ids + if (!rawTargets || !Array.isArray(rawTargets) || rawTargets.length === 0) { + ctx.status = 400 + ctx.body = { error: 'ids is required and must be a non-empty array' } + return + } + + const targets = rawTargets + .map((target): BatchDeleteTarget | null => { + if (typeof target === 'string') { + const id = target.trim() + return id ? { id } : null + } + if (!target || typeof target.id !== 'string') return null + const id = target.id.trim() + if (!id) return null + const profile = typeof target.profile === 'string' && target.profile.trim() + ? target.profile.trim() + : undefined + return { id, profile } + }) + .filter((target): target is BatchDeleteTarget => Boolean(target)) + + if (targets.length === 0) { + ctx.status = 400 + ctx.body = { error: 'No valid session ids provided' } + return + } + + const results = { + deleted: 0, + failed: 0, + hermesDeleted: 0, + hermesFailed: 0, + errors: [] as Array<{ id: string; error: string }>, + hermesErrors: [] as Array<{ id: string; profile?: string; error: string }> + } + + for (const target of targets) { + const { id } = target + const existing = localGetSession(id) + const targetProfile = target.profile || existing?.profile + if (targetProfile && !canAccessProfile(ctx, targetProfile)) { + results.failed++ + results.errors.push({ id, error: `Profile "${targetProfile || 'default'}" is not available for this user` }) + continue + } + if (!targetProfile && existing && !canAccessProfile(ctx, existing.profile)) { + results.failed++ + results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` }) + continue + } + + const hermes = await deleteHermesSessionIfPresent(id, targetProfile) + if (hermes.deleted) { + results.hermesDeleted++ + } else if (hermes.attempted && hermes.error) { + results.hermesFailed++ + results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error }) + } + + const shouldDeleteLocal = Boolean(existing && (!targetProfile || existing.profile === targetProfile)) + if (shouldDeleteLocal) { + const ok = localDeleteSession(id) + if (ok) { + deleteUsage(id) + results.deleted++ + } else { + results.failed++ + results.errors.push({ id, error: 'Failed to delete session' }) + } + } else if (hermes.deleted) { + results.deleted++ + } else { + results.failed++ + results.errors.push({ id, error: 'Session not found' }) + } + } + + ctx.body = { ...results, ok: true } +} + +export async function usageBatch(ctx: any) { + const ids = (ctx.query.ids as string) + if (!ids) { + ctx.body = {} + return + } + const idList = ids.split(',').filter(Boolean) + ctx.body = getUsageBatch(idList) +} + +export async function usageSingle(ctx: any) { + const session = localGetSession(ctx.params.id) + if (denySessionAccess(ctx, session)) return + const result = getUsage(ctx.params.id) + if (!result) { + ctx.body = { input_tokens: 0, output_tokens: 0 } + return + } + ctx.body = result +} + +export async function rename(ctx: any) { + const { title } = ctx.request.body as { title?: string } + if (!title || typeof title !== 'string') { + ctx.status = 400 + ctx.body = { error: 'title is required' } + return + } + const existing = localGetSession(ctx.params.id) + if (denySessionAccess(ctx, existing)) return + const ok = localRenameSession(ctx.params.id, title.trim()) + if (!ok) { + ctx.status = 500 + ctx.body = { error: 'Failed to rename session' } + return + } + ctx.body = { ok: true } +} + +export async function setWorkspace(ctx: any) { + const { workspace } = ctx.request.body as { workspace?: string } + if (workspace !== undefined && workspace !== null && typeof workspace !== 'string') { + ctx.status = 400 + ctx.body = { error: 'workspace must be a string or null' } + return + } + const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store') + const id = ctx.params.id + const existing = getSession(id) + if (denySessionAccess(ctx, existing)) return + if (!existing) { + createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' }) + } + updateSession(id, { workspace: workspace || null } as any) + ctx.body = { ok: true } +} + +export async function setModel(ctx: any) { + const { model, provider } = ctx.request.body as { model?: string; provider?: string } + if (!model || typeof model !== 'string') { + ctx.status = 400 + ctx.body = { error: 'model is required' } + return + } + if (provider !== undefined && provider !== null && typeof provider !== 'string') { + ctx.status = 400 + ctx.body = { error: 'provider must be a string' } + return + } + const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store') + const id = ctx.params.id + const existing = getSession(id) + if (denySessionAccess(ctx, existing)) return + if (!existing) { + createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' }) + } + updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any) + ctx.body = { ok: true } +} + +export async function contextLength(ctx: any) { + const profile = requestedProfile(ctx) + const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined + const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined + ctx.body = { context_length: getModelContextLength({ profile, model, provider }) } +} + +export async function usageStats(ctx: any) { + const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10) + const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30 + const profile = requestedProfile(ctx) + + let hermes = { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + sessions: 0, + by_model: [] as UsageStatsModelRow[], + by_day: [] as UsageStatsDailyRow[], + cost: 0, + total_api_calls: 0, + } + + try { + hermes = profile ? await getUsageStatsFromDb(days, undefined, profile) : await getUsageStatsFromDb(days) + } catch (err) { + logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db') + } + + const dayMap = new Map() + const now = new Date() + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now) + d.setDate(d.getDate() - i) + const key = d.toISOString().slice(0, 10) + dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 }) + } + for (const d of hermes.by_day) { + const existing = dayMap.get(d.date) + if (existing) { + existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens + existing.cache_read_tokens += d.cache_read_tokens; existing.cache_write_tokens += d.cache_write_tokens + existing.sessions += d.sessions; existing.errors += d.errors; existing.cost += d.cost + } + } + + ctx.body = { + total_input_tokens: hermes.input_tokens, + total_output_tokens: hermes.output_tokens, + total_cache_read_tokens: hermes.cache_read_tokens, + total_cache_write_tokens: hermes.cache_write_tokens, + total_reasoning_tokens: hermes.reasoning_tokens, + total_sessions: hermes.sessions, + total_cost: hermes.cost, + total_api_calls: hermes.total_api_calls, + period_days: days, + model_usage: hermes.by_model.sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)), + daily_usage: [...dayMap.values()], + } +} + +/** + * List folders under workspace base path for folder picker. + * GET /api/hermes/workspace/folders?path= + * Base: /opt/data/workspace (overridable via WORKSPACE_BASE env) + */ +export async function listWorkspaceFolders(ctx: any) { + const { resolve, join } = await import('path') + const { readdir } = await import('fs/promises') + const { existsSync } = await import('fs') + + const WORKSPACE_BASE = process.env.WORKSPACE_BASE || '/opt/data/workspace' + const subPath = (ctx.query.path as string) || '' + + // Security: prevent path traversal + const fullPath = resolve(join(WORKSPACE_BASE, subPath)) + if (!isPathWithin(fullPath, WORKSPACE_BASE)) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return + } + + if (!existsSync(fullPath)) { + ctx.status = 404 + ctx.body = { error: 'Path not found', folders: [] } + return + } + + try { + const entries = await readdir(fullPath, { withFileTypes: true }) + const folders = entries + .filter(e => e.isDirectory() && !e.name.startsWith('.')) + .map(e => ({ + name: e.name, + path: subPath ? `${subPath}/${e.name}` : e.name, + fullPath: join(fullPath, e.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + ctx.body = { base: WORKSPACE_BASE, current: subPath, folders } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +const exportCompressor = new ExportCompressor() + +export async function exportSession(ctx: any) { + const session = localGetSessionDetail(ctx.params.id) + + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + if (denySessionAccess(ctx, session)) return + + const mode = (ctx.query.mode as string) || 'full' + const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json') + const title = session.title || 'session' + const safeName = title.replace(/[^a-zA-Z0-9一-鿿_-]/g, '_').slice(0, 50) + const filename = `${safeName}_${ctx.params.id.slice(0, 8)}.${ext}` + + if (mode === 'compressed') { + const result = await compressSession(session) + if (ext === 'json') { + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + ctx.set('Content-Type', 'application/json') + ctx.body = JSON.stringify({ id: session.id, title: session.title, ...result.meta, messages: result.messages }, null, 2) + } else { + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + ctx.set('Content-Type', 'text/plain; charset=utf-8') + ctx.body = serializeAsText(session.title, result.messages) + } + } else { + if (ext === 'txt') { + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + ctx.set('Content-Type', 'text/plain; charset=utf-8') + ctx.body = serializeAsText(session.title, session.messages || []) + } else { + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + ctx.set('Content-Type', 'application/json') + ctx.body = JSON.stringify(session, null, 2) + } + } +} + +async function compressSession(session: any) { + const profile = session.profile || getActiveProfileName() + const upstream = '' + const apiKey = undefined + const messages = (session.messages || []).map((m: any) => ({ + role: m.role, + content: m.content || '', + tool_calls: m.tool_calls, + tool_call_id: m.tool_call_id, + name: m.tool_name, + reasoning_content: m.reasoning, + })) + + return exportCompressor.compress(messages, upstream, apiKey, session.id, { + profile, + model: session.model, + provider: session.provider, + }) +} + +function serializeAsText(title: string | null, messages: any[]): string { + const lines: string[] = [`# ${title || 'Untitled'}`, ''] + for (const msg of messages) { + const role = msg.role || 'unknown' + const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + const ts = msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : '' + lines.push(`[${role}]${ts ? ' ' + ts : ''}`) + lines.push(content || '') + lines.push('') + } + return lines.join('\n') +} + +export async function getConversationMessagesPaginated(ctx: any) { + const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0 + const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50 + const profile = requestedProfile(ctx) + + const { getSessionDetailPaginated } = await import('../../db/hermes/session-store') + const localResult = getSessionDetailPaginated(ctx.params.id, offset, limit) + const result = localResult && (!profile || localResult.session.profile === profile) + ? localResult + : await getSessionDetailPaginatedFromDbWithProfile(ctx.params.id, profile || 'default', offset, limit) + + if (!result) { + ctx.status = 404 + ctx.body = { error: 'Conversation not found' } + return + } + const session = { ...result.session, profile: (result.session as any).profile || profile || 'default' } + if (denySessionAccess(ctx, session)) return + + ctx.body = { + session: { + id: session.id, + profile: session.profile, + source: session.source, + model: session.model, + title: session.title, + started_at: session.started_at, + ended_at: session.ended_at, + last_active: session.last_active, + message_count: session.message_count, + input_tokens: session.input_tokens, + output_tokens: session.output_tokens, + }, + messages: result.messages, + total: result.total, + offset: result.offset, + limit: result.limit, + hasMore: result.hasMore, + } +} diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts new file mode 100644 index 0000000..2208457 --- /dev/null +++ b/packages/server/src/controllers/hermes/skills.ts @@ -0,0 +1,548 @@ +import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { join, resolve } from 'path' +import { createHash } from 'crypto' +import { + readConfigYamlForProfile, updateConfigYamlForProfile, + safeReadFile, extractDescription, listFilesRecursive, +} from '../../services/config-helpers' +import type { SkillSource } from '../../services/config-helpers' +import { isPathWithin } from '../../services/hermes/hermes-path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db' + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function requestProfileDir(ctx: any): string { + return getProfileDir(requestedProfile(ctx)) +} + +function requestSkillsDir(ctx: any): string { + return join(requestProfileDir(ctx), 'skills') +} + +function expandConfiguredPath(value: string): string { + const expandedEnv = value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => { + return process.env[braced || bare] || '' + }) + if (expandedEnv === '~') return homedir() + if (expandedEnv.startsWith('~/')) return join(homedir(), expandedEnv.slice(2)) + return expandedEnv +} + +async function resolveExternalSkillsDirs(config: Record, localSkillsDir: string): Promise { + const rawDirs = config.skills?.external_dirs + const entries = typeof rawDirs === 'string' + ? [rawDirs] + : Array.isArray(rawDirs) + ? rawDirs + : [] + const localResolved = resolve(localSkillsDir) + const seen = new Set() + const dirs: string[] = [] + + for (const rawEntry of entries) { + const entry = String(rawEntry || '').trim() + if (!entry) continue + const expanded = expandConfiguredPath(entry) + const resolved = resolve(expanded) + if (resolved === localResolved || seen.has(resolved)) continue + try { + const info = await stat(resolved) + if (!info.isDirectory()) continue + } catch { + continue + } + seen.add(resolved) + dirs.push(resolved) + } + + return dirs +} + +/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */ +function readBundledManifest(manifestContent: string | null): Map { + const map = new Map() + if (!manifestContent) return map + for (const line of manifestContent.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const idx = trimmed.indexOf(':') + if (idx === -1) continue + const name = trimmed.slice(0, idx).trim() + const hash = trimmed.slice(idx + 1).trim() + if (name && hash) map.set(name, hash) + } + return map +} + +/** Read hub-installed skill names from ~/.hermes/skills/.hub/lock.json */ +function readHubInstalledNames(lockContent: string | null): Set { + if (!lockContent) return new Set() + try { + const data = JSON.parse(lockContent) + if (data?.installed && typeof data.installed === 'object') { + return new Set(Object.keys(data.installed)) + } + } catch { /* ignore */ } + return new Set() +} + +/** Compute md5 hash of all files in a directory (mirrors Hermes _dir_hash), with in-memory cache */ +const hashCache = new Map() +const HASH_CACHE_TTL = 60_000 // 1 minute + +async function dirHash(directory: string): Promise { + const cached = hashCache.get(directory) + if (cached && Date.now() - cached.mtime < HASH_CACHE_TTL) return cached.hash + + const hasher = createHash('md5') + const files = await listFilesRecursive(directory, '') + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + for (const f of files) { + hasher.update(f.path) + const content = await readFile(join(directory, f.path)) + hasher.update(content) + } + const hash = hasher.digest('hex') + hashCache.set(directory, { hash, mtime: Date.now() }) + return hash +} + +/** Determine the source type of a skill */ +function getSkillSource( + dirName: string, + bundledManifest: Map, + hubNames: Set, +): SkillSource { + if (bundledManifest.has(dirName)) return 'builtin' + if (hubNames.has(dirName)) return 'hub' + return 'local' +} + +/** Read .usage.json as a name→stats map */ +interface UsageStats { patch_count: number; use_count: number; view_count: number; pinned: boolean } +function readUsageStats(usageContent: string | null): Map { + const map = new Map() + if (!usageContent) return map + try { + const data = JSON.parse(usageContent) + for (const [name, stats] of Object.entries(data)) { + const s = stats as any + map.set(name, { patch_count: s.patch_count ?? 0, use_count: s.use_count ?? 0, view_count: s.view_count ?? 0, pinned: !!s.pinned }) + } + } catch { /* ignore */ } + return map +} + +async function findSkillDirByName(rootDir: string, skillName: string): Promise { + let entries: import('fs').Dirent[] + try { + entries = await readdir(rootDir, { withFileTypes: true }) + } catch { + return null + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + + const entryPath = join(rootDir, entry.name) + const skillMd = await safeReadFile(join(entryPath, 'SKILL.md')) + if (skillMd !== null) { + if (entry.name === skillName) return entryPath + // This is another skill root. Do not search inside its references/scripts. + continue + } + + const found = await findSkillDirByName(entryPath, skillName) + if (found) return found + } + + return null +} + +async function findSkillDirInRoot(rootDir: string, category: string, skillName: string): Promise { + if (category === 'misc') { + const skillDir = join(rootDir, skillName) + const skillMd = await safeReadFile(join(skillDir, 'SKILL.md')) + return skillMd !== null ? skillDir : null + } + return findSkillDirByName(join(rootDir, category), skillName) +} + +async function resolveSkillDirFromConfig( + config: Record, + localSkillsDir: string, + category: string, + skillName: string, +): Promise { + const localSkillDir = await findSkillDirInRoot(localSkillsDir, category, skillName) + if (localSkillDir) return localSkillDir + + for (const externalDir of await resolveExternalSkillsDirs(config, localSkillsDir)) { + const externalSkillDir = await findSkillDirInRoot(externalDir, category, skillName) + if (externalSkillDir) return externalSkillDir + } + return null +} + +/** + * Scan for skills at different directory depths. + * + * Supports both: + * - Three-level: skills///SKILL.md (category is a container) + * - Two-level: skills//SKILL.md (flat skill under "misc" category) + * + * Categories are identified by having a DESCRIPTION.md at the category level + * or by containing subdirectories with SKILL.md (three-level pattern). + * Skills without a parent category (flat skills) are grouped under the "misc" category. + */ +async function scanSkillsDir(skillsDir: string, bundledManifest: Map, hubNames: Set, disabledList: string[], usageStats: Map) { + const allEntries = await readdir(skillsDir, { withFileTypes: true }) + const dirNames = allEntries + .filter(e => e.isDirectory() && !e.name.startsWith('.')) + .map(e => e.name) + + // Classify directories: categories vs. flat skills + const categoryDirs: { name: string; description: string }[] = [] + const flatSkills: { name: string; skillMd: string; source: string }[] = [] + + for (const dirName of dirNames) { + const catDir = join(skillsDir, dirName) + const hasDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) + const hasSkillMd = await safeReadFile(join(catDir, 'SKILL.md')) + const subEntries = await readdir(catDir, { withFileTypes: true }) + const subDirs = subEntries.filter(se => se.isDirectory()) + + // Priority: SKILL.md at top level → flat skill + // DESCRIPTION.md or subdirs (without SKILL.md) → category + if (hasSkillMd) { + // Flat skill: has SKILL.md at the top level (two-level pattern) + // Could also have subdirectories (references/, scripts/, etc.) + flatSkills.push({ + name: dirName, + skillMd: hasSkillMd, + source: getSkillSource(dirName, bundledManifest, hubNames), + }) + } else if (!!hasDesc || subDirs.length > 0) { + // True category: has DESCRIPTION.md or subdirs, but no SKILL.md at top level + const catDescription = hasDesc ? hasDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' + categoryDirs.push({ name: dirName, description: catDescription }) + } + } + + // Build categories with their nested skills + const categories: any[] = [] + + for (const cat of categoryDirs) { + const catDir = join(skillsDir, cat.name) + const subEntries = await readdir(catDir, { withFileTypes: true }) + const skills: any[] = [] + // Recursively collect skills from subdirectories (supports nested sub-categories) + async function collectSkills(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const results: any[] = [] + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const entryPath = join(dir, entry.name) + const skillMd = await safeReadFile(join(entryPath, 'SKILL.md')) + if (skillMd) { + const source = getSkillSource(entry.name, bundledManifest, hubNames) + let modified = false + if (source === 'builtin') { + const manifestHash = bundledManifest.get(entry.name) + if (manifestHash) { + const currentHash = await dirHash(entryPath) + modified = currentHash !== manifestHash + } + } + const usage = usageStats.get(entry.name) + results.push({ + name: entry.name, + description: extractDescription(skillMd), + enabled: !disabledList.includes(entry.name), + source, + modified: modified || undefined, + patchCount: usage?.patch_count, + useCount: usage?.use_count, + viewCount: usage?.view_count, + pinned: usage?.pinned || undefined, + }) + } else { + // No SKILL.md — might be a sub-category container, recurse deeper + const subResults = await collectSkills(entryPath) + results.push(...subResults) + } + } + return results + } + skills.push(...await collectSkills(catDir)) + if (skills.length > 0) { + categories.push({ name: cat.name, description: cat.description, skills }) + } + } + + // Group flat skills into a "misc" (雜項) category + if (flatSkills.length > 0) { + const miscSkills: any[] = [] + for (const fs of flatSkills) { + const usage = usageStats.get(fs.name) + miscSkills.push({ + name: fs.name, + description: extractDescription(fs.skillMd), + enabled: !disabledList.includes(fs.name), + source: fs.source, + modified: undefined, + patchCount: usage?.patch_count, + useCount: usage?.use_count, + viewCount: usage?.view_count, + pinned: usage?.pinned || undefined, + }) + } + miscSkills.sort((a: any, b: any) => a.name.localeCompare(b.name)) + categories.push({ + name: 'misc', + description: '雜項', + skills: miscSkills, + }) + } + + categories.sort((a, b) => a.name.localeCompare(b.name)) + for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) } + return categories +} + +async function scanExternalSkillsDir(skillsDir: string, disabledList: string[], usageStats: Map) { + return scanSkillsDir(skillsDir, new Map(), new Set(), disabledList, usageStats).then(categories => + categories.map(category => ({ + ...category, + skills: category.skills.map((skill: any) => ({ + ...skill, + source: 'external' as SkillSource, + modified: undefined, + })), + })), + ) +} + +function collectSkillNames(categories: any[]): Set { + const names = new Set() + for (const category of categories) { + for (const skill of category.skills || []) { + if (skill?.name) names.add(skill.name) + } + } + return names +} + +function mergeExternalCategories(categories: any[], externalCategories: any[]): any[] { + const byName = new Map() + for (const category of categories) { + byName.set(category.name, { ...category, skills: [...category.skills] }) + } + + const seenSkills = collectSkillNames(categories) + for (const externalCategory of externalCategories) { + const target = byName.get(externalCategory.name) || { + name: externalCategory.name, + description: externalCategory.description, + skills: [], + } + for (const skill of externalCategory.skills || []) { + if (seenSkills.has(skill.name)) continue + seenSkills.add(skill.name) + target.skills.push(skill) + } + if (target.skills.length > 0) byName.set(target.name, target) + } + + const merged = [...byName.values()] + .filter(category => category.skills.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + for (const category of merged) { + category.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) + } + return merged +} + +export async function list(ctx: any) { + const skillsDir = requestSkillsDir(ctx) + try { + const config = await readConfigYamlForProfile(requestedProfile(ctx)) + const disabledList: string[] = config.skills?.disabled || [] + + // Read provenance sources + const bundledManifest = readBundledManifest(await safeReadFile(join(skillsDir, '.bundled_manifest'))) + const hubNames = readHubInstalledNames(await safeReadFile(join(skillsDir, '.hub', 'lock.json'))) + const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json'))) + + // Scan all skills (supports both two-level and three-level directory structures) + let categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats) + for (const externalDir of await resolveExternalSkillsDirs(config, skillsDir)) { + const externalCategories = await scanExternalSkillsDir(externalDir, disabledList, usageStats) + categories = mergeExternalCategories(categories, externalCategories) + } + + // Read archived skills from .archive/ + const archived: any[] = [] + const archiveDir = join(skillsDir, '.archive') + const archiveEntries = await readdir(archiveDir, { withFileTypes: true }).catch(() => [] as import('fs').Dirent[]) + for (const entry of archiveEntries) { + if (!entry.isDirectory()) continue + const skillMd = await safeReadFile(join(archiveDir, entry.name, 'SKILL.md')) + if (skillMd) { + const usage = usageStats.get(entry.name) + archived.push({ + name: entry.name, + description: extractDescription(skillMd), + source: getSkillSource(entry.name, bundledManifest, hubNames), + patchCount: usage?.patch_count, + useCount: usage?.use_count, + viewCount: usage?.view_count, + pinned: usage?.pinned || undefined, + }) + } + } + archived.sort((a: any, b: any) => a.name.localeCompare(b.name)) + + ctx.body = { categories, archived } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: `Failed to read skills directory: ${err.message}` } + } +} + +export async function usageStats(ctx: any) { + const rawDays = parseInt(String(ctx.query?.days ?? '7'), 10) + const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 7 + + try { + ctx.body = await getSkillUsageStatsFromDb(days, undefined, requestedProfile(ctx)) + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: `Failed to read skill usage stats: ${err.message}` } + } +} + +export async function toggle(ctx: any) { + const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean } + if (!name || typeof enabled !== 'boolean') { + ctx.status = 400 + ctx.body = { error: 'Missing name or enabled flag' } + return + } + try { + await updateConfigYamlForProfile(requestedProfile(ctx), (config) => { + if (!config.skills) config.skills = {} + if (!Array.isArray(config.skills.disabled)) config.skills.disabled = [] + const disabled = config.skills.disabled as string[] + const idx = disabled.indexOf(name) + if (enabled) { if (idx !== -1) disabled.splice(idx, 1) } + else { if (idx === -1) disabled.push(name) } + return config + }) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function listFiles(ctx: any) { + const { category, skill } = ctx.params + const profileSkillsDir = requestSkillsDir(ctx) + try { + const config = await readConfigYamlForProfile(requestedProfile(ctx)) + const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skill) + if (!skillDir) { + ctx.status = 404 + ctx.body = { error: 'Skill not found' } + return + } + const allFiles = await listFilesRecursive(skillDir, '') + const files = allFiles.filter((f: any) => f.path !== 'SKILL.md') + ctx.body = { files } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function readFile_(ctx: any) { + const filePath = (ctx.params as any).path + const profileSkillsDir = requestSkillsDir(ctx) + // Handle 'misc' category: real skill dir is skills/, not skills/misc/ + let realPath = filePath + if (filePath.startsWith('misc/')) { + realPath = filePath.slice(5) + } + const fullPath = resolve(join(profileSkillsDir, realPath)) + if (!isPathWithin(fullPath, profileSkillsDir)) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return + } + let content = await safeReadFile(fullPath) + if (content === null) { + // Fallback: recursive search for nested skills (e.g., mlops/lm-evaluation-harness/SKILL.md + // where actual path is mlops/evaluation/lm-evaluation-harness/SKILL.md) + const parts = filePath.split('/') + if (parts.length >= 2) { + const category = parts[0] + const skillName = parts[1] + const restPath = parts.slice(2).join('/') + const config = await readConfigYamlForProfile(requestedProfile(ctx)) + const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skillName) + if (skillDir) { + const resolvedPath = resolve(join(skillDir, restPath)) + if (isPathWithin(resolvedPath, skillDir)) { + const nestedContent = await safeReadFile(resolvedPath) + if (nestedContent !== null) { + ctx.body = { content: nestedContent } + return + } + } + } + } + ctx.status = 404 + ctx.body = { error: 'File not found' } + return + } + ctx.body = { content } +} + +async function updatePinnedSkill(skillsDir: string, name: string, pinned: boolean): Promise { + await mkdir(skillsDir, { recursive: true }) + const usagePath = join(skillsDir, '.usage.json') + let usage: Record = {} + const raw = await safeReadFile(usagePath) + if (raw) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) usage = parsed + } catch { /* rewrite malformed usage file with the requested pin state */ } + } + const current = usage[name] + usage[name] = current && typeof current === 'object' && !Array.isArray(current) + ? { ...current, pinned } + : { patch_count: 0, use_count: 0, view_count: 0, pinned } + await writeFile(usagePath, `${JSON.stringify(usage, null, 2)}\n`, 'utf-8') +} + +export async function pin_(ctx: any) { + const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean } + if (!name || typeof pinned !== 'boolean') { + ctx.status = 400 + ctx.body = { error: 'Missing name or pinned flag' } + return + } + try { + await updatePinnedSkill(requestSkillsDir(ctx), name, pinned) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/tts.ts b/packages/server/src/controllers/hermes/tts.ts new file mode 100644 index 0000000..6f980fb --- /dev/null +++ b/packages/server/src/controllers/hermes/tts.ts @@ -0,0 +1,70 @@ +import type { Context } from 'koa' +import { textToSpeech, openaiCompatibleTts, speedToEdgeRate } from '../../services/hermes/tts' + +export async function generate(ctx: Context) { + const { text, lang } = ctx.request.body as { + text?: string + lang?: string + } + + if (!text || typeof text !== 'string') { + ctx.status = 400 + ctx.body = { error: 'text is required' } + return + } + + if (text.length > 5000) { + ctx.status = 400 + ctx.body = { error: 'text is too long (max 5000 characters)' } + return + } + + const { audio, engine } = await textToSpeech({ text, lang }) + + ctx.set('Content-Type', 'audio/mpeg') + ctx.set('Content-Length', String(audio.length)) + ctx.set('X-TTS-Engine', engine) + ctx.body = audio +} + +/** + * OpenAI-compatible TTS endpoint. + * Accepts: { model, input, voice, speed } + * Returns audio/mpeg stream. + */ +export async function openaiProxy(ctx: Context) { + const body = ctx.request.body as { + input?: string + voice?: string + speed?: number + model?: string + rate?: string + pitch?: string + } + + if (!body.input || typeof body.input !== 'string') { + ctx.status = 400 + ctx.body = { error: 'input is required' } + return + } + + if (body.input.length > 5000) { + ctx.status = 400 + ctx.body = { error: 'input is too long (max 5000 characters)' } + return + } + + const { audio, engine } = await openaiCompatibleTts({ + input: body.input, + voice: body.voice, + speed: body.speed, + model: body.model, + rate: body.rate, + pitch: body.pitch, + }) + + ctx.set('Content-Type', 'audio/mpeg') + ctx.set('Content-Length', String(audio.length)) + ctx.set('X-TTS-Engine', engine) + ctx.body = audio +} diff --git a/packages/server/src/controllers/hermes/weixin.ts b/packages/server/src/controllers/hermes/weixin.ts new file mode 100644 index 0000000..6fbf10d --- /dev/null +++ b/packages/server/src/controllers/hermes/weixin.ts @@ -0,0 +1,55 @@ +import axios from 'axios' +import { getActiveProfileName } from '../../services/hermes/hermes-profile' +import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart' +import { saveEnvValueForProfile } from '../../services/config-helpers' + +const ILINK_BASE = 'https://ilinkai.weixin.qq.com' + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +export async function getQrcode(ctx: any) { + try { + const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, { params: { bot_type: 3 }, timeout: 15000 }) + const data = res.data + if (!data || !data.qrcode) { ctx.status = 500; ctx.body = { error: 'Failed to get QR code' }; return } + ctx.body = { qrcode: data.qrcode, qrcode_url: data.qrcode_img_content } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message || 'Failed to connect to iLink API' } + } +} + +export async function pollStatus(ctx: any) { + const qrcode = ctx.query.qrcode as string + if (!qrcode) { ctx.status = 400; ctx.body = { error: 'Missing qrcode parameter' }; return } + try { + const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, { params: { qrcode }, timeout: 35000 }) + const data = res.data + const status = data?.status || 'wait' + if (status === 'confirmed') { + ctx.body = { status: 'confirmed', account_id: data.ilink_bot_id, token: data.bot_token, base_url: data.baseurl } + } else { + ctx.body = { status } + } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message || 'Failed to poll QR status' } + } +} + +export async function save(ctx: any) { + const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string } + if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return } + try { + const profile = requestedProfile(ctx) + const entries: Record = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token } + if (base_url) entries.WEIXIN_BASE_URL = base_url + for (const [key, val] of Object.entries(entries)) { + await saveEnvValueForProfile(profile, key, val) + } + await restartGatewayForProfile(profile) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500; ctx.body = { error: err.message } + } +} diff --git a/packages/server/src/controllers/hermes/xai-auth.ts b/packages/server/src/controllers/hermes/xai-auth.ts new file mode 100644 index 0000000..0e5e2e9 --- /dev/null +++ b/packages/server/src/controllers/hermes/xai-auth.ts @@ -0,0 +1,369 @@ +import { createHash, randomBytes, randomUUID } from 'crypto' +import { createServer, type Server } from 'http' +import { request as httpsRequest, type RequestOptions } from 'https' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { URL } from 'url' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { logger } from '../../services/logger' +import { updateConfigYamlForProfile } from '../../services/config-helpers' + +const XAI_OAUTH_ISSUER = 'https://auth.x.ai' +const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration` +const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828' +const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access' +const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1' +const XAI_REDIRECT_HOST = '127.0.0.1' +const XAI_CALLBACK_BIND_HOST = process.env.HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST?.trim() || XAI_REDIRECT_HOST +const XAI_REDIRECT_PORT = 56121 +const XAI_REDIRECT_PATH = '/callback' +const POLL_MAX_DURATION = 15 * 60 * 1000 +const XAI_DEFAULT_MODEL = 'grok-4.3' + +interface XaiSession { + id: string + profile: string + status: 'pending' | 'approved' | 'expired' | 'error' + authorizeUrl: string + redirectUri: string + codeVerifier: string + state: string + tokenEndpoint: string + discovery: Record + server: Server + error?: string + createdAt: number +} + +interface AuthJson { + version?: number + active_provider?: string + providers?: Record + credential_pool?: Record + updated_at?: string +} + +const sessions = new Map() + +export function applyXaiOAuthDefaultModel(config: Record): Record { + if (typeof config.model !== 'object' || config.model === null) config.model = {} + const currentDefault = String(config.model.default || '').trim() + config.model.provider = 'xai-oauth' + config.model.default = currentDefault.toLowerCase().startsWith('grok-') + ? currentDefault + : XAI_DEFAULT_MODEL + delete config.model.base_url + delete config.model.api_key + return config +} + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((session, id) => { + if (now - session.createdAt > POLL_MAX_DURATION + 60000) { + closeServer(session) + sessions.delete(id) + } + }) +} + +function closeServer(session: XaiSession) { + try { session.server.close() } catch {} +} + +function base64Url(input: Buffer): string { + return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function makeCodeVerifier(): string { + return base64Url(randomBytes(48)) +} + +function makeCodeChallenge(verifier: string): string { + return base64Url(createHash('sha256').update(verifier).digest()) +} + +function validateXaiEndpoint(raw: string, field: string): string { + const url = new URL(raw) + if (url.protocol !== 'https:') throw new Error(`xAI discovery returned non-HTTPS ${field}`) + const host = url.hostname.toLowerCase() + if (host !== 'x.ai' && !host.endsWith('.x.ai')) { + throw new Error(`xAI discovery ${field} host is not on x.ai`) + } + return raw +} + +async function requestJson(url: string, options: { + method?: string + headers?: Record + body?: string + timeoutMs?: number +} = {}): Promise<{ status: number; text: string; json: any }> { + const target = new URL(url) + const timeoutMs = options.timeoutMs || 15000 + const body = options.body || '' + const headers: Record = { + Accept: 'application/json', + ...(options.headers || {}), + } + if (body && !headers['Content-Length']) headers['Content-Length'] = Buffer.byteLength(body).toString() + + const requestOptions: RequestOptions = { + hostname: target.hostname, + port: Number(target.port || 443), + path: `${target.pathname}${target.search}`, + method: options.method || 'GET', + headers, + timeout: timeoutMs, + } + + return await new Promise((resolve, reject) => { + const req = httpsRequest(requestOptions, (res) => { + const chunks: Buffer[] = [] + res.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf-8') + let json: any = null + try { json = text ? JSON.parse(text) : null } catch {} + resolve({ status: res.statusCode || 0, text, json }) + }) + }) + req.once('timeout', () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`))) + req.once('error', reject) + if (body) req.write(body) + req.end() + }) +} + +async function discoverXai(): Promise> { + const res = await requestJson(XAI_OAUTH_DISCOVERY_URL, { timeoutMs: 15000 }) + if (res.status < 200 || res.status >= 300) throw new Error(`xAI discovery failed: ${res.status}`) + const payload = res.json as Record + if (!payload || typeof payload !== 'object') throw new Error('xAI discovery returned invalid JSON') + const authorizationEndpoint = String(payload.authorization_endpoint || '').trim() + const tokenEndpoint = String(payload.token_endpoint || '').trim() + if (!authorizationEndpoint || !tokenEndpoint) throw new Error('xAI discovery missing endpoints') + return { + authorization_endpoint: validateXaiEndpoint(authorizationEndpoint, 'authorization_endpoint'), + token_endpoint: validateXaiEndpoint(tokenEndpoint, 'token_endpoint'), + } +} + +function loadAuthJson(authPath: string): AuthJson { + try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } +} + +function saveAuthJson(authPath: string, data: AuthJson): void { + data.updated_at = new Date().toISOString() + const dir = dirname(authPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +function requestedProfile(ctx: any): string { + const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : '' + const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : '' + const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : '' + return ctx.state?.profile?.name || + headerProfile.trim() || + queryProfile.trim() || + bodyProfile.trim() || + getActiveProfileName() || + 'default' +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +export async function saveXaiOAuthTokensForProfile( + profile: string, + session: Pick, + tokenData: any, +) { + const accessToken = String(tokenData.access_token || '').trim() + const refreshToken = String(tokenData.refresh_token || '').trim() + if (!accessToken || !refreshToken) throw new Error('xAI token response missing access_token or refresh_token') + + const lastRefresh = new Date().toISOString() + const tokens = { + access_token: accessToken, + refresh_token: refreshToken, + id_token: String(tokenData.id_token || '').trim(), + expires_in: tokenData.expires_in, + token_type: String(tokenData.token_type || 'Bearer').trim() || 'Bearer', + } + + const authPath = authPathForProfile(profile) + const auth = loadAuthJson(authPath) + if (!auth.providers) auth.providers = {} + auth.providers['xai-oauth'] = { + tokens, + last_refresh: lastRefresh, + auth_mode: 'oauth_pkce', + discovery: session.discovery, + redirect_uri: session.redirectUri, + } + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['xai-oauth'] = [{ + id: `xai-oauth-${Date.now()}`, + label: 'xAI Grok OAuth (SuperGrok Subscription)', + auth_type: 'oauth', + source: 'loopback_pkce', + priority: 0, + access_token: accessToken, + refresh_token: refreshToken, + base_url: XAI_DEFAULT_BASE_URL, + }] + saveAuthJson(authPath, auth) + + await updateConfigYamlForProfile(profile, applyXaiOAuthDefaultModel) +} + +async function saveTokens(session: XaiSession, tokenData: any) { + await saveXaiOAuthTokensForProfile(session.profile, session, tokenData) +} + +async function exchangeCode(session: XaiSession, code: string) { + const res = await requestJson(session.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: session.redirectUri, + client_id: XAI_OAUTH_CLIENT_ID, + code_verifier: session.codeVerifier, + }).toString(), + timeoutMs: 20000, + }) + if (res.status < 200 || res.status >= 300) { + throw new Error(`xAI token exchange failed: ${res.status}${res.text ? ` ${res.text}` : ''}`) + } + await saveTokens(session, res.json) +} + +function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_PORT): Promise<{ server: Server; redirectUri: string }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const session = sessions.get(sessionId) + const url = new URL(req.url || '/', `http://${XAI_REDIRECT_HOST}`) + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': 'https://auth.x.ai', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }) + res.end() + return + } + if (!session || url.pathname !== XAI_REDIRECT_PATH) { + res.writeHead(404) + res.end('Not found.') + return + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end('

xAI authorization received.

You can close this tab.') + + void (async () => { + try { + const error = url.searchParams.get('error') + if (error) throw new Error(url.searchParams.get('error_description') || error) + if (url.searchParams.get('state') !== session.state) throw new Error('xAI OAuth state mismatch') + const code = url.searchParams.get('code') + if (!code) throw new Error('xAI OAuth callback missing code') + await exchangeCode(session, code) + session.status = 'approved' + closeServer(session) + } catch (err: any) { + logger.error(err, 'xAI OAuth callback failed') + session.status = 'error' + session.error = err?.message || String(err) + closeServer(session) + } + })() + }) + server.once('error', (err: any) => { + if (preferredPort !== 0 && err?.code === 'EADDRINUSE') { + startCallbackServer(sessionId, 0).then(resolve, reject) + } else { + reject(err) + } + }) + server.listen(preferredPort, XAI_CALLBACK_BIND_HOST, () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : preferredPort + resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` }) + }) + }) +} + +export async function start(ctx: any) { + try { + cleanupExpiredSessions() + const sessionId = randomUUID() + const profile = requestedProfile(ctx) + const discovery = await discoverXai() + const codeVerifier = makeCodeVerifier() + const state = randomUUID().replace(/-/g, '') + const nonce = randomUUID().replace(/-/g, '') + const { server, redirectUri } = await startCallbackServer(sessionId) + const authorizeUrl = `${discovery.authorization_endpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: XAI_OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + scope: XAI_OAUTH_SCOPE, + code_challenge: makeCodeChallenge(codeVerifier), + code_challenge_method: 'S256', + state, + nonce, + plan: 'generic', + referrer: 'hermes-web-ui', + }).toString()}` + sessions.set(sessionId, { + id: sessionId, + profile, + status: 'pending', + authorizeUrl, + redirectUri, + codeVerifier, + state, + tokenEndpoint: discovery.token_endpoint, + discovery, + server, + createdAt: Date.now(), + }) + ctx.body = { session_id: sessionId, authorization_url: authorizeUrl, expires_in: 900 } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function poll(ctx: any) { + const session = sessions.get(ctx.params.sessionId) + if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return } + if (Date.now() - session.createdAt > POLL_MAX_DURATION) { + session.status = 'expired' + closeServer(session) + } + ctx.body = { status: session.status, error: session.error || null } +} + +export async function status(ctx: any) { + try { + const auth = loadAuthJson(authPathForProfile(requestedProfile(ctx))) + const provider = auth.providers?.['xai-oauth'] + const pool = auth.credential_pool?.['xai-oauth'] + ctx.body = { + authenticated: !!( + provider?.tokens?.access_token || + provider?.access_token || + (Array.isArray(pool) && pool.some((entry: any) => entry?.access_token)) + ), + last_refresh: provider?.last_refresh, + } + } catch { + ctx.body = { authenticated: false } + } +} diff --git a/packages/server/src/controllers/update.ts b/packages/server/src/controllers/update.ts new file mode 100644 index 0000000..bb582ed --- /dev/null +++ b/packages/server/src/controllers/update.ts @@ -0,0 +1,1282 @@ +import { execFile, execFileSync, spawn, type ChildProcess } from 'child_process' +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { createServer } from 'net' +import { delimiter, dirname, extname, join, resolve } from 'path' +import { getWebUiHome } from '../config' + +let updateInProgress = false +const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing' + +const PREVIEW_DIR_NAME = 'hermes-web-ui-pereview' +const PREVIEW_HOME_DIR_NAME = 'hermes-web-ui-pereview-home' +const PREVIEW_BACKEND_PORT = 8650 +const PREVIEW_FRONTEND_PORT = 8651 +const PREVIEW_AGENT_BRIDGE_PORT = 18650 +const PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE = 19650 +const PREVIEW_AGENT_BRIDGE_ENDPOINT_ENV = 'HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT' +const PREVIEW_AGENT_BRIDGE_TRANSPORT_ENV = 'HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT' +const PREVIEW_FRONTEND_URL = `http://localhost:${PREVIEW_FRONTEND_PORT}` +const PREVIEW_TAG_REF_PATTERN = /^[A-Za-z0-9._/-]+$/ +const PREVIEW_MAIN_REF = 'main' +const PREVIEW_TAGS_CACHE_MS = 5 * 60 * 1000 + +type PreviewTagRef = { name: string; sha: string } +type PreviewTagsCache = { expiresAt: number; tags: PreviewTagRef[] } +type PreviewActionResult = { success: boolean; message?: string; code?: string } + +class PreviewRuntimeState { + process: ChildProcess | null = null + tagsCache: PreviewTagsCache | null = null + activeAction: string | null = null + activeActionStartedAt: string | null = null + lastAction: string | null = null + lastActionCompletedAt: string | null = null + lastActionResult: PreviewActionResult | null = null + + getCachedTags(): PreviewTagRef[] | null { + return this.tagsCache && this.tagsCache.expiresAt > Date.now() + ? this.tagsCache.tags + : null + } + + setTags(tags: PreviewTagRef[]) { + this.tagsCache = { tags, expiresAt: Date.now() + PREVIEW_TAGS_CACHE_MS } + } + + beginAction(action: string): boolean { + if (this.activeAction) return false + this.activeAction = action + this.activeActionStartedAt = new Date().toISOString() + this.lastAction = null + this.lastActionCompletedAt = null + this.lastActionResult = null + return true + } + + endAction(action: string, result: PreviewActionResult) { + if (this.activeAction !== action) return + this.activeAction = null + this.activeActionStartedAt = null + this.lastAction = action + this.lastActionCompletedAt = new Date().toISOString() + this.lastActionResult = result + } +} + +const previewState = new PreviewRuntimeState() + +interface PackageInfo { + name: string + version: string + repositoryUrl?: string +} + +function readPackageInfo(): PackageInfo | null { + const candidatePaths = [ + // ts-node dev: packages/server/src/controllers -> repo root + resolve(__dirname, '../../../../package.json'), + // bundled server: dist/server -> repo root/package root + resolve(__dirname, '../../package.json'), + // fallback for processes started at the repo root + resolve(process.cwd(), 'package.json'), + ] + + for (const packagePath of candidatePaths) { + if (!existsSync(packagePath)) continue + try { + const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')) + if (pkg?.name && pkg?.version) { + const repository = typeof pkg.repository === 'string' + ? pkg.repository + : typeof pkg.repository?.url === 'string' + ? pkg.repository.url + : '' + return { + name: String(pkg.name), + version: String(pkg.version), + repositoryUrl: repository, + } + } + } catch {} + } + + return null +} + +function normalizeGithubRepoUrl(raw: string): string { + return raw + .trim() + .replace(/^git\+/, '') + .replace(/^git@github\.com:/, 'https://github.com/') + .replace(/\.git$/, '') +} + +function getPreviewRepoBaseUrl(): string { + const configured = process.env.HERMES_WEB_UI_PREVIEW_REPO?.trim() + const repository = configured || readPackageInfo()?.repositoryUrl || '' + const normalized = normalizeGithubRepoUrl(repository) + if (!normalized) throw new Error('Preview repository is not configured') + return normalized +} + +function getPreviewRepoGitUrl(): string { + return `${getPreviewRepoBaseUrl()}.git` +} + +function getPreviewRepoApiUrl(): string { + const baseUrl = getPreviewRepoBaseUrl() + const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/) + if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`) + return `https://api.github.com/repos/${match[1]}/${match[2]}` +} + +function getPreviewGithubRepoParts(): { owner: string; repo: string } { + const baseUrl = getPreviewRepoBaseUrl() + const match = baseUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/) + if (!match) throw new Error(`Preview zip fallback only supports GitHub repositories: ${baseUrl}`) + return { owner: match[1], repo: match[2] } +} + +function parsePreviewTagRefs(output: string): PreviewTagRef[] { + return output + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [sha, ref] = line.split(/\s+/) + return { sha: sha || '', name: (ref || '').replace(/^refs\/tags\//, '') } + }) + .filter(tag => tag.name) + .reverse() +} + +function execFileText( + command: string, + args: string[], + options: { cwd?: string; timeout?: number; env?: NodeJS.ProcessEnv; maxBuffer?: number } = {}, +): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { + cwd: options.cwd, + encoding: 'utf-8', + timeout: options.timeout, + env: options.env, + windowsHide: true, + maxBuffer: options.maxBuffer || 1024 * 1024, + }, (error, stdout, stderr) => { + if (error) { + ;(error as any).stdout = stdout + ;(error as any).stderr = stderr + reject(error) + return + } + resolve(String(stdout || '').trim()) + }) + }) +} + +async function listPreviewTagsWithGitAsync(): Promise { + const output = await execFileText('git', ['ls-remote', '--tags', '--refs', getPreviewRepoGitUrl()], { + timeout: 8_000, + }) + return parsePreviewTagRefs(output) +} + +function getNodeBinDir() { + return dirname(process.execPath) +} + +function getNodePrefix() { + return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir()) +} + +function getHomebrewPrefix() { + const match = process.execPath.match(/^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/) + return match?.[1] || null +} + +function getNpmCliCandidates() { + const prefix = getNodePrefix() + const homebrewPrefix = getHomebrewPrefix() + + return process.platform === 'win32' + ? [ + join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ] + : [ + join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ...(homebrewPrefix ? [join(homebrewPrefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')] : []), + ] +} + +function getNpmCliPath() { + const candidates = getNpmCliCandidates() + const npmCli = candidates.find(existsSync) + + return npmCli || null +} + +function getNpmBin() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +function windowsCommandNeedsShell(command: string): boolean { + const extension = extname(command).toLowerCase() + return extension === '.cmd' || extension === '.bat' +} + +function commandExecution(command: string, args: string[]): { command: string; args: string[] } { + if (process.platform === 'win32' && windowsCommandNeedsShell(command)) { + const commandArg = / /.test(command) ? `"${command}"` : command + const argsString = args.map(arg => / /.test(arg) ? `"${arg}"` : arg).join(' ') + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', `${commandArg} ${argsString}`], + } + } + return { command, args } +} + +function nodeEnvironmentMissingError(): Error { + const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.') + ;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE + return err +} + +function isNodeEnvironmentMissingError(err: any): boolean { + const text = [ + err?.code, + err?.message, + err?.stderr?.toString?.(), + err?.stdout?.toString?.(), + ].filter(Boolean).join('\n').toLowerCase() + return text.includes('enoent') || + text.includes('spawn npm') || + text.includes('npm: command not found') || + text.includes('npm not found') || + text.includes('node: command not found') || + text.includes('node not found') +} + +function normalizeNodeToolError(err: any): { message: string; code?: string } { + if (isNodeEnvironmentMissingError(err)) { + return { message: nodeEnvironmentMissingError().message, code: NODE_ENVIRONMENT_MISSING_CODE } + } + return { message: err?.stderr?.toString() || err?.message || String(err) } +} + +function findCommandPath(command: string, env: NodeJS.ProcessEnv): string | null { + try { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which' + const stdout = execFileSync(lookupCommand, [command], { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + env, + windowsHide: true, + }) + return stdout.split(/\r?\n/).map((line: string) => line.trim()).find(Boolean) || null + } catch { + return null + } +} + +function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null { + const binDir = dirname(npmBin) + if (process.platform === 'win32') { + const node = join(binDir, 'node.exe') + const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null + } + + const node = join(binDir, 'node') + const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null +} + +function npmExecution(args: string[], env: NodeJS.ProcessEnv): { command: string; args: string[] } { + const bundledNpmCli = getNpmCliPath() + if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] } + + const npmBin = findCommandPath(getNpmBin(), env) || findCommandPath('npm', env) + if (!npmBin) throw nodeEnvironmentMissingError() + + const npmCli = npmCliFromNpmBin(npmBin) + if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] } + + const nodeBin = findCommandPath(process.platform === 'win32' ? 'node.exe' : 'node', env) || findCommandPath('node', env) + if (!nodeBin) throw nodeEnvironmentMissingError() + + return commandExecution(npmBin, args) +} + +function isTermuxRuntime() { + const prefix = process.env.PREFIX || '' + return prefix.includes('/com.termux/') || + existsSync('/data/data/com.termux/files/usr') +} + +function getPreviewViteHostArg() { + return isTermuxRuntime() ? '127.0.0.1' : '' +} + +function getGlobalPackageBin(root: string) { + return join(root, 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs') +} + +function getCurrentNodeEnv() { + return { + ...process.env, + PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter), + npm_node_execpath: process.execPath, + } +} + +function runNpm(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) { + const env = { + ...getCurrentNodeEnv(), + ...options.env, + } + const execution = npmExecution(args, env) + const label = options.logLabel || '' + + if (label) appendPreviewActionLog(`${label}: ${execution.command} ${execution.args.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`) + try { + const output = execFileSync(execution.command, execution.args, { + encoding: 'utf-8', + timeout: options.timeout, + stdio: ['pipe', 'pipe', 'pipe'], + env, + cwd: options.cwd, + windowsHide: true, + }).trim() + if (label) { + if (output) appendPreviewActionLog(`${label} output:\n${output}`) + appendPreviewActionLog(`${label} completed`) + } + return output + } catch (err: any) { + if (label) { + const stderr = err.stderr?.toString() || '' + const stdout = err.stdout?.toString() || '' + appendPreviewActionLog(`${label} failed`) + if (stdout) appendPreviewActionLog(`${label} stdout:\n${stdout}`) + if (stderr) appendPreviewActionLog(`${label} stderr:\n${stderr}`) + } + throw err + } +} + +async function runNpmAsync(args: string[], options: { timeout?: number; cwd?: string; logLabel?: string; env?: NodeJS.ProcessEnv } = {}) { + const env = { + ...getCurrentNodeEnv(), + ...options.env, + } + const execution = npmExecution(args, env) + const label = options.logLabel || '' + + if (label) appendPreviewActionLog(`${label}: ${execution.command} ${execution.args.join(' ')}${options.cwd ? `\ncwd: ${options.cwd}` : ''}`) + try { + const output = await execFileText(execution.command, execution.args, { + cwd: options.cwd, + timeout: options.timeout, + env, + maxBuffer: 16 * 1024 * 1024, + }) + if (label) { + if (output) appendPreviewActionLog(`${label} output:\n${output}`) + appendPreviewActionLog(`${label} completed`) + } + return output + } catch (err: any) { + if (label) { + const stderr = err.stderr?.toString() || '' + const stdout = err.stdout?.toString() || '' + appendPreviewActionLog(`${label} failed`) + if (stdout) appendPreviewActionLog(`${label} stdout:\n${stdout}`) + if (stderr) appendPreviewActionLog(`${label} stderr:\n${stderr}`) + } + throw err + } +} + +function getPreviewDir() { + return join(getWebUiHome(), PREVIEW_DIR_NAME) +} + +function getPreviewHomeDir() { + return join(getWebUiHome(), PREVIEW_HOME_DIR_NAME) +} + +function normalizePreviewAgentBridgeTransport(value: string | undefined) { + const transport = value?.trim().toLowerCase() + return transport && ['tcp', 'ipc', 'unix'].includes(transport) ? transport : '' +} + +function getPreviewAgentBridgeEndpoint() { + const configured = process.env[PREVIEW_AGENT_BRIDGE_ENDPOINT_ENV]?.trim() + if (configured) return configured + + const transport = normalizePreviewAgentBridgeTransport(process.env[PREVIEW_AGENT_BRIDGE_TRANSPORT_ENV]) + || normalizePreviewAgentBridgeTransport(process.env.HERMES_AGENT_BRIDGE_WORKER_TRANSPORT) + const useTcp = transport ? transport === 'tcp' : process.platform === 'win32' + return useTcp + ? `tcp://127.0.0.1:${PREVIEW_AGENT_BRIDGE_PORT}` + : `ipc://${join(getPreviewHomeDir(), 'agent-bridge.sock')}` +} + +function getTcpEndpointPort(endpoint: string): number | null { + try { + const url = new URL(endpoint) + if (url.protocol !== 'tcp:') return null + const port = Number(url.port) + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null + } catch { + return null + } +} + +function getPreviewListeningPorts() { + const agentBridgePort = getTcpEndpointPort(getPreviewAgentBridgeEndpoint()) + return [ + PREVIEW_BACKEND_PORT, + PREVIEW_FRONTEND_PORT, + ...(agentBridgePort ? [agentBridgePort] : []), + ] +} + +function getPreviewPackagePath() { + return join(getPreviewDir(), 'package.json') +} + +function getPreviewLogPath() { + return join(getPreviewDir(), 'preview-dev.log') +} + +function getPreviewActionLogPath() { + return join(getPreviewDir(), 'preview-action.log') +} + +function getPreviewInstallEnv() { + return { + NODE_ENV: 'development', + npm_config_production: 'false', + npm_config_omit: '', + NPM_CONFIG_PRODUCTION: 'false', + NPM_CONFIG_OMIT: '', + } +} + +function readLogTail(path: string, maxChars = 24_000): string { + if (!existsSync(path)) return '' + const raw = readFileSync(path, 'utf-8') + return raw.length > maxChars ? raw.slice(raw.length - maxChars) : raw +} + +function getCurrentPreviewTag() { + const tagPath = join(getPreviewDir(), '.preview-tag') + if (!existsSync(tagPath)) return '' + try { + return readFileSync(tagPath, 'utf-8').trim() + } catch { + return '' + } +} + +function appendPreviewActionLog(message: string) { + mkdirSync(getPreviewDir(), { recursive: true }) + appendFileSync(getPreviewActionLogPath(), `[${new Date().toISOString()}] ${message}\n`, 'utf-8') +} + +function previewPayload(extra: Record = {}) { + return { + ...extra, + ...getPreviewStatus(), + active_action: previewState.activeAction, + active_action_started_at: previewState.activeActionStartedAt, + last_action: previewState.lastAction, + last_action_completed_at: previewState.lastActionCompletedAt, + last_action_success: previewState.lastActionResult?.success ?? null, + last_action_message: previewState.lastActionResult?.message || '', + last_action_code: previewState.lastActionResult?.code || '', + action_log: readLogTail(getPreviewActionLogPath()), + dev_log: readLogTail(getPreviewLogPath()), + } +} + +function getPreviewStatus() { + const previewDir = getPreviewDir() + const packagePath = getPreviewPackagePath() + const exists = existsSync(previewDir) + const hasPackage = existsSync(packagePath) + const installed = hasPackage && getMissingPreviewDependencyBins().length === 0 + const runtimePids = getPreviewListeningPids() + const running = Boolean(previewState.process?.pid && !previewState.process.killed) || runtimePids.length > 0 + const currentTag = getCurrentPreviewTag() + + return { + preview_dir: previewDir, + exists, + has_package: hasPackage, + installed, + running, + pid: running ? previewState.process?.pid || runtimePids[0] || null : null, + current_tag: currentTag, + frontend_url: PREVIEW_FRONTEND_URL, + agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(), + log_path: getPreviewLogPath(), + action_log_path: getPreviewActionLogPath(), + dev_log_path: getPreviewLogPath(), + webui_home: getPreviewHomeDir(), + } +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer() + server.once('error', () => resolve(false)) + server.once('listening', () => { + server.close(() => resolve(true)) + }) + server.listen(port, '127.0.0.1') + }) +} + +function parsePidLines(output: string): number[] { + return [...new Set(output + .split(/\r?\n/) + .map(line => Number(line.trim())) + .filter(pid => Number.isFinite(pid) && pid > 0))] +} + +function getPreviewListeningPids(): number[] { + const ports = getPreviewListeningPorts() + const pids = new Set() + + if (process.platform === 'win32') { + try { + const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { encoding: 'utf-8', windowsHide: true }) + for (const line of output.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/) + if (parts.length < 5) continue + const [proto, localAddress, , state, pidRaw] = parts + if (proto.toUpperCase() !== 'TCP' || state.toUpperCase() !== 'LISTENING') continue + const listenPort = Number(localAddress.split(':').pop()) + if (!ports.includes(listenPort)) continue + const pid = Number(pidRaw) + if (Number.isFinite(pid) && pid > 0) pids.add(pid) + } + } catch {} + return [...pids] + } + + for (const port of ports) { + try { + for (const pid of parsePidLines(execFileSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }))) { + pids.add(pid) + } + } catch {} + } + + return [...pids] +} + +function getUnixProcessGroupId(pid: number): number | null { + try { + const output = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + const pgid = Number(output) + return Number.isFinite(pgid) && pgid > 0 ? pgid : null + } catch { + return null + } +} + +async function assertPreviewPortsAvailable() { + const ports = getPreviewListeningPorts() + const checks = await Promise.all(ports.map(port => isPortAvailable(port))) + const busy = ports.filter((_, index) => !checks[index]) + + if (busy.length) { + throw new Error(`Preview port(s) already in use: ${busy.join(', ')}. Stop the existing dev server and try again.`) + } +} + +async function waitForPreviewReady(timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + let lastError = '' + + while (Date.now() < deadline) { + if (!previewState.process || previewState.process.killed) { + throw new Error(`Preview process exited before it became ready. Check log: ${getPreviewLogPath()}`) + } + + try { + const res = await fetch(`http://127.0.0.1:${PREVIEW_FRONTEND_PORT}/`, { + signal: AbortSignal.timeout(1500), + }) + if (res.ok) return + lastError = `HTTP ${res.status}` + } catch (err: any) { + lastError = err.message || String(err) + } + + await sleep(1000) + } + + throw new Error(`Preview did not become ready on port ${PREVIEW_FRONTEND_PORT}. Last error: ${lastError}. Check log: ${getPreviewLogPath()}`) +} + +function openPreviewLogFile() { + mkdirSync(getPreviewDir(), { recursive: true }) + writeFileSync(getPreviewLogPath(), `[preview] starting ${new Date().toISOString()}\n`, 'utf-8') + return openSync(getPreviewLogPath(), 'a') +} + +async function stopPreviewProcess() { + const child = previewState.process + const pids = new Set() + if (child?.pid && !child.killed) pids.add(child.pid) + for (const pid of getPreviewListeningPids()) pids.add(pid) + + if (!pids.size) { + previewState.process = null + return + } + + appendPreviewActionLog(`stopping preview process pid(s)=${[...pids].join(', ')}`) + if (process.platform === 'win32') { + for (const pid of pids) { + try { + execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) + } catch {} + } + } else { + const pgids = new Set() + for (const pid of pids) { + const pgid = getUnixProcessGroupId(pid) + if (pgid) pgids.add(pgid) + else pgids.add(pid) + } + for (const pgid of pgids) { + try { + process.kill(-pgid, 'SIGTERM') + } catch { + try { process.kill(pgid, 'SIGTERM') } catch {} + } + } + await sleep(800) + const remainingPids = getPreviewListeningPids() + const remainingPgids = new Set(remainingPids.map(getUnixProcessGroupId).filter((pgid): pgid is number => Boolean(pgid))) + for (const pgid of remainingPgids) { + try { process.kill(-pgid, 'SIGKILL') } catch {} + } + } + + previewState.process = null + await sleep(800) +} + +export async function stopPreviewRuntime(): Promise { + await stopPreviewProcess() +} + +function assertPreviewPackage() { + const packagePath = getPreviewPackagePath() + if (!existsSync(packagePath)) { + throw new Error(`Preview package.json not found: ${packagePath}`) + } + + const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')) + if (pkg?.name !== 'hermes-web-ui') { + throw new Error(`Preview directory is not hermes-web-ui: ${getPreviewDir()}`) + } +} + +function getPreviewBinPath(name: string) { + return join(getPreviewDir(), 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name) +} + +async function getPreviewNodePtyErrorAsync() { + if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) { + return 'node-pty' + } + + try { + await execFileText(process.execPath, ['-e', "require('node-pty')"], { + cwd: getPreviewDir(), + timeout: 30_000, + }) + return '' + } catch (err: any) { + return `node-pty (${err.stderr?.toString().trim() || err.message || String(err)})` + } +} + +function getMissingPreviewDependencyBins() { + if (!existsSync(join(getPreviewDir(), 'node_modules'))) { + return ['node_modules'] + } + + const missing = ['concurrently', 'vite', 'nodemon'].filter(name => !existsSync(getPreviewBinPath(name))) + if (!existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) missing.push('node-pty') + return missing +} + +async function getMissingPreviewDependencyBinsAsync() { + const missing = getMissingPreviewDependencyBins() + if (missing.includes('node_modules') || missing.includes('node-pty')) return missing + + const nodePtyError = await getPreviewNodePtyErrorAsync() + if (nodePtyError) missing.push(nodePtyError) + return missing +} + +function patchFileIfExists(path: string, patcher: (source: string) => string) { + if (!existsSync(path)) return + const source = readFileSync(path, 'utf-8') + const next = patcher(source) + if (next !== source) writeFileSync(path, next, 'utf-8') +} + +function patchPreviewWebSocketClient(source: string) { + return source.replace( + /const host = import\.meta\.env\.DEV\s*\?\s*formatHostForPort\(location\.hostname,\s*\d+\)\s*:\s*location\.host/g, + [ + 'const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT', + ' const host = import.meta.env.DEV && directDevPort', + ' ? formatHostForPort(location.hostname, Number(directDevPort))', + ' : location.host', + ].join('\n'), + ) +} + +function patchPreviewApiClient(source: string) { + return source.replace( + /return localStorage\.getItem\(['"]hermes_server_url['"]\) \|\| DEFAULT_BASE_URL/, + "return import.meta.env.VITE_HERMES_PREVIEW === '1' ? DEFAULT_BASE_URL : localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL", + ) +} + +function patchPreviewViteConfig(source: string) { + let next = source.replace( + /const BACKEND = ['"]http:\/\/127\.0\.0\.1:\d+['"]/, + [ + `const BACKEND_PORT = process.env.HERMES_WEB_UI_BACKEND_PORT || '${PREVIEW_BACKEND_PORT}'`, + 'const BACKEND = `http://127.0.0.1:${BACKEND_PORT}`', + ].join('\n'), + ) + if (!next.includes('HERMES_WEB_UI_FRONTEND_PORT')) { + next = next.replace( + /server:\s*\{/, + `server: {\n port: Number(process.env.HERMES_WEB_UI_FRONTEND_PORT || ${PREVIEW_FRONTEND_PORT}),\n strictPort: true,`, + ) + } + next = next.replace( + /(changeOrigin:\s*true,)(?!\s*\n\s*ws:\s*true,)/, + '$1\n ws: true,', + ) + return next +} + +function patchPreviewSidebar(source: string) { + let next = source + if (!next.includes('VITE_HERMES_PREVIEW')) { + next = next.replace( + /const isSuperAdmin = computed\(\(\) => isStoredSuperAdmin\(\)\);/, + "const isSuperAdmin = computed(() => isStoredSuperAdmin());\nconst isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';", + ) + } + next = next.replace( + / Promise, + normalizeError: (err: any) => { message: string; code?: string } = err => ({ message: errorMessage(err) }), + onError?: (err: any) => Promise, +): boolean { + if (!previewState.beginAction(action)) return false + + void (async () => { + try { + const result = await work() + const normalized = result || { success: true } + previewState.endAction(action, normalized) + appendPreviewActionLog(`${action} completed${normalized.success === false ? ': failed' : ''}`) + } catch (err: any) { + if (onError) { + try { await onError(err) } catch {} + } + const normalized = normalizeError(err) + appendPreviewActionLog(`${action} failed: ${normalized.message}`) + previewState.endAction(action, { + success: false, + message: normalized.message, + code: normalized.code, + }) + } + })() + + return true +} + +function previewActionAlreadyRunning(ctx: any) { + ctx.status = 409 + ctx.body = previewPayload({ success: false, message: `Preview action already running: ${previewState.activeAction}` }) +} + +function previewActionAccepted(ctx: any) { + ctx.status = 202 + ctx.body = previewPayload({ success: true, accepted: true }) +} + +async function downloadGithubZip(ref: string, targetDir: string, type: 'tag' | 'branch' = 'tag') { + const { owner, repo } = getPreviewGithubRepoParts() + const refKind = type === 'branch' ? 'heads' : 'tags' + const archiveKind = process.platform === 'win32' ? 'zip' : 'tar.gz' + const url = `https://codeload.github.com/${owner}/${repo}/${archiveKind}/refs/${refKind}/${encodeURIComponent(ref)}` + appendPreviewActionLog(`download archive: ${url}`) + const res = await fetch(url, { + headers: { 'User-Agent': 'hermes-web-ui-preview' }, + signal: AbortSignal.timeout(60_000), + }) + if (!res.ok) throw new Error(`Failed to download GitHub archive: HTTP ${res.status}`) + + const tmpRoot = `${targetDir}.download` + const archivePath = `${tmpRoot}.${archiveKind === 'zip' ? 'zip' : 'tar.gz'}` + rmSync(tmpRoot, { recursive: true, force: true }) + rmSync(archivePath, { force: true }) + mkdirSync(tmpRoot, { recursive: true }) + const archiveBuffer = Buffer.from(await res.arrayBuffer()) + writeFileSync(archivePath, archiveBuffer) + appendPreviewActionLog(`downloaded archive: ${archiveBuffer.length} bytes`) + + try { + appendPreviewActionLog(`extract archive: ${archivePath}`) + if (process.platform === 'win32') { + await execFileText('powershell.exe', [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(tmpRoot)} -Force`, + ], { timeout: 5 * 60 * 1000 }) + } else { + await execFileText('tar', ['-xzf', archivePath, '-C', tmpRoot], { timeout: 5 * 60 * 1000 }) + } + + const entries = (await execFileText(process.platform === 'win32' ? 'cmd.exe' : 'ls', process.platform === 'win32' ? ['/c', 'dir', '/b', tmpRoot] : [tmpRoot], { + timeout: 30_000, + })).trim().split(/\r?\n/).filter(Boolean) + const extracted = entries.length === 1 ? join(tmpRoot, entries[0]) : tmpRoot + appendPreviewActionLog(`replace preview directory: ${targetDir}`) + rmSync(targetDir, { recursive: true, force: true }) + mkdirSync(dirname(targetDir), { recursive: true }) + if (process.platform !== 'win32') mkdirSync(targetDir, { recursive: true }) + await execFileText(process.platform === 'win32' ? 'cmd.exe' : 'cp', process.platform === 'win32' + ? ['/c', 'move', extracted, targetDir] + : ['-R', `${extracted}/.`, targetDir], { + timeout: 5 * 60 * 1000, + }) + appendPreviewActionLog('archive preview code ready') + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + rmSync(archivePath, { force: true }) + } +} + +async function clonePreview(ref: string) { + const previewDir = getPreviewDir() + appendPreviewActionLog(`prepare preview clone for tag: ${ref}`) + rmSync(previewDir, { recursive: true, force: true }) + mkdirSync(dirname(previewDir), { recursive: true }) + + try { + appendPreviewActionLog(`git clone --branch ${ref} --depth 1 ${getPreviewRepoGitUrl()} ${previewDir}`) + await runGitAsync(['clone', '--branch', ref, '--depth', '1', getPreviewRepoGitUrl(), previewDir]) + appendPreviewActionLog('git clone completed') + } catch { + appendPreviewActionLog('git clone unavailable or failed, falling back to GitHub zip') + rmSync(previewDir, { recursive: true, force: true }) + await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag') + } +} + +async function checkoutPreview(ref: string) { + const previewDir = getPreviewDir() + appendPreviewActionLog(`checkout preview tag: ${ref}`) + if (!existsSync(previewDir)) { + await clonePreview(ref) + } else if (existsSync(join(previewDir, '.git'))) { + try { + appendPreviewActionLog('git fetch --tags --force') + await runGitAsync(['fetch', '--tags', '--force'], previewDir) + appendPreviewActionLog(`git checkout --force ${ref}`) + await runGitAsync(['checkout', '--force', ref], previewDir) + } catch (err: any) { + appendPreviewActionLog(`git checkout failed, replacing with GitHub zip: ${err.stderr?.toString() || err.message || String(err)}`) + rmSync(previewDir, { recursive: true, force: true }) + await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag') + } + } else { + appendPreviewActionLog('preview directory is missing git metadata or package.json, replacing with GitHub zip') + rmSync(previewDir, { recursive: true, force: true }) + await downloadGithubZip(ref, previewDir, ref === PREVIEW_MAIN_REF ? 'branch' : 'tag') + } + + assertPreviewPackage() + appendPreviewActionLog('apply preview runtime port patch') + applyPreviewRuntimePatch() + writeFileSync(join(previewDir, '.preview-tag'), `${ref}\n`) + appendPreviewActionLog(`preview tag ready: ${ref}`) +} + +function getGlobalRoot() { + return runNpm(['root', '-g']) +} + +function getGlobalCliScript() { + const cli = getGlobalPackageBin(getGlobalRoot()) + if (!existsSync(cli)) { + throw new Error(`Updated hermes-web-ui CLI not found: ${cli}`) + } + return cli +} + +function runUpdateInstall() { + try { + runNpm(['cache', 'clean', '--force'], { timeout: 2 * 60 * 1000 }) + } catch (err) { + console.warn('[update] failed to clean npm cache, continuing update:', err) + } + + return runNpm(['install', '-g', 'hermes-web-ui@latest'], { timeout: 10 * 60 * 1000 }) +} + +function spawnRestart(port: string) { + const cli = getGlobalCliScript() + + return spawn(process.execPath, [cli, 'restart', '--port', port], { + detached: true, + stdio: 'ignore', + windowsHide: true, + env: getCurrentNodeEnv(), + }) +} + +export async function handleUpdate(ctx: any) { + if (updateInProgress) { + ctx.status = 409 + ctx.body = { + success: false, + message: 'hermes-web-ui update is already in progress', + } + return + } + + updateInProgress = true + + try { + const output = runUpdateInstall() + + ctx.body = { + success: true, + message: output.trim() || 'hermes-web-ui updated successfully', + } + + setTimeout(() => { + let restart + try { + restart = spawnRestart(process.env.PORT || '8648') + } catch (err) { + updateInProgress = false + console.error('[update] failed to spawn restart:', err) + return + } + + restart.on('error', (err) => { + updateInProgress = false + console.error('[update] restart process failed:', err) + }) + restart.on('exit', (code, signal) => { + updateInProgress = false + const failed = (typeof code === 'number' && code !== 0) || Boolean(signal) + if (failed) { + console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`) + } + }) + restart.unref() + }, 3000) + } catch (err: any) { + updateInProgress = false + ctx.status = 500 + ctx.body = { + success: false, + message: err.stderr?.toString() || err.message || String(err), + } + } +} + +export async function previewStatus(ctx: any) { + ctx.body = previewPayload() +} + +export async function previewTags(ctx: any) { + const cachedTags = previewState.getCachedTags() + if (cachedTags) { + ctx.body = { tags: cachedTags } + return + } + + try { + appendPreviewActionLog('load tags with git ls-remote') + const tags = [{ name: PREVIEW_MAIN_REF, sha: '' }, ...await listPreviewTagsWithGitAsync()] + previewState.setTags(tags) + ctx.body = { tags } + return + } catch (gitErr: any) { + appendPreviewActionLog(`load tags with git failed: ${gitErr.message || String(gitErr)}`) + } + + try { + appendPreviewActionLog('load tags with GitHub API') + const res = await fetch(`${getPreviewRepoApiUrl()}/tags?per_page=100`, { + headers: { 'User-Agent': 'hermes-web-ui-preview' }, + signal: AbortSignal.timeout(15_000), + }) + if (!res.ok) { + throw new Error(`GitHub API HTTP ${res.status}`) + } + + const tags = await res.json() as Array<{ name?: string; commit?: { sha?: string } }> + const parsedTags = [ + { name: PREVIEW_MAIN_REF, sha: '' }, + ...tags + .filter((tag): tag is { name: string; commit?: { sha?: string } } => typeof tag.name === 'string' && Boolean(tag.name.trim())) + .map(tag => ({ name: tag.name, sha: tag.commit?.sha || '' })), + ] + previewState.setTags(parsedTags) + ctx.body = { tags: parsedTags } + } catch (apiErr: any) { + appendPreviewActionLog(`load tags failed: ${apiErr.message || String(apiErr)}`) + ctx.status = 502 + ctx.body = previewPayload({ error: networkErrorMessage(apiErr) }) + } +} + +export async function preparePreview(ctx: any) { + try { + const tag = assertTagRef((ctx.request.body as any)?.tag) + const queued = queuePreviewAction('prepare', async () => { + appendPreviewActionLog(`prepare requested: ${tag}`) + await stopPreviewProcess() + await checkoutPreview(tag) + return { success: true } + }) + if (!queued) { + previewActionAlreadyRunning(ctx) + return + } + previewActionAccepted(ctx) + } catch (err: any) { + appendPreviewActionLog(`prepare failed: ${errorMessage(err)}`) + ctx.status = 500 + ctx.body = previewPayload({ success: false, message: errorMessage(err) }) + } +} + +export async function installPreview(ctx: any) { + const queued = queuePreviewAction('install', async () => { + appendPreviewActionLog('npm install requested') + await stopPreviewProcess() + assertPreviewPackage() + const output = await runNpmAsync(['install', '--include=dev', '--ignore-scripts'], { + cwd: getPreviewDir(), + timeout: 15 * 60 * 1000, + logLabel: 'npm install --include=dev --ignore-scripts', + env: getPreviewInstallEnv(), + }) + if (existsSync(join(getPreviewDir(), 'node_modules', 'node-pty'))) { + await runNpmAsync(['rebuild', 'node-pty'], { + cwd: getPreviewDir(), + timeout: 5 * 60 * 1000, + logLabel: 'npm rebuild node-pty', + env: getPreviewInstallEnv(), + }) + } + appendPreviewActionLog(`verify preview dependencies in: ${getPreviewDir()}`) + const missing = await getMissingPreviewDependencyBinsAsync() + if (missing.length) { + const message = `npm install completed but preview dependencies are still missing: ${missing.join(', ')}` + appendPreviewActionLog(message) + return { success: false, message } + } + return { success: true, message: output } + }, normalizeNodeToolError) + if (!queued) { + previewActionAlreadyRunning(ctx) + return + } + previewActionAccepted(ctx) +} + +export async function startPreview(ctx: any) { + try { + const tag = (ctx.request.body as any)?.tag + const requestedTag = typeof tag === 'string' && tag.trim() ? assertTagRef(tag) : '' + const queued = queuePreviewAction('start', async () => { + appendPreviewActionLog(`npm run dev requested${requestedTag ? ` for ${requestedTag}` : ''}`) + if (requestedTag && requestedTag !== getCurrentPreviewTag() && previewState.process?.pid && !previewState.process.killed) { + await stopPreviewProcess() + } + + if (requestedTag) { + const currentTag = getCurrentPreviewTag() + if (requestedTag === currentTag && existsSync(getPreviewPackagePath())) { + appendPreviewActionLog(`skip checkout, preview tag already prepared: ${requestedTag}`) + appendPreviewActionLog('apply preview runtime port patch') + applyPreviewRuntimePatch() + } else { + await checkoutPreview(requestedTag) + } + } + assertPreviewPackage() + const missingDependencies = await getMissingPreviewDependencyBinsAsync() + if (missingDependencies.length) { + const message = `Preview dependencies are not installed. Missing: ${missingDependencies.join(', ')}. Run npm install first.` + appendPreviewActionLog(`start blocked: ${message}`) + return { success: false, message } + } + + if (previewState.process?.pid && !previewState.process.killed) { + appendPreviewActionLog('preview is already running') + return { success: true, message: 'Preview is already running' } + } + + await assertPreviewPortsAvailable() + + const env = { + ...getCurrentNodeEnv(), + NODE_ENV: 'development', + PORT: String(PREVIEW_BACKEND_PORT), + HERMES_WEB_UI_HOME: getPreviewHomeDir(), + HERMES_WEBUI_STATE_DIR: getPreviewHomeDir(), + HERMES_AGENT_BRIDGE_ENDPOINT: getPreviewAgentBridgeEndpoint(), + HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(PREVIEW_AGENT_BRIDGE_WORKER_PORT_BASE), + AUTH_TOKEN: '', + HERMES_WEB_UI_BACKEND_PORT: String(PREVIEW_BACKEND_PORT), + HERMES_WEB_UI_FRONTEND_PORT: String(PREVIEW_FRONTEND_PORT), + VITE_HERMES_PREVIEW: '1', + } + const execution = npmExecution(['run', 'dev'], env) + const logFd = openPreviewLogFile() + appendPreviewActionLog(`spawn preview process: ${execution.command} ${execution.args.join(' ')}`) + previewState.process = spawn(execution.command, execution.args, { + cwd: getPreviewDir(), + detached: true, + stdio: ['ignore', logFd, logFd], + windowsHide: true, + env, + }) + closeSync(logFd) + previewState.process.on('exit', () => { + appendPreviewActionLog('preview process exited') + previewState.process = null + }) + previewState.process.on('error', (err) => { + console.error('[preview] failed:', err) + previewState.process = null + }) + previewState.process.unref() + + await waitForPreviewReady() + + appendPreviewActionLog(`preview ready: ${PREVIEW_FRONTEND_URL}`) + return { success: true, message: 'Preview started' } + }, normalizeNodeToolError, async () => { + await stopPreviewProcess() + }) + if (!queued) { + previewActionAlreadyRunning(ctx) + return + } + previewActionAccepted(ctx) + } catch (err: any) { + const normalized = normalizeNodeToolError(err) + appendPreviewActionLog(`npm run dev failed: ${normalized.message}`) + ctx.status = 500 + ctx.body = previewPayload({ success: false, message: normalized.message, code: normalized.code }) + } +} + +export async function stopPreview(ctx: any) { + appendPreviewActionLog('stop preview requested') + await stopPreviewProcess() + ctx.body = previewPayload({ success: true }) +} diff --git a/packages/server/src/controllers/upload.ts b/packages/server/src/controllers/upload.ts new file mode 100644 index 0000000..ea80a3f --- /dev/null +++ b/packages/server/src/controllers/upload.ts @@ -0,0 +1,70 @@ +import { randomBytes } from 'crypto' +import { mkdir, writeFile } from 'fs/promises' +import { join } from 'path' +import { getActiveProfileName } from '../services/hermes/hermes-profile' +import { getProfileUploadDir } from '../services/hermes/upload-paths' + +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +export async function handleUpload(ctx: any) { + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400; ctx.body = { error: 'Expected multipart/form-data' }; return + } + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400; ctx.body = { error: 'Missing boundary' }; return + } + const chunks: Buffer[] = [] + let totalSize = 0 + for await (const chunk of ctx.req) { + totalSize += chunk.length + if (totalSize > MAX_UPLOAD_SIZE) { + ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)` }; return + } + chunks.push(chunk) + } + const raw = Buffer.concat(chunks) + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) + const results: { name: string; path: string }[] = [] + const uploadDir = getProfileUploadDir(requestedProfile(ctx)) + await mkdir(uploadDir, { recursive: true }) + for (const part of parts) { + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) + if (headerEnd === -1) continue + const headerBuf = part.subarray(0, headerEnd) + const header = headerBuf.toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { filename = decodeURIComponent(filenameStarMatch[1]) } + else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } + const ext = filename.includes('.') ? '.' + filename.split('.').pop() : '' + const savedName = randomBytes(8).toString('hex') + ext + const savedPath = join(uploadDir, savedName) + await writeFile(savedPath, data) + results.push({ name: filename, path: savedPath }) + } + ctx.body = { files: results } +} + +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { parts.push(raw.subarray(start + 2, idx)) } + start = idx + boundary.length + } + return parts +} diff --git a/packages/server/src/controllers/webhook.ts b/packages/server/src/controllers/webhook.ts new file mode 100644 index 0000000..ac77729 --- /dev/null +++ b/packages/server/src/controllers/webhook.ts @@ -0,0 +1,12 @@ +import { logger } from '../services/logger' + +export async function handleWebhook(ctx: any) { + const payload = ctx.request.body + if (!payload || !payload.event) { + ctx.status = 400 + ctx.body = { error: 'Missing event field' } + return + } + logger.info('Received webhook event: %s', payload.event) + ctx.body = { ok: true } +} diff --git a/packages/server/src/db/hermes/compression-snapshot.ts b/packages/server/src/db/hermes/compression-snapshot.ts new file mode 100644 index 0000000..62de36a --- /dev/null +++ b/packages/server/src/db/hermes/compression-snapshot.ts @@ -0,0 +1,40 @@ +/** + * SQLite-backed compression snapshot store for 1:1 chat sessions. + * + * Stores the latest compression summary and the index of the last + * compressed message, so incremental compression can pick up where + * the previous one left off. + */ + +import { isSqliteAvailable, getDb } from '../index' +import { COMPRESSION_SNAPSHOT_TABLE as TABLE } from './schemas' + +export function getCompressionSnapshot(sessionId: string): { summary: string; lastMessageIndex: number; messageCountAtTime: number } | null { + if (!isSqliteAvailable()) return null + return getDb()!.prepare( + `SELECT summary, last_message_index AS lastMessageIndex, message_count_at_time AS messageCountAtTime FROM ${TABLE} WHERE session_id = ?`, + ).get(sessionId) as any ?? null +} + +export function saveCompressionSnapshot( + sessionId: string, + summary: string, + lastMessageIndex: number, + messageCountAtTime: number, +): void { + if (!isSqliteAvailable()) return + getDb()!.prepare( + `INSERT INTO ${TABLE} (session_id, summary, last_message_index, message_count_at_time, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + summary = excluded.summary, + last_message_index = excluded.last_message_index, + message_count_at_time = excluded.message_count_at_time, + updated_at = excluded.updated_at`, + ).run(sessionId, summary, lastMessageIndex, messageCountAtTime, Date.now()) +} + +export function deleteCompressionSnapshot(sessionId: string): void { + if (!isSqliteAvailable()) return + getDb()!.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId) +} diff --git a/packages/server/src/db/hermes/conversations-db.ts b/packages/server/src/db/hermes/conversations-db.ts new file mode 100644 index 0000000..5acb5e1 --- /dev/null +++ b/packages/server/src/db/hermes/conversations-db.ts @@ -0,0 +1,537 @@ +import { join } from 'path' +import { getActiveProfileDir } from '../../services/hermes/hermes-profile' +import type { + ConversationDetail, + ConversationListOptions, + ConversationMessage, + ConversationSummary, +} from '../../services/hermes/conversations' + +const SQLITE_AVAILABLE = (() => { + const [major, minor] = process.versions.node.split('.').map(Number) + return major > 22 || (major === 22 && minor >= 5) +})() + +const LINEAGE_TOLERANCE_SECONDS = 3 +const LIVE_WINDOW_SECONDS = 300 +const DEFAULT_CONVERSATION_LIMIT = 200 +const SYNTHETIC_USER_PREFIXES = [ + '[system:', + "you've reached the maximum number of tool-calling iterations allowed.", + 'you have reached the maximum number of tool-calling iterations allowed.', +] + +const VISIBLE_HUMAN_MESSAGE_SQL = ` + m.content IS NOT NULL + AND m.content != '' + AND ( + m.role = 'assistant' + OR ( + m.role = 'user' + AND LOWER(m.content) NOT LIKE '[system:%' + AND LOWER(m.content) NOT LIKE 'you''ve reached the maximum number of tool-calling iterations allowed.%' + AND LOWER(m.content) NOT LIKE 'you have reached the maximum number of tool-calling iterations allowed.%' + ) + ) +` + +interface ConversationSessionRow { + id: string + source: string + user_id: string | null + model: string + title: string | null + parent_session_id: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + preview: string + last_active: number + has_visible_messages: boolean + is_active: boolean +} + +function conversationDbPath(): string { + return join(getActiveProfileDir(), 'state.db') +} + +function normalizeNumber(value: unknown, fallback = 0): number { + if (value == null || value === '') return fallback + const num = Number(value) + return Number.isFinite(num) ? num : fallback +} + +function normalizeNullableNumber(value: unknown): number | null { + if (value == null || value === '') return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function normalizeNullableString(value: unknown): string | null { + if (value == null || value === '') return null + return String(value) +} + +function safeText(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return '' +} + +function textFromContent(value: unknown): string { + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) { + try { + const parsed = JSON.parse(trimmed) + const nested = textFromContent(parsed) + if (nested) return nested + } catch { + // Fall back to the original string below. + } + } + return value + } + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return value + .map(item => textFromContent(item).trim()) + .filter(Boolean) + .join('\n') + } + if (!value || typeof value !== 'object') return '' + + const record = value as Record + for (const key of ['text', 'content', 'value'] as const) { + const direct = record[key] + if (typeof direct === 'string') return direct + if (Array.isArray(direct)) { + const nested = textFromContent(direct) + if (nested) return nested + } + } + + for (const key of ['parts', 'children', 'items'] as const) { + if (Array.isArray(record[key])) { + const nested = textFromContent(record[key]) + if (nested) return nested + } + } + + const flattened = Object.values(record) + .map(entry => textFromContent(entry).trim()) + .filter(Boolean) + .join('\n') + if (flattened) return flattened + + try { + return JSON.stringify(record) + } catch { + return '' + } +} + +function normalizeText(value: unknown): string { + return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase() +} + +function excerpt(value: unknown, width = 80): string { + const text = textFromContent(value).replace(/\s+/g, ' ').trim() + if (!text) return '' + return text.length > width ? `${text.slice(0, width)}…` : text +} + +function isSyntheticUserText(content: unknown): boolean { + const text = normalizeText(content) + return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix)) +} + +function mapSessionRow(row: Record, nowSeconds: number): ConversationSessionRow { + const startedAt = normalizeNumber(row.started_at) + const endedAt = normalizeNullableNumber(row.ended_at) + const preview = excerpt(row.preview || '') + const rawTitle = normalizeNullableString(row.title) + const title = rawTitle || (preview ? (preview.length > 40 ? `${preview.slice(0, 40)}...` : preview) : null) + const lastActive = normalizeNumber(row.last_active, startedAt) + + return { + id: String(row.id || ''), + source: String(row.source || ''), + user_id: normalizeNullableString(row.user_id), + model: String(row.model || ''), + title, + parent_session_id: normalizeNullableString(row.parent_session_id), + started_at: startedAt, + ended_at: endedAt, + end_reason: normalizeNullableString(row.end_reason), + message_count: normalizeNumber(row.message_count), + tool_call_count: normalizeNumber(row.tool_call_count), + input_tokens: normalizeNumber(row.input_tokens), + output_tokens: normalizeNumber(row.output_tokens), + cache_read_tokens: normalizeNumber(row.cache_read_tokens), + cache_write_tokens: normalizeNumber(row.cache_write_tokens), + reasoning_tokens: normalizeNumber(row.reasoning_tokens), + billing_provider: normalizeNullableString(row.billing_provider), + estimated_cost_usd: normalizeNumber(row.estimated_cost_usd), + actual_cost_usd: normalizeNullableNumber(row.actual_cost_usd), + cost_status: String(row.cost_status || ''), + preview, + last_active: lastActive, + has_visible_messages: !!normalizeNumber(row.has_visible_messages), + is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS, + } +} + +function sortByRecency(items: T[]): T[] { + return [...items].sort((a, b) => { + if (b.last_active !== a.last_active) return b.last_active - a.last_active + if (b.started_at !== a.started_at) return b.started_at - a.started_at + return a.id.localeCompare(b.id) + }) +} + +function timingMatchesParent(parent: ConversationSessionRow | undefined, child: ConversationSessionRow | undefined): boolean { + if (!parent || !child || parent.ended_at == null) return false + return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS +} + +function isCompressionEndReason(reason: string | null): boolean { + return reason === 'compression' || reason === 'compressed' +} + +function continuationCandidates(parent: ConversationSessionRow, byId: Map, childrenByParent: Map): ConversationSessionRow[] { + const childIds = childrenByParent.get(parent.id) || [] + return childIds + .map(childId => byId.get(childId)) + .filter((child): child is ConversationSessionRow => !!child) + .filter(child => child.source !== 'tool') + .filter(child => child.source === parent.source) + .filter(child => timingMatchesParent(parent, child)) + .sort((a, b) => { + const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0)) + const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0)) + if (aDelta !== bDelta) return aDelta - bDelta + return a.id.localeCompare(b.id) + }) +} + +function nextContinuationChild(parent: ConversationSessionRow, byId: Map, childrenByParent: Map): ConversationSessionRow | null { + if (!isCompressionEndReason(parent.end_reason)) return null + const candidates = continuationCandidates(parent, byId, childrenByParent) + if (candidates.length === 1) return candidates[0] + + const exactPreviewMatches = candidates.filter(child => { + const childPreview = normalizeText(child.preview) + const parentPreview = normalizeText(parent.preview) + return !!childPreview && childPreview === parentPreview + }) + + if (exactPreviewMatches.length === 1) return exactPreviewMatches[0] + return null +} + +function isCompressionContinuationChild(session: ConversationSessionRow | undefined, byId: Map, childrenByParent: Map): boolean { + if (!session?.parent_session_id) return false + const parent = byId.get(session.parent_session_id) + if (!parent) return false + return nextContinuationChild(parent, byId, childrenByParent)?.id === session.id +} + +function compressionChainRootId(sessionId: string, byId: Map, childrenByParent: Map): string | null { + let current = byId.get(sessionId) || null + if (!current || current.source === 'tool') return null + + const seen = new Set() + while (current?.parent_session_id && !seen.has(current.id)) { + seen.add(current.id) + const parent = byId.get(current.parent_session_id) + if (!parent) break + if (nextContinuationChild(parent, byId, childrenByParent)?.id !== current.id) break + current = parent + } + return current?.id || null +} + +function isVisibleConversationStart(session: ConversationSessionRow | undefined, byId: Map, childrenByParent: Map): boolean { + if (!session || session.source === 'tool') return false + return !isCompressionContinuationChild(session, byId, childrenByParent) +} + +function collectConversationChain(rootId: string, byId: Map, childrenByParent: Map): ConversationSessionRow[] { + const chain: ConversationSessionRow[] = [] + const seen = new Set() + let current = byId.get(rootId) || null + while (current && !seen.has(current.id)) { + chain.push(current) + seen.add(current.id) + current = nextContinuationChild(current, byId, childrenByParent) + } + return chain +} + +function toSummary(session: ConversationSessionRow): ConversationSummary { + return { + id: session.id, + source: safeText(session.source), + model: safeText(session.model), + title: session.title ?? null, + started_at: Number(session.started_at || 0), + ended_at: session.ended_at ?? null, + last_active: session.last_active, + message_count: Number(session.message_count || 0), + tool_call_count: Number(session.tool_call_count || 0), + input_tokens: Number(session.input_tokens || 0), + output_tokens: Number(session.output_tokens || 0), + cache_read_tokens: Number(session.cache_read_tokens || 0), + cache_write_tokens: Number(session.cache_write_tokens || 0), + reasoning_tokens: Number(session.reasoning_tokens || 0), + billing_provider: session.billing_provider ?? null, + estimated_cost_usd: Number(session.estimated_cost_usd || 0), + actual_cost_usd: session.actual_cost_usd ?? null, + cost_status: safeText(session.cost_status), + preview: session.preview, + is_active: session.is_active, + thread_session_count: 1, + } +} + +function aggregateSummary(rootId: string, byId: Map, childrenByParent: Map): ConversationSummary | null { + const chain = collectConversationChain(rootId, byId, childrenByParent) + if (!chain.length || !chain.some(session => session.has_visible_messages)) return null + const root = chain[0] + const last = chain[chain.length - 1] + const firstPreview = chain.map(session => session.preview).find(Boolean) || '' + const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean))) + + return { + ...toSummary(last), + title: last.title || root.title || firstPreview || null, + preview: last.preview || root.preview || firstPreview, + started_at: Number(root.started_at || 0), + ended_at: last?.ended_at ?? null, + last_active: Math.max(...chain.map(session => session.last_active)), + is_active: chain.some(session => session.is_active), + billing_provider: last?.billing_provider ?? root.billing_provider ?? null, + cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed', + thread_session_count: chain.length, + message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0), + tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0), + input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0), + output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0), + cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0), + cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0), + reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0), + estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0), + actual_cost_usd: chain.reduce((sum, session) => { + const actual = session.actual_cost_usd + if (actual == null) return sum + return (sum || 0) + Number(actual) + }, null), + } +} + +function normalizeVisibleMessage(message: { id: number | string, session_id: string, role: string, content: unknown, timestamp: number }, fallbackTimestamp: number): ConversationMessage | null { + const role = safeText(message.role) + const content = textFromContent(message.content).trim() + if (!content) return null + if (role !== 'user' && role !== 'assistant') return null + if (role === 'user' && isSyntheticUserText(content)) return null + + return { + id: message.id, + session_id: message.session_id, + role, + content, + timestamp: Number.isFinite(Number(message.timestamp)) && Number(message.timestamp) > 0 + ? Number(message.timestamp) + : fallbackTimestamp, + } +} + +async function openConversationDb() { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const { DatabaseSync } = await import('node:sqlite') + return new DatabaseSync(conversationDbPath(), { open: true, readOnly: true }) +} + +function buildConversationSessionSql(source?: string): { sql: string, params: any[] } { + const sql = ` + SELECT + s.id, + s.source, + COALESCE(s.user_id, '') AS user_id, + COALESCE(s.model, '') AS model, + COALESCE(s.title, '') AS title, + s.parent_session_id AS parent_session_id, + COALESCE(s.started_at, 0) AS started_at, + s.ended_at AS ended_at, + COALESCE(s.end_reason, '') AS end_reason, + COALESCE(s.message_count, 0) AS message_count, + COALESCE(s.tool_call_count, 0) AS tool_call_count, + COALESCE(s.input_tokens, 0) AS input_tokens, + COALESCE(s.output_tokens, 0) AS output_tokens, + COALESCE(s.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(s.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(s.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(s.billing_provider, '') AS billing_provider, + COALESCE(s.estimated_cost_usd, 0) AS estimated_cost_usd, + s.actual_cost_usd AS actual_cost_usd, + COALESCE(s.cost_status, '') AS cost_status, + COALESCE( + ( + SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 80) + FROM messages m + WHERE m.session_id = s.id + AND ${VISIBLE_HUMAN_MESSAGE_SQL} + ORDER BY m.timestamp, m.id + LIMIT 1 + ), + '' + ) AS preview, + COALESCE((SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), s.started_at) AS last_active, + CASE WHEN EXISTS ( + SELECT 1 + FROM messages m + WHERE m.session_id = s.id + AND ${VISIBLE_HUMAN_MESSAGE_SQL} + ) THEN 1 ELSE 0 END AS has_visible_messages + FROM sessions s + WHERE s.source != 'tool' + ${source ? 'AND s.source = ?' : ''} + ORDER BY s.started_at DESC + ` + + return { sql, params: source ? [source] : [] } +} + +async function loadConversationSessions(source?: string): Promise { + const db = await openConversationDb() + try { + const { sql, params } = buildConversationSessionSql(source) + const rows = db.prepare(sql).all(...params) as Record[] + const nowSeconds = Date.now() / 1000 + return rows.map(row => mapSessionRow(row, nowSeconds)) + } finally { + db.close() + } +} + +export async function listConversationSummariesFromDb(options: ConversationListOptions = {}): Promise { + const humanOnly = options.humanOnly !== false + const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT + const sessions = await loadConversationSessions(options.source) + const byId = new Map(sessions.map(session => [session.id, session])) + const childrenByParent = new Map() + for (const session of sessions) { + const key = session.parent_session_id ?? null + const siblings = childrenByParent.get(key) || [] + siblings.push(session.id) + childrenByParent.set(key, siblings) + } + + if (!humanOnly) { + return sortByRecency(sessions.map(toSummary)).slice(0, limit) + } + + const summaries = sessions + .filter(session => isVisibleConversationStart(session, byId, childrenByParent)) + .map(session => aggregateSummary(session.id, byId, childrenByParent)) + .filter((summary): summary is ConversationSummary => !!summary) + + return sortByRecency(summaries).slice(0, limit) +} + +export async function getConversationDetailFromDb(sessionId: string, options: ConversationListOptions = {}): Promise { + const humanOnly = options.humanOnly !== false + const sessions = await loadConversationSessions(options.source) + const byId = new Map(sessions.map(session => [session.id, session])) + const childrenByParent = new Map() + for (const session of sessions) { + const key = session.parent_session_id ?? null + const siblings = childrenByParent.get(key) || [] + siblings.push(session.id) + childrenByParent.set(key, siblings) + } + + let chain: ConversationSessionRow[] = [] + if (!humanOnly) { + const session = byId.get(sessionId) + if (!session || session.source === 'tool') return null + chain = [session] + } else { + const session = byId.get(sessionId) + if (!session || session.source === 'tool') return null + const rootId = compressionChainRootId(sessionId, byId, childrenByParent) + if (!rootId) return null + if (!isVisibleConversationStart(byId.get(rootId), byId, childrenByParent)) return null + chain = collectConversationChain(rootId, byId, childrenByParent) + } + + if (!chain.length) return null + + const db = await openConversationDb() + try { + const ids = chain.map(session => session.id) + const placeholders = ids.map(() => '?').join(', ') + const rows = db.prepare(` + SELECT id, session_id, role, content, timestamp + FROM messages + WHERE session_id IN (${placeholders}) + AND role IN ('user', 'assistant') + AND content IS NOT NULL + AND content != '' + ORDER BY timestamp, id + `).all(...ids) as Array> + + const sessionById = new Map(chain.map(session => [session.id, session])) + const messages = rows + .map(row => { + const session = sessionById.get(String(row.session_id || '')) + return normalizeVisibleMessage({ + id: row.id as number | string, + session_id: String(row.session_id || ''), + role: String(row.role || ''), + content: row.content, + timestamp: normalizeNumber(row.timestamp), + }, session?.last_active || session?.started_at || 0) + }) + .filter((message): message is ConversationMessage => !!message) + .sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp + return String(a.id).localeCompare(String(b.id)) + }) + + if (!messages.length) { + return humanOnly + ? null + : { + session_id: sessionId, + messages: [], + visible_count: 0, + thread_session_count: chain.length, + } + } + return { + session_id: sessionId, + messages, + visible_count: messages.length, + thread_session_count: chain.length, + } + } finally { + db.close() + } +} diff --git a/packages/server/src/db/hermes/init.ts b/packages/server/src/db/hermes/init.ts new file mode 100644 index 0000000..0f5f754 --- /dev/null +++ b/packages/server/src/db/hermes/init.ts @@ -0,0 +1,14 @@ +/** + * Unified initializer for all Hermes SQLite stores. + * Call this once at bootstrap to create/migrate all tables. + * + * All table schemas, creation, and migration logic are now centralized + * in schemas.ts to avoid duplication and ensure consistency. + */ + +import { initAllHermesTables } from './schemas' + +export function initAllStores(): void { + // Initialize all tables with centralized schema definitions and migrations + initAllHermesTables() +} diff --git a/packages/server/src/db/hermes/message-content.ts b/packages/server/src/db/hermes/message-content.ts new file mode 100644 index 0000000..8b0b377 --- /dev/null +++ b/packages/server/src/db/hermes/message-content.ts @@ -0,0 +1,104 @@ +const IMAGE_PART_TYPES = new Set(['image', 'image_url', 'input_image']) +const DATA_IMAGE_RE = /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\r\n]+/g + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isContentPart(value: unknown): value is Record { + return isPlainRecord(value) && typeof value.type === 'string' +} + +function summarizeContentParts(parts: unknown[]): string | null { + let sawContentPart = false + const text: string[] = [] + + for (const part of parts) { + if (!isContentPart(part)) continue + const type = String(part.type) + if (type === 'text') { + sawContentPart = true + const value = part.text + if (value != null) text.push(String(value)) + } else if (IMAGE_PART_TYPES.has(type)) { + sawContentPart = true + text.push('[screenshot]') + } + } + + return sawContentPart ? text.filter(Boolean).join('\n') : null +} + +function summarizeMultimodalEnvelope(value: Record): string | null { + if (value._multimodal !== true && !Array.isArray(value.content)) return null + const parts = Array.isArray(value.content) ? value.content : [] + if (!parts.length) return null + return summarizeContentParts(parts) +} + +function redactDataImages(value: unknown): unknown { + if (typeof value === 'string') return value.replace(DATA_IMAGE_RE, '[screenshot]') + if (Array.isArray(value)) return value.map(redactDataImages) + if (!isPlainRecord(value)) return value + + const cleaned: Record = {} + for (const [key, child] of Object.entries(value)) { + cleaned[key] = redactDataImages(child) + } + return cleaned +} + +function summarizeKnownMultimodalContent(value: unknown): string | null { + if (Array.isArray(value)) { + return summarizeContentParts(value) + } + + if (isPlainRecord(value)) { + return summarizeMultimodalEnvelope(value) + } + + return null +} + +function serializeStructuredMessageContent(value: unknown): string | null { + const summary = summarizeKnownMultimodalContent(value) + if (summary != null) return summary + if (Array.isArray(value) || isPlainRecord(value)) return JSON.stringify(redactDataImages(value)) + return null +} + +function shouldTryParseStructuredString(value: string): boolean { + const trimmed = value.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return false + if (trimmed.includes('_multimodal') || trimmed.includes('data:image/')) return true + return ( + trimmed.includes('"image_url"') || + trimmed.includes('"input_image"') || + trimmed.includes('"type":"image"') || + trimmed.includes('"type": "image"') + ) +} + +export function normalizeMessageContentForStorage(content: unknown): string { + if (typeof content === 'string') { + if (shouldTryParseStructuredString(content)) { + try { + const parsed = JSON.parse(content.trim()) + const summary = summarizeKnownMultimodalContent(parsed) + if (summary != null) return summary + return JSON.stringify(redactDataImages(parsed)) + } catch { + // Fall back to direct redaction below. + } + } + return content.replace(DATA_IMAGE_RE, '[screenshot]') + } + + const normalized = serializeStructuredMessageContent(content) + if (normalized != null) return normalized + return String(content ?? '') +} + +export function normalizeMessageContentForStorageRole(role: string | undefined | null, content: string): string { + return role === 'user' ? content : normalizeMessageContentForStorage(content) +} diff --git a/packages/server/src/db/hermes/schemas.ts b/packages/server/src/db/hermes/schemas.ts new file mode 100644 index 0000000..4bb9bed --- /dev/null +++ b/packages/server/src/db/hermes/schemas.ts @@ -0,0 +1,395 @@ +/** + * Centralized schema definitions for all Hermes SQLite tables. + * All table schemas are defined here for unified management and migration. + */ + +// ============================================================================ +// Usage Store (usage-store.ts) +// ============================================================================ + +export const USAGE_TABLE = 'session_usage' + +export const USAGE_SCHEMA: Record = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + session_id: 'TEXT NOT NULL', + input_tokens: 'INTEGER NOT NULL DEFAULT 0', + output_tokens: 'INTEGER NOT NULL DEFAULT 0', + cache_read_tokens: 'INTEGER NOT NULL DEFAULT 0', + cache_write_tokens: 'INTEGER NOT NULL DEFAULT 0', + reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0', + model: "TEXT NOT NULL DEFAULT ''", + profile: "TEXT NOT NULL DEFAULT 'default'", + created_at: 'INTEGER NOT NULL DEFAULT 0', +} + +// ============================================================================ +// Session Store (session-store.ts) +// ============================================================================ + +export const SESSIONS_TABLE = 'sessions' + +export const SESSIONS_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + profile: 'TEXT NOT NULL DEFAULT \'default\'', + source: 'TEXT NOT NULL DEFAULT \'api_server\'', + user_id: 'TEXT', + model: 'TEXT NOT NULL DEFAULT \'\'', + provider: 'TEXT NOT NULL DEFAULT \'\'', + title: 'TEXT', + started_at: 'INTEGER NOT NULL', + ended_at: 'INTEGER', + end_reason: 'TEXT', + message_count: 'INTEGER NOT NULL DEFAULT 0', + tool_call_count: 'INTEGER NOT NULL DEFAULT 0', + input_tokens: 'INTEGER NOT NULL DEFAULT 0', + output_tokens: 'INTEGER NOT NULL DEFAULT 0', + cache_read_tokens: 'INTEGER NOT NULL DEFAULT 0', + cache_write_tokens: 'INTEGER NOT NULL DEFAULT 0', + reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0', + billing_provider: 'TEXT', + estimated_cost_usd: 'REAL NOT NULL DEFAULT 0', + actual_cost_usd: 'REAL', + cost_status: 'TEXT NOT NULL DEFAULT \'\'', + preview: 'TEXT NOT NULL DEFAULT \'\'', + last_active: 'INTEGER NOT NULL', + workspace: 'TEXT', +} + +export const MESSAGES_TABLE = 'messages' + +export const MESSAGES_SCHEMA: Record = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + session_id: 'TEXT NOT NULL', + role: 'TEXT NOT NULL', + content: 'TEXT NOT NULL DEFAULT \'\'', + tool_call_id: 'TEXT', + tool_calls: 'TEXT', + tool_name: 'TEXT', + timestamp: 'INTEGER NOT NULL', + token_count: 'INTEGER', + finish_reason: 'TEXT', + reasoning: 'TEXT', + reasoning_details: 'TEXT', + reasoning_content: 'TEXT', +} + +export const MESSAGES_INDEX = 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)' + +// ============================================================================ +// Compression Snapshot (compression-snapshot.ts) +// ============================================================================ + +export const COMPRESSION_SNAPSHOT_TABLE = 'chat_compression_snapshots' + +export const COMPRESSION_SNAPSHOT_SCHEMA: Record = { + session_id: 'TEXT PRIMARY KEY', + summary: 'TEXT NOT NULL DEFAULT \'\'', + last_message_index: 'INTEGER NOT NULL DEFAULT 0', + message_count_at_time: 'INTEGER NOT NULL DEFAULT 0', + updated_at: 'INTEGER NOT NULL', +} + +// ============================================================================ +// Model Context (model-context.ts) +// ============================================================================ + +export const MODEL_CONTEXT_TABLE = 'model_context' + +export const MODEL_CONTEXT_SCHEMA: Record = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + provider: 'TEXT NOT NULL', + model: 'TEXT NOT NULL', + context_limit: 'INTEGER NOT NULL', +} + +export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)' + +// ============================================================================ +// Users and Profile Access +// ============================================================================ + +export const USERS_TABLE = 'users' + +export const USERS_SCHEMA: Record = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + username: 'TEXT NOT NULL UNIQUE', + password_hash: 'TEXT NOT NULL', + role: "TEXT NOT NULL DEFAULT 'admin'", + status: "TEXT NOT NULL DEFAULT 'active'", + created_at: 'INTEGER NOT NULL', + updated_at: 'INTEGER NOT NULL', + last_login_at: 'INTEGER', +} + +export const USER_PROFILES_TABLE = 'user_profiles' + +export const USER_PROFILES_SCHEMA: Record = { + user_id: 'INTEGER NOT NULL', + profile_name: "TEXT NOT NULL DEFAULT 'default'", + is_default: 'INTEGER NOT NULL DEFAULT 0', + created_at: 'INTEGER NOT NULL', +} + +export const USER_PROFILES_INDEXES = { + idx_user_profiles_user: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_user ON user_profiles(user_id)', + idx_user_profiles_profile: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_profile ON user_profiles(profile_name)', + idx_user_profiles_default: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_default ON user_profiles(user_id) WHERE is_default = 1', +} + +// ============================================================================ +// Group Chat (services/hermes/group-chat/index.ts) +// ============================================================================ + +export const GC_ROOMS_TABLE = 'gc_rooms' + +export const GC_ROOMS_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + name: 'TEXT NOT NULL', + inviteCode: 'TEXT UNIQUE', + triggerTokens: 'INTEGER NOT NULL DEFAULT 100000', + maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000', + tailMessageCount: 'INTEGER NOT NULL DEFAULT 10', + totalTokens: 'INTEGER NOT NULL DEFAULT 0', + sessionSeed: "TEXT NOT NULL DEFAULT '0'", +} + +export const GC_MESSAGES_TABLE = 'gc_messages' + +export const GC_MESSAGES_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + roomId: 'TEXT NOT NULL', + senderId: 'TEXT NOT NULL', + senderName: 'TEXT NOT NULL', + content: 'TEXT NOT NULL', + timestamp: 'INTEGER NOT NULL', + role: "TEXT NOT NULL DEFAULT 'user'", + tool_call_id: 'TEXT', + tool_calls: 'TEXT', + tool_name: 'TEXT', + finish_reason: 'TEXT', + reasoning: 'TEXT', + reasoning_details: 'TEXT', + reasoning_content: 'TEXT', +} + +export const GC_ROOM_AGENTS_TABLE = 'gc_room_agents' + +export const GC_ROOM_AGENTS_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + roomId: 'TEXT NOT NULL', + agentId: 'TEXT NOT NULL', + profile: 'TEXT NOT NULL', + name: 'TEXT NOT NULL', + description: "TEXT NOT NULL DEFAULT ''", + invited: 'INTEGER NOT NULL DEFAULT 0', +} + +export const GC_CONTEXT_SNAPSHOTS_TABLE = 'gc_context_snapshots' + +export const GC_CONTEXT_SNAPSHOTS_SCHEMA: Record = { + roomId: 'TEXT PRIMARY KEY', + summary: 'TEXT NOT NULL DEFAULT \'\'', + lastMessageId: 'TEXT NOT NULL', + lastMessageTimestamp: 'INTEGER NOT NULL', + updatedAt: 'INTEGER NOT NULL', +} + +export const GC_ROOM_MEMBERS_TABLE = 'gc_room_members' + +export const GC_ROOM_MEMBERS_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + roomId: 'TEXT NOT NULL', + userId: 'TEXT NOT NULL', + userName: 'TEXT NOT NULL', + description: "TEXT NOT NULL DEFAULT ''", + joinedAt: 'INTEGER NOT NULL', + updatedAt: 'INTEGER NOT NULL', +} + +export const GC_PENDING_SESSION_DELETES_TABLE = 'gc_pending_session_deletes' + +export const GC_PENDING_SESSION_DELETES_SCHEMA: Record = { + session_id: 'TEXT PRIMARY KEY', + profile_name: 'TEXT NOT NULL', + status: "TEXT NOT NULL DEFAULT 'pending'", + attempt_count: 'INTEGER NOT NULL DEFAULT 0', + last_error: 'TEXT', + created_at: 'INTEGER NOT NULL', + updated_at: 'INTEGER NOT NULL', + next_attempt_at: 'INTEGER NOT NULL DEFAULT 0', +} + +export const GC_SESSION_PROFILES_TABLE = 'gc_session_profiles' + +export const GC_SESSION_PROFILES_SCHEMA: Record = { + session_id: 'TEXT PRIMARY KEY', + room_id: 'TEXT NOT NULL', + agent_id: 'TEXT NOT NULL', + profile_name: 'TEXT NOT NULL', + created_at: 'INTEGER NOT NULL', +} + +// ============================================================================ +// Schema Sync Utilities +// ============================================================================ + +import { getDb, getStoragePath } from '../index' + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"` +} + +/** + * 检查表是否存在 + */ +function tableExists(db: NonNullable>, tableName: string): boolean { + const result = db.prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?` + ).get(tableName) + return !!result +} + +/** + * 创建表(带完整 schema) + */ +function createTable( + db: NonNullable>, + tableName: string, + schema: Record, + primaryKey?: string +): void { + const colDefs = Object.entries(schema).map(([col, def]) => `${quoteIdentifier(col)} ${def}`) + + // 只在 schema 中没有主键时才添加复合主键 + const hasPrimaryKeyInSchema = Object.values(schema).some((def) => + def.toUpperCase().includes("PRIMARY KEY") + ) + + if (primaryKey && !hasPrimaryKeyInSchema) { + colDefs.push(`PRIMARY KEY (${primaryKey})`) + } + + db.exec(`CREATE TABLE ${quoteIdentifier(tableName)} (${colDefs.join(', ')})`) +} + +function canAddColumnToExistingTable(schemaDef: string): boolean { + const normalized = schemaDef.toUpperCase() + if (normalized.includes('PRIMARY KEY')) return false + if (normalized.includes('NOT NULL') && !normalized.includes('DEFAULT')) return false + return true +} + +function addMissingSafeColumns( + db: NonNullable>, + tableName: string, + schema: Record, +): void { + const columns = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as Array<{ name: string }> + const existingColumns = new Set(columns.map(col => col.name)) + + for (const [columnName, columnDef] of Object.entries(schema)) { + if (existingColumns.has(columnName)) continue + if (!canAddColumnToExistingTable(columnDef)) { + console.warn(`[Schema] ${tableName}.${columnName} cannot be added safely to existing table; skipping`) + continue + } + db.exec(`ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${quoteIdentifier(columnName)} ${columnDef}`) + } +} + +/** + * 主同步函数 + * - 表不存在:创建 + * - 表存在:只追加安全的新列,不删除、不重建、不修改主键/类型 + */ +export function syncTable( + tableName: string, + schema: Record, + options?: { + primaryKey?: string // 主键定义,如 "roomId, agentId" 或 "id" + indexes?: Record // 索引定义 + } +): void { + const db = getDb() + if (!db) return + + // 1. 表不存在 → 直接创建 + if (!tableExists(db, tableName)) { + createTable(db, tableName, schema, options?.primaryKey) + + // 创建索引 + if (options?.indexes) { + for (const indexSQL of Object.values(options.indexes)) { + db.exec(indexSQL) + } + } + return + } + + addMissingSafeColumns(db, tableName, schema) +} + +// ============================================================================ +// Unified Initializer +// ============================================================================ + +/** + * Initialize missing Hermes SQLite tables with proper schemas. + * Existing tables only receive safe additive columns. + * Call this once at application bootstrap. + */ +export function initAllHermesTables(): void { + const db = getDb() + if (!db) return + + try { + // Usage store + syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' }) + + // Session store + syncTable(SESSIONS_TABLE, SESSIONS_SCHEMA) + syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA) + db.exec(MESSAGES_INDEX) + + // Compression snapshot + syncTable(COMPRESSION_SNAPSHOT_TABLE, COMPRESSION_SNAPSHOT_SCHEMA) + + // Model context + syncTable(MODEL_CONTEXT_TABLE, MODEL_CONTEXT_SCHEMA, { + indexes: { + idx_model_context_provider_model: MODEL_CONTEXT_INDEX, + } + }) + + // Users and profile access + syncTable(USERS_TABLE, USERS_SCHEMA) + syncTable(USER_PROFILES_TABLE, USER_PROFILES_SCHEMA, { + primaryKey: 'user_id, profile_name', + indexes: USER_PROFILES_INDEXES, + }) + + // Group chat - basic tables + syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA) + syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA) + syncTable(GC_CONTEXT_SNAPSHOTS_TABLE, GC_CONTEXT_SNAPSHOTS_SCHEMA) + syncTable(GC_PENDING_SESSION_DELETES_TABLE, GC_PENDING_SESSION_DELETES_SCHEMA) + syncTable(GC_SESSION_PROFILES_TABLE, GC_SESSION_PROFILES_SCHEMA) + + // Group chat - single-column primary key tables (PRIMARY KEY in column definition) + syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, { + indexes: { + idx_gc_room_agents_profile: 'CREATE INDEX idx_gc_room_agents_profile ON gc_room_agents(profile)', + } + }) + + syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, { + indexes: { + idx_gc_room_members_user: 'CREATE INDEX idx_gc_room_members_user ON gc_room_members(userId)', + } + }) + } catch (e) { + console.error('Error initializing Hermes SQLite tables:', e) + console.error(`[Schema] Database initialization failed. Existing database was left untouched: ${getStoragePath()}`) + throw e + } +} diff --git a/packages/server/src/db/hermes/session-store.ts b/packages/server/src/db/hermes/session-store.ts new file mode 100644 index 0000000..26c35a8 --- /dev/null +++ b/packages/server/src/db/hermes/session-store.ts @@ -0,0 +1,490 @@ +/** + * Self-built session database — completely replaces Hermes CLI dependency. + * Uses the same ensureTable/getDb pattern as usage-store.ts. + */ +import { isSqliteAvailable, getDb } from '../index' +import { SESSIONS_TABLE, MESSAGES_TABLE } from './schemas' +import { normalizeMessageContentForStorageRole } from './message-content' + +// Re-export types for compatibility with sessions-db.ts consumers +export interface HermesSessionRow { + id: string + profile: string + source: string + user_id: string | null + model: string + provider: string + title: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + preview: string + last_active: number + workspace: string | null +} + +export interface HermesMessageRow { + id: number | string + session_id: string + role: string + content: string + tool_call_id: string | null + tool_calls: any[] | null + tool_name: string | null + timestamp: number + token_count: number | null + finish_reason: string | null + reasoning: string | null + reasoning_details?: string | null + reasoning_content?: string | null +} + +export interface HermesSessionSearchRow extends HermesSessionRow { + snippet: string + matched_message_id: number | null +} + +export interface HermesSessionDetailRow extends HermesSessionRow { + messages: HermesMessageRow[] + thread_session_count: number +} + +// Note: Table schemas and initialization are now centralized in schemas.ts +// Tables are created automatically on bootstrap via initAllHermesTables() + +// --- Helpers --- + +function parseToolCalls(value: unknown): any[] | null { + if (value == null || value === '') return null + if (Array.isArray(value)) return value + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +function mapSessionRow(row: Record): HermesSessionRow { + const rawTitle = row.title != null ? String(row.title) : null + const preview = String(row.preview || '') + const title = rawTitle || (preview ? (preview.length > 40 ? preview.slice(0, 40) + '...' : preview) : null) + return { + id: String(row.id || ''), + profile: String(row.profile || 'default'), + source: String(row.source || 'api_server'), + user_id: row.user_id != null ? String(row.user_id) : null, + model: String(row.model || ''), + provider: String(row.provider || ''), + title, + started_at: Number(row.started_at || 0), + ended_at: row.ended_at != null ? Number(row.ended_at) : null, + end_reason: row.end_reason != null ? String(row.end_reason) : null, + message_count: Number(row.message_count || 0), + tool_call_count: Number(row.tool_call_count || 0), + input_tokens: Number(row.input_tokens || 0), + output_tokens: Number(row.output_tokens || 0), + cache_read_tokens: Number(row.cache_read_tokens || 0), + cache_write_tokens: Number(row.cache_write_tokens || 0), + reasoning_tokens: Number(row.reasoning_tokens || 0), + billing_provider: row.billing_provider != null ? String(row.billing_provider) : null, + estimated_cost_usd: Number(row.estimated_cost_usd || 0), + actual_cost_usd: row.actual_cost_usd != null ? Number(row.actual_cost_usd) : null, + cost_status: String(row.cost_status || ''), + preview: String(row.preview || ''), + last_active: Number(row.last_active || 0), + workspace: row.workspace != null ? String(row.workspace) : null, + } +} + +function mapMessageRow(row: Record): HermesMessageRow { + return { + id: typeof row.id === 'number' ? row.id : Number(row.id), + session_id: String(row.session_id || ''), + role: String(row.role || ''), + content: row.content != null ? String(row.content) : '', + tool_call_id: row.tool_call_id != null ? String(row.tool_call_id) : null, + tool_calls: parseToolCalls(row.tool_calls), + tool_name: row.tool_name != null ? String(row.tool_name) : null, + timestamp: Number(row.timestamp || 0), + token_count: row.token_count != null ? Number(row.token_count) : null, + finish_reason: row.finish_reason != null ? String(row.finish_reason) : null, + reasoning: row.reasoning != null ? String(row.reasoning) : null, + reasoning_details: row.reasoning_details != null ? String(row.reasoning_details) : null, + reasoning_content: row.reasoning_content != null ? String(row.reasoning_content) : null, + } +} + +// --- Session CRUD --- + +export function createSession(data: { + id: string + profile?: string + source?: string + model?: string + provider?: string + title?: string + workspace?: string +}): HermesSessionRow { + const now = Math.floor(Date.now() / 1000) + const source = data.source || 'api_server' + if (!isSqliteAvailable()) { + return { + id: data.id, profile: data.profile || 'default', source, + user_id: null, model: data.model || '', provider: data.provider || '', title: data.title || null, + started_at: now, ended_at: null, end_reason: null, + message_count: 0, tool_call_count: 0, + input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, + billing_provider: null, estimated_cost_usd: 0, actual_cost_usd: null, + cost_status: '', preview: '', last_active: now, workspace: data.workspace || null, + } + } + const db = getDb()! + db.prepare( + `INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, provider, title, started_at, last_active, workspace) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(data.id, data.profile || 'default', source, data.model || '', data.provider || '', data.title || null, now, now, data.workspace || null) + return getSession(data.id)! +} + +export function getSession(id: string): HermesSessionRow | null { + if (!isSqliteAvailable()) return null + const db = getDb()! + const row = db.prepare( + `SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`, + ).get(id) as Record | undefined + return row ? mapSessionRow(row) : null +} + +export function updateSession(id: string, data: Partial>): void { + if (!isSqliteAvailable()) return + const db = getDb()! + const fields: string[] = [] + const values: any[] = [] + for (const [key, val] of Object.entries(data)) { + if (key === 'id' || key === 'profile') continue + // Skip last_active and ended_at - handle them separately below + if (key === 'last_active' || key === 'ended_at') continue + fields.push(`"${key}" = ?`) + values.push(val) + } + + // Handle ended_at - only update if provided, otherwise keep existing value + if (data.ended_at !== undefined) { + fields.push(`"ended_at" = ?`) + values.push(data.ended_at) + } + + // Handle last_active - use provided value or current time + if (data.last_active !== undefined) { + fields.push(`"last_active" = ?`) + values.push(data.last_active) + } + + if (fields.length === 0) return + db.prepare(`UPDATE ${SESSIONS_TABLE} SET ${fields.join(', ')} WHERE id = ?`).run(...values, id) +} + +export function deleteSession(id: string): boolean { + if (!isSqliteAvailable()) return false + const db = getDb()! + db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id) + const result = db.prepare(`DELETE FROM ${SESSIONS_TABLE} WHERE id = ?`).run(id) + return result.changes > 0 +} + +export function clearSessionMessages(id: string): number { + if (!isSqliteAvailable()) return 0 + const db = getDb()! + const result = db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id) + updateSessionStats(id) + return Number(result.changes) +} + +export function renameSession(id: string, title: string): boolean { + if (!isSqliteAvailable()) return false + const db = getDb()! + const result = db.prepare(`UPDATE ${SESSIONS_TABLE} SET title = ? WHERE id = ?`).run(title, id) + return result.changes > 0 +} + +export function listSessions(profile?: string, source?: string, limit = 2000): HermesSessionRow[] { + if (!isSqliteAvailable()) return [] + const db = getDb()! + const profileFilter = profile?.trim() + + // Use a subquery to generate preview from first user message if not set + const sql = ` + SELECT + s.*, + COALESCE( + s.preview, + ( + SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 63) + FROM ${MESSAGES_TABLE} m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id + LIMIT 1 + ), + '' + ) AS preview + FROM ${SESSIONS_TABLE} s + WHERE 1 = 1 + ${profileFilter ? 'AND s.profile = ?' : ''} + ${source ? 'AND s.source = ?' : ''} + ORDER BY s.last_active DESC + LIMIT ? + ` + + const params: any[] = [] + if (profileFilter) { + params.push(profileFilter) + } + if (source) { + params.push(source) + } + params.push(limit) + + const rows = db.prepare(sql).all(...params) as Record[] + return rows.map(mapSessionRow) +} + +export function searchSessions(profile: string | null | undefined, query: string, limit = 20): HermesSessionSearchRow[] { + if (!isSqliteAvailable()) return [] + const profileFilter = profile?.trim() + const trimmed = query.trim() + if (!trimmed) { + return listSessions(profileFilter, undefined, limit).map(s => ({ ...s, snippet: s.preview || '', matched_message_id: null })) + } + const db = getDb()! + const lowered = trimmed.toLowerCase() + const pattern = `%${lowered}%` + + // Step 1: Find matching sessions + const sessionRows = db.prepare( + `SELECT * FROM ${SESSIONS_TABLE} + WHERE 1 = 1 + ${profileFilter ? 'AND profile = ?' : ''} + AND ( + LOWER(title) LIKE ? OR LOWER(preview) LIKE ? + OR id IN (SELECT DISTINCT session_id FROM ${MESSAGES_TABLE} WHERE LOWER(content) LIKE ? OR LOWER(COALESCE(tool_name, '')) LIKE ?) + ) + ORDER BY last_active DESC LIMIT ?`, + ).all(...[ + ...(profileFilter ? [profileFilter] : []), + pattern, + pattern, + pattern, + pattern, + limit, + ]) as Record[] + + if (sessionRows.length === 0) return [] + + // Step 2: For each session, find first matching message id + snippet + const msgQuery = db.prepare( + `SELECT id, content, tool_name FROM ${MESSAGES_TABLE} + WHERE session_id = ? AND (LOWER(content) LIKE ? OR LOWER(COALESCE(tool_name, '')) LIKE ?) + ORDER BY timestamp, id LIMIT 1`, + ) + + return sessionRows.map(row => { + const session = mapSessionRow(row) + let snippet = '' + let matched_message_id: number | null = null + + // Check if session title or preview matches + const titleLower = (session.title || '').toLowerCase() + const previewLower = (session.preview || '').toLowerCase() + const titleIdx = titleLower.indexOf(lowered) + const previewIdx = previewLower.indexOf(lowered) + + if (titleIdx >= 0) { + snippet = session.title!.substring(Math.max(0, titleIdx - 20), titleIdx + lowered.length + 60) + } else if (previewIdx >= 0) { + snippet = session.preview.substring(Math.max(0, previewIdx - 20), previewIdx + lowered.length + 60) + } else { + // Get snippet from matching message + const msg = msgQuery.get(session.id, pattern, pattern) as { id: number; content: string; tool_name: string | null } | undefined + if (msg) { + matched_message_id = msg.id + const contentLower = msg.content.toLowerCase() + const idx = contentLower.indexOf(lowered) + snippet = msg.content.substring(Math.max(0, idx - 20), idx + lowered.length + 60) + } + } + + return { ...session, snippet, matched_message_id } + }) +} + +export interface PaginatedSessionDetailResult { + session: HermesSessionRow + messages: HermesMessageRow[] + total: number + offset: number + limit: number + hasMore: boolean +} + +export function getSessionDetail(id: string): HermesSessionDetailRow | null { + if (!isSqliteAvailable()) return null + const db = getDb()! + const sessionRow = db.prepare(`SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`).get(id) as Record | undefined + if (!sessionRow) return null + const msgRows = db.prepare( + `SELECT * FROM ${MESSAGES_TABLE} WHERE session_id = ? ORDER BY id`, + ).all(id) as Record[] + const session = mapSessionRow(sessionRow) + return { + ...session, + messages: msgRows.map(mapMessageRow), + thread_session_count: 1, + } +} + +// --- Message CRUD --- + +export function addMessage(msg: { + session_id: string + role: string + content: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + timestamp?: number + token_count?: number | null + finish_reason?: string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null +}): number | undefined { + if (!isSqliteAvailable()) return undefined + const db = getDb()! + const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null + const result = db.prepare( + `INSERT INTO ${MESSAGES_TABLE} (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_details, reasoning_content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + msg.session_id, msg.role, normalizeMessageContentForStorageRole(msg.role, msg.content), + msg.tool_call_id ?? null, toolCallsJson, msg.tool_name ?? null, + msg.timestamp ?? Math.floor(Date.now() / 1000), + msg.token_count ?? null, msg.finish_reason ?? null, + msg.reasoning ?? null, msg.reasoning_details ?? null, + msg.reasoning_content ?? null, + ) + return result.lastInsertRowid as number +} + +export function addMessages(msgs: Array<{ + session_id: string + role: string + content: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + timestamp?: number + token_count?: number | null + finish_reason?: string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null +}>): void { + if (!isSqliteAvailable() || msgs.length === 0) return + const db = getDb()! + const insert = db.prepare( + `INSERT INTO ${MESSAGES_TABLE} (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_details, reasoning_content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + db.exec('BEGIN') + try { + for (const msg of msgs) { + const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null + insert.run( + msg.session_id, msg.role, normalizeMessageContentForStorageRole(msg.role, msg.content), + msg.tool_call_id ?? null, toolCallsJson, msg.tool_name ?? null, + msg.timestamp ?? Math.floor(Date.now() / 1000), + msg.token_count ?? null, msg.finish_reason ?? null, + msg.reasoning ?? null, msg.reasoning_details ?? null, + msg.reasoning_content ?? null, + ) + } + db.exec('COMMIT') + } catch (e) { + db.exec('ROLLBACK') + throw e + } +} + +export function getMessageCount(sessionId: string): number { + if (!isSqliteAvailable()) return 0 + const db = getDb()! + const row = db.prepare( + `SELECT COUNT(*) as cnt FROM ${MESSAGES_TABLE} WHERE session_id = ?`, + ).get(sessionId) as { cnt: number } | undefined + return row?.cnt ?? 0 +} + +export function updateSessionStats(id: string): void { + if (!isSqliteAvailable()) return + const db = getDb()! + db.prepare( + `UPDATE ${SESSIONS_TABLE} + SET message_count = (SELECT COUNT(*) FROM ${MESSAGES_TABLE} WHERE session_id = ?), + last_active = COALESCE((SELECT MAX(timestamp) FROM ${MESSAGES_TABLE} WHERE session_id = ?), started_at) + WHERE id = ?`, + ).run(id, id, id) + console.log(`Updated session ${id} stats`) +} + +export function getSessionDetailPaginated( + id: string, + offset = 0, + limit = 300, +): PaginatedSessionDetailResult | null { + if (!isSqliteAvailable()) { + return null + } + + const db = getDb()! + + // Get session info + const sessionRow = db.prepare(`SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`).get(id) as Record | undefined + if (!sessionRow) return null + + // Get total message count + const countResult = db.prepare( + `SELECT COUNT(*) as total FROM ${MESSAGES_TABLE} WHERE session_id = ?`, + ).get(id) as { total: number } | undefined + const total = countResult?.total || 0 + + // Get paginated messages (newest first from DB, then reverse). + // Timestamp precision is mixed across message sources; id is insertion order. + const msgRows = db.prepare( + `SELECT * FROM ${MESSAGES_TABLE} WHERE session_id = ? ORDER BY id DESC LIMIT ? OFFSET ?`, + ).all(id, limit, offset) as Record[] + + const session = mapSessionRow(sessionRow) + const messages = msgRows.map(mapMessageRow).reverse() // Reverse to show oldest first + + return { + session, + messages, + total, + offset, + limit, + hasMore: offset + messages.length < total, + } +} diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts new file mode 100644 index 0000000..130745b --- /dev/null +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -0,0 +1,1538 @@ +import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile' +import { join } from 'path' +import type { LocalUsageStats } from './usage-store' + +const SQLITE_AVAILABLE = (() => { + const [major, minor] = process.versions.node.split('.').map(Number) + return major > 22 || (major === 22 && minor >= 5) +})() + +const COMPRESSION_END_REASONS = new Set(['compression', 'compressed']) +const SEARCH_CANDIDATE_MULTIPLIER = 20 +const SEARCH_CANDIDATE_MIN = 100 + +export interface HermesSessionRow { + id: string + source: string + user_id: string | null + model: string + title: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + preview: string + last_active: number +} + +export interface HermesSessionSearchRow extends HermesSessionRow { + matched_message_id: number | null + snippet: string + rank: number +} + +export interface HermesMessageRow { + id: number | string + session_id: string + role: string + content: string + tool_call_id: string | null + tool_calls: any[] | null + tool_name: string | null + timestamp: number + token_count: number | null + finish_reason: string | null + reasoning: string | null + reasoning_details?: string | null + reasoning_content?: string | null +} + +export interface HermesSessionDetailRow extends HermesSessionRow { + messages: HermesMessageRow[] + thread_session_count: number +} + +export interface PaginatedHermesSessionDetailResult { + session: HermesSessionDetailRow + messages: HermesMessageRow[] + total: number + offset: number + limit: number + hasMore: boolean +} + +interface HermesSessionInternalRow extends HermesSessionRow { + parent_session_id: string | null +} + +function sessionDbPath(): string { + return join(getActiveProfileDir(), 'state.db') +} + +function normalizeNumber(value: unknown, fallback = 0): number { + if (value == null || value === '') return fallback + const num = Number(value) + return Number.isFinite(num) ? num : fallback +} + +function normalizeNullableNumber(value: unknown): number | null { + if (value == null || value === '') return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function normalizeNullableString(value: unknown): string | null { + if (value == null || value === '') return null + return String(value) +} + +function titleFromPreview(preview: string): string | null { + if (!preview) return null + return preview.length > 40 ? `${preview.slice(0, 40)}...` : preview +} + +function mapRow(row: Record): HermesSessionRow { + const startedAt = normalizeNumber(row.started_at) + return { + id: String(row.id || ''), + source: String(row.source || ''), + user_id: normalizeNullableString(row.user_id), + model: String(row.model || ''), + title: normalizeNullableString(row.title), + started_at: startedAt, + ended_at: normalizeNullableNumber(row.ended_at), + end_reason: normalizeNullableString(row.end_reason), + message_count: normalizeNumber(row.message_count), + tool_call_count: normalizeNumber(row.tool_call_count), + input_tokens: normalizeNumber(row.input_tokens), + output_tokens: normalizeNumber(row.output_tokens), + cache_read_tokens: normalizeNumber(row.cache_read_tokens), + cache_write_tokens: normalizeNumber(row.cache_write_tokens), + reasoning_tokens: normalizeNumber(row.reasoning_tokens), + billing_provider: normalizeNullableString(row.billing_provider), + estimated_cost_usd: normalizeNumber(row.estimated_cost_usd), + actual_cost_usd: normalizeNullableNumber(row.actual_cost_usd), + cost_status: String(row.cost_status || ''), + preview: String(row.preview || ''), + last_active: normalizeNumber(row.last_active, startedAt), + } +} + +const SESSION_SELECT = ` + s.id, + s.source, + COALESCE(s.user_id, '') AS user_id, + COALESCE(s.model, '') AS model, + COALESCE(s.title, '') AS title, + COALESCE(s.started_at, 0) AS started_at, + s.ended_at AS ended_at, + COALESCE(s.end_reason, '') AS end_reason, + COALESCE(s.message_count, 0) AS message_count, + COALESCE(s.tool_call_count, 0) AS tool_call_count, + COALESCE(s.input_tokens, 0) AS input_tokens, + COALESCE(s.output_tokens, 0) AS output_tokens, + COALESCE(s.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(s.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(s.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(s.billing_provider, '') AS billing_provider, + COALESCE(s.estimated_cost_usd, 0) AS estimated_cost_usd, + s.actual_cost_usd AS actual_cost_usd, + COALESCE(s.cost_status, '') AS cost_status, + COALESCE( + ( + SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 63) + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id + LIMIT 1 + ), + '' + ) AS preview, + COALESCE((SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), s.started_at) AS last_active +` + +function containsCjk(text: string): boolean { + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0 + if ( + (cp >= 0x4E00 && cp <= 0x9FFF) || + (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x20000 && cp <= 0x2A6DF) || + (cp >= 0x3000 && cp <= 0x303F) || + (cp >= 0x3040 && cp <= 0x309F) || + (cp >= 0x30A0 && cp <= 0x30FF) || + (cp >= 0xAC00 && cp <= 0xD7AF) + ) { + return true + } + } + return false +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`) +} + +function buildLikePattern(value: string): string { + return `%${escapeLikePattern(value)}%` +} + +function normalizeTitleLikeQuery(query: string): string { + const tokens = query.match(/"[^"]*"\*?|\S+/g) + if (!tokens) return query + + const normalizedTokens = tokens + .map((token) => { + let value = token.endsWith('*') ? token.slice(0, -1) : token + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1) + } + return value + }) + .filter(Boolean) + + return normalizedTokens.join(' ').trim() || query +} + +function shouldUseLiteralContentSearch(query: string): boolean { + const trimmed = query.trim() + if (!trimmed) return false + if (/[^\p{L}\p{N}\s"*.-]/u.test(trimmed)) return true + + const tokens = trimmed.match(/"[^"]*"\*?|\S+/g) + if (!tokens) return true + + for (const token of tokens) { + if (/^(AND|OR|NOT)$/i.test(token)) continue + + const raw = token.endsWith('*') ? token.slice(0, -1) : token + if (!raw) return true + + if (raw.startsWith('"') && raw.endsWith('"')) { + const inner = raw.slice(1, -1) + if (!inner.trim()) return true + if (!/^[\p{L}\p{N}\s.-]+$/u.test(inner)) return true + if ((inner.includes('.') || inner.includes('-')) && !/^[\p{L}\p{N}]+(?:[.-][\p{L}\p{N}]+)*(?:\s+[\p{L}\p{N}]+(?:[.-][\p{L}\p{N}]+)*)*$/u.test(inner)) return true + continue + } + + if (raw.includes('.') || raw.includes('-')) { + if (!/^[\p{L}\p{N}]+(?:[.-][\p{L}\p{N}]+)*$/u.test(raw)) return true + continue + } + + if (!/^[\p{L}\p{N}]+$/u.test(raw)) return true + } + + return false +} + +function runLiteralContentSearch( + db: { prepare: (sql: string) => { all: (...params: any[]) => Record[] } }, + source: string | undefined, + query: string, + limit: number, +): Record[] { + const loweredQuery = query.toLowerCase() + const likePattern = buildLikePattern(loweredQuery) + const sourceClause = source ? 'AND s.source = ?' : '' + const sourceParams = source ? [source] : [] + const likeSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT + base.*, + m.id AS matched_message_id, + substr( + m.content, + max(1, instr(LOWER(m.content), ?) - 40), + 120 + ) AS snippet, + 0 AS rank + FROM base + JOIN messages m ON m.session_id = base.id + WHERE LOWER(m.content) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT ? + ` + return db.prepare(likeSql).all(...sourceParams, loweredQuery, likePattern, limit) as Record[] +} + +function sanitizeFtsQuery(query: string): string { + const quotedParts: string[] = [] + + const preserved = query.replace(/"[^"]*"/g, (match) => { + quotedParts.push(match) + return `\u0000Q${quotedParts.length - 1}\u0000` + }) + + let sanitized = preserved.replace(/[+{}()"^]/g, ' ') + sanitized = sanitized.replace(/\*+/g, '*') + sanitized = sanitized.replace(/(^|\s)\*/g, '$1') + sanitized = sanitized.trim().replace(/^(AND|OR|NOT)\b\s*/i, '') + sanitized = sanitized.trim().replace(/\s+(AND|OR|NOT)\s*$/i, '') + sanitized = sanitized.replace(/\b([\p{L}\p{N}]+(?:[.-][\p{L}\p{N}]+)+)\b/gu, '"$1"') + + for (let i = 0; i < quotedParts.length; i += 1) { + sanitized = sanitized.replace(`\u0000Q${i}\u0000`, quotedParts[i]) + } + + return sanitized.trim() +} + +function toPrefixQuery(query: string): string { + const tokens = query.match(/"[^"]*"\*?|\S+/g) + if (!tokens) return '' + return tokens + .map((token) => { + if (token === 'AND' || token === 'OR' || token === 'NOT') return token + if (token.startsWith('"') && token.endsWith('"')) return token + if (token.endsWith('*')) return token + return `${token}*` + }) + .join(' ') +} + +function mapSearchRow(row: Record): HermesSessionSearchRow { + return { + ...mapRow(row), + matched_message_id: normalizeNullableNumber(row.matched_message_id), + snippet: String(row.snippet || row.preview || ''), + rank: Number.isFinite(Number(row.rank)) ? Number(row.rank) : 0, + } +} + +function mapInternalSessionRow(row: Record): HermesSessionInternalRow { + return { + ...mapRow(row), + parent_session_id: normalizeNullableString(row.parent_session_id), + } +} + +function parseToolCalls(value: unknown): any[] | null { + if (value == null || value === '') return null + if (Array.isArray(value)) return value + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +function normalizeMessageId(value: unknown): number | string { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'bigint') return Number(value) + const asNumber = Number(value) + if (Number.isInteger(asNumber)) return asNumber + return String(value || '') +} + +function mapMessageRow(row: Record): HermesMessageRow { + const reasoning = normalizeNullableString(row.reasoning) || normalizeNullableString(row.reasoning_content) + return { + id: normalizeMessageId(row.id), + session_id: String(row.session_id || ''), + role: String(row.role || ''), + content: row.content == null ? '' : String(row.content), + tool_call_id: normalizeNullableString(row.tool_call_id), + tool_calls: parseToolCalls(row.tool_calls), + tool_name: normalizeNullableString(row.tool_name), + timestamp: normalizeNumber(row.timestamp), + token_count: normalizeNullableNumber(row.token_count), + finish_reason: normalizeNullableString(row.finish_reason), + reasoning, + reasoning_details: normalizeNullableString(row.reasoning_details), + reasoning_content: normalizeNullableString(row.reasoning_content), + } +} + +function isCompressionEnded(session: HermesSessionInternalRow | undefined): boolean { + return !!session && COMPRESSION_END_REASONS.has(String(session.end_reason || '')) +} + +function isCompressionContinuation(parent: HermesSessionInternalRow | undefined, child: HermesSessionInternalRow | undefined): boolean { + if (!parent || !child || !isCompressionEnded(parent) || parent.ended_at == null) return false + return child.source !== 'tool' && Number(child.started_at || 0) >= Number(parent.ended_at || 0) +} + +function latestSessionInChain(chain: HermesSessionInternalRow[]): HermesSessionInternalRow { + return chain.reduce((latest, session) => { + const latestStarted = Number(latest.started_at || 0) + const sessionStarted = Number(session.started_at || 0) + if (sessionStarted !== latestStarted) return sessionStarted > latestStarted ? session : latest + return session.id.localeCompare(latest.id) > 0 ? session : latest + }, chain[0]) +} + +function projectSessionSummary(root: HermesSessionInternalRow, chain: HermesSessionInternalRow[]): HermesSessionRow { + const latest = latestSessionInChain(chain) + const firstPreview = chain.map(session => session.preview).find(Boolean) || root.preview + const { parent_session_id: _parentSessionId, ...rootRow } = root + return { + ...rootRow, + id: latest.id, + model: latest.model || root.model, + title: latest.title || root.title || titleFromPreview(firstPreview), + ended_at: latest.ended_at, + end_reason: latest.end_reason, + message_count: latest.message_count, + tool_call_count: latest.tool_call_count, + input_tokens: latest.input_tokens, + output_tokens: latest.output_tokens, + cache_read_tokens: latest.cache_read_tokens, + cache_write_tokens: latest.cache_write_tokens, + reasoning_tokens: latest.reasoning_tokens, + billing_provider: latest.billing_provider ?? root.billing_provider, + estimated_cost_usd: latest.estimated_cost_usd, + actual_cost_usd: latest.actual_cost_usd, + cost_status: latest.cost_status, + preview: latest.preview || root.preview || firstPreview || '', + last_active: latest.last_active || root.last_active, + } +} + +// --- In-memory session index for chain traversal --- + +interface SessionIndex { + byId: Map + childrenByParent: Map +} + +function loadAllSessions(db: { prepare: (sql: string) => { all: (...params: any[]) => Record[] } }): SessionIndex { + const rows = db.prepare(` + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + `).all() as Record[] + const sessions = rows.map(mapInternalSessionRow) + const byId = new Map(sessions.map(s => [s.id, s])) + const childrenByParent = new Map() + for (const s of sessions) { + const key = s.parent_session_id ?? '' + const list = childrenByParent.get(key) || [] + list.push(s.id) + childrenByParent.set(key, list) + } + return { byId, childrenByParent } +} + +function getLatestContinuationChild( + parent: HermesSessionInternalRow, + idx: SessionIndex, +): HermesSessionInternalRow | null { + if (!isCompressionEnded(parent) || parent.ended_at == null) return null + const candidates = (idx.childrenByParent.get(parent.id) || []) + .map(id => idx.byId.get(id)) + .filter((c): c is HermesSessionInternalRow => !!c) + .filter(c => Number(c.started_at || 0) >= Number(parent.ended_at || 0)) + .sort((a, b) => { + const aDelta = Number(a.started_at || 0) - Number(parent.ended_at || 0) + const bDelta = Number(b.started_at || 0) - Number(parent.ended_at || 0) + if (aDelta !== bDelta) return aDelta - bDelta + return b.id.localeCompare(a.id) + }) + return candidates[0] || null +} + +function collectCompressionPath( + session: HermesSessionInternalRow, + idx: SessionIndex, +): HermesSessionInternalRow[] { + const reversed: HermesSessionInternalRow[] = [session] + const seen = new Set() + let current: HermesSessionInternalRow | null = session + + for (let depth = 0; current && current.parent_session_id && depth < 100 && !seen.has(current.id); depth += 1) { + seen.add(current.id) + const parent = idx.byId.get(current.parent_session_id) + if (!parent || !isCompressionContinuation(parent, current)) break + reversed.push(parent) + current = parent + } + + return reversed.reverse() +} + +function extendCompressionChain( + chain: HermesSessionInternalRow[], + idx: SessionIndex, +): HermesSessionInternalRow[] { + const result = [...chain] + const seen = new Set(result.map(s => s.id)) + let current: HermesSessionInternalRow | null = result[result.length - 1] || null + + for (let depth = 0; current && depth < 100; depth += 1) { + const next = getLatestContinuationChild(current, idx) + if (!next || seen.has(next.id)) break + result.push(next) + seen.add(next.id) + current = next + } + + return result +} + +function collectSessionChain( + root: HermesSessionInternalRow, + idx: SessionIndex, +): HermesSessionInternalRow[] { + return extendCompressionChain([root], idx) +} + +function collectSessionChainForMatchedSession( + session: HermesSessionInternalRow, + idx: SessionIndex, +): HermesSessionInternalRow[] { + return extendCompressionChain(collectCompressionPath(session, idx), idx) +} + +type SessionDbLike = { + prepare: (sql: string) => { all: (...params: any[]) => Record[] } +} + +function searchCandidateLimit(limit: number): number { + return Math.max(limit * SEARCH_CANDIDATE_MULTIPLIER, SEARCH_CANDIDATE_MIN) +} + +function projectSearchRow( + row: Record, + idx: SessionIndex, + source?: string, +): HermesSessionSearchRow | null { + const matchedSession = mapInternalSessionRow(row) + if (!matchedSession.id) return null + + const chain = collectSessionChainForMatchedSession(matchedSession, idx) + const root = chain[0] + if (!root) return null + if (source && matchedSession.source !== source) return null + + const projected = projectSessionSummary(root, chain) + return { + ...projected, + matched_message_id: normalizeNullableNumber(row.matched_message_id), + snippet: String(row.snippet || row.preview || ''), + rank: Number.isFinite(Number(row.rank)) ? Number(row.rank) : 0, + } +} + +function aggregateSessionDetail( + chain: HermesSessionInternalRow[], + messages: HermesMessageRow[], + requestedSessionId: string, +): HermesSessionDetailRow { + const root = chain[0] + const latest = latestSessionInChain(chain) + const costStatuses = Array.from(new Set(chain.map(session => String(session.cost_status || '')).filter(Boolean))) + const actualCosts = chain + .map(session => session.actual_cost_usd) + .filter((value): value is number => value != null) + const firstPreview = chain.map(session => session.preview).find(Boolean) || root.preview + + const { parent_session_id: _parentSessionId, ...rootRow } = root + + return { + ...rootRow, + id: requestedSessionId, + source: latest.source || root.source, + title: latest.title || root.title || titleFromPreview(firstPreview), + preview: latest.preview || root.preview || firstPreview || '', + model: latest.model || root.model, + ended_at: latest.ended_at, + end_reason: latest.end_reason, + last_active: Math.max(...chain.map(session => session.last_active || session.started_at || 0)), + message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0), + tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0), + input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0), + output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0), + cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0), + cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0), + reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0), + billing_provider: latest.billing_provider ?? root.billing_provider, + estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0), + actual_cost_usd: actualCosts.length ? actualCosts.reduce((sum, value) => sum + Number(value || 0), 0) : null, + cost_status: costStatuses.length === 1 ? costStatuses[0] : (costStatuses.length > 1 ? 'mixed' : ''), + messages, + thread_session_count: chain.length, + } +} + +function chainOrderSql(ids: string[]): string { + return ids.map((_, index) => `WHEN ? THEN ${index}`).join(' ') +} + +async function openSessionDb(profile?: string) { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + const { DatabaseSync } = await import('node:sqlite') + const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath() + try { + return new DatabaseSync(dbPath, { open: true, readOnly: true }) + } catch (err: any) { + console.error(`[sessions-db] Failed to open session db at ${dbPath}:`, err.message) + throw err + } +} + +/** + * Lightweight alternative: get messages + session row for a single session ID + * without chain traversal. Used by syncFromHermes for ephemeral sessions. + */ +export async function getSessionMessagesFromDb(sessionId: string): Promise<{ + messages: HermesMessageRow[] + session: HermesSessionRow | null +} | null> { + const db = await openSessionDb() + try { + const sessionRow = db.prepare(` + SELECT ${SESSION_SELECT} + FROM sessions s + WHERE s.id = ? + `).get(sessionId) as Record | undefined + + const messageRows = db.prepare(` + SELECT * FROM messages + WHERE session_id = ? + ORDER BY id + `).all(sessionId) as Record[] + + return { + messages: messageRows.map(mapMessageRow), + session: sessionRow ? mapRow(sessionRow) : null, + } + } finally { + db.close() + } +} + +export async function getSessionDetailFromDb(sessionId: string): Promise { + const db = await openSessionDb() + try { + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null + if (!requested) return null + + const chain = collectSessionChainForMatchedSession(requested, idx) + if (!chain.length) return null + + const ids = chain.map(session => session.id) + const placeholders = ids.map(() => '?').join(', ') + const orderSql = chainOrderSql(ids) + const messageRows = db.prepare(` + SELECT * FROM messages + WHERE session_id IN (${placeholders}) + ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id + `).all(...ids, ...ids) as Record[] + const messages = messageRows.map(mapMessageRow) + return aggregateSessionDetail(chain, messages, sessionId) + } finally { + db.close() + } +} + +export async function getSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise { + const { DatabaseSync } = await import('node:sqlite') + const dbPath = join(getProfileDir(profile), 'state.db') + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + try { + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null + if (!requested) return null + + const chain = collectSessionChainForMatchedSession(requested, idx) + if (!chain.length) return null + + const ids = chain.map(session => session.id) + const placeholders = ids.map(() => '?').join(', ') + const orderSql = chainOrderSql(ids) + const messageRows = db.prepare(` + SELECT * FROM messages + WHERE session_id IN (${placeholders}) + ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id + `).all(...ids, ...ids) as Record[] + const messages = messageRows.map(mapMessageRow) + return aggregateSessionDetail(chain, messages, sessionId) + } finally { + db.close() + } +} + +export async function getSessionDetailPaginatedFromDbWithProfile( + sessionId: string, + profile: string, + offset = 0, + limit = 300, +): Promise { + const db = await openSessionDb(profile) + try { + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null + if (!requested) return null + + const chain = collectSessionChainForMatchedSession(requested, idx) + if (!chain.length) return null + + const ids = chain.map(session => session.id) + const placeholders = ids.map(() => '?').join(', ') + const orderSql = chainOrderSql(ids) + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM messages + WHERE session_id IN (${placeholders}) + `).get(...ids) as { total: number } | undefined + const total = Number(totalRow?.total || 0) + + const messageRows = db.prepare(` + SELECT * FROM messages + WHERE session_id IN (${placeholders}) + ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END DESC, id DESC + LIMIT ? OFFSET ? + `).all(...ids, ...ids, limit, offset) as Record[] + const messages = messageRows.map(mapMessageRow).reverse() + + return { + session: aggregateSessionDetail(chain, messages, sessionId), + messages, + total, + offset, + limit, + hasMore: offset + messages.length < total, + } + } finally { + db.close() + } +} + +export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise { + const { DatabaseSync } = await import('node:sqlite') + const dbPath = join(getProfileDir(profile), 'state.db') + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + try { + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null + if (!requested) return null + + const messageRows = db.prepare(` + SELECT * FROM messages + WHERE session_id = ? + ORDER BY id + `).all(sessionId) as Record[] + const messages = messageRows.map(mapMessageRow) + return aggregateSessionDetail([requested], messages, sessionId) + } finally { + db.close() + } +} + +export async function findLatestExactSessionIdWithProfile( + query: string, + profile: string, + source?: string, +): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const trimmed = query.trim() + if (!trimmed) return null + + const { DatabaseSync } = await import('node:sqlite') + const dbPath = join(getProfileDir(profile), 'state.db') + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + const loweredQuery = trimmed.toLowerCase() + const likePattern = buildLikePattern(loweredQuery) + const kanbanPrompt = `work kanban task ${trimmed}`.toLowerCase() + const taskJsonNeedle = `"task_id": "${trimmed}"`.toLowerCase() + + try { + const sourceClause = source ? 'AND s.source = ?' : '' + const sourceParams = source ? [source] : [] + const exactPromptSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE m.role = 'user' + AND LOWER(TRIM(m.content)) = ? + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const exactPromptMatch = db.prepare(exactPromptSql).get(...sourceParams, kanbanPrompt) as Record | undefined + if (exactPromptMatch?.id) return String(exactPromptMatch.id) + + const taskJsonSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE LOWER(m.content) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const taskJsonMatch = db.prepare(taskJsonSql).get(...sourceParams, buildLikePattern(taskJsonNeedle)) as Record | undefined + if (taskJsonMatch?.id) return String(taskJsonMatch.id) + + const contentSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE LOWER(m.content) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const contentMatch = db.prepare(contentSql).get(...sourceParams, likePattern) as Record | undefined + if (contentMatch?.id) return String(contentMatch.id) + + const titleSql = ` + SELECT s.id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + AND LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\' + ORDER BY s.started_at DESC + LIMIT 1 + ` + const titleMatch = db.prepare(titleSql).get(...sourceParams, likePattern) as Record | undefined + return titleMatch?.id ? String(titleMatch.id) : null + } finally { + db.close() + } +} + +export interface HermesUsageStats extends LocalUsageStats { + cost: number + total_api_calls: number +} + +export interface HermesSkillUsageRow { + skill: string + view_count: number + manage_count: number + total_count: number + percentage: number + last_used_at: number | null +} + +export interface HermesSkillUsageDailySkillRow { + skill: string + view_count: number + manage_count: number + total_count: number +} + +export interface HermesSkillUsageDailyRow { + date: string + view_count: number + manage_count: number + total_count: number + skills: HermesSkillUsageDailySkillRow[] +} + +export interface HermesSkillUsageStats { + period_days: number + summary: { + total_skill_loads: number + total_skill_edits: number + total_skill_actions: number + distinct_skills_used: number + } + by_day: HermesSkillUsageDailyRow[] + top_skills: HermesSkillUsageRow[] +} + +function tableHasColumn( + db: { prepare: (sql: string) => { all: (...params: any[]) => Record[] } }, + tableName: string, + columnName: string, +): boolean { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() + return columns.some(column => String(column.name || '') === columnName) +} + +function parseJsonObject(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null + } catch { + return null + } +} + +type SkillUsageAction = 'view' | 'manage' + +interface RawSkillUsageEvent { + skill: string + action: SkillUsageAction + timestamp: number | null +} + +function extractSkillNameFromViewContent(content: string): string { + const match = content.match(/^\[skill_view\]\s+name=(.+?)(?:\s+\(|\s*$)/) + if (match?.[1]) return match[1].trim() + + const parsed = parseJsonObject(content) + return typeof parsed?.name === 'string' ? parsed.name.trim() : '' +} + +function extractSkillNameFromManageContent(content: string): string { + const bracketMatch = content.match(/^\[skill_manage\]\s+name=(.+?)(?:\s+|\(|$)/) + if (bracketMatch?.[1]) return bracketMatch[1].trim() + + const parsed = parseJsonObject(content) + const message = typeof parsed?.message === 'string' ? parsed.message : content + const quotedMatch = message.match(/skill ['"]([^'"]+)['"]/i) + if (quotedMatch?.[1]) return quotedMatch[1].trim() + + const namedMatch = message.match(/\bname=([^\s)]+)/i) + return namedMatch?.[1]?.trim() || '' +} + +function extractSkillToolCall(row: Record): { action: SkillUsageAction; skill: string } | null { + const toolCallId = typeof row.tool_call_id === 'string' ? row.tool_call_id : '' + const rawToolCalls = typeof row.assistant_tool_calls === 'string' ? row.assistant_tool_calls : '' + if (!toolCallId || !rawToolCalls) return null + + let parsed: unknown + try { + parsed = JSON.parse(rawToolCalls) + } catch { + return null + } + + const calls = Array.isArray(parsed) ? parsed : [parsed] + for (const call of calls) { + if (!call || typeof call !== 'object') continue + const record = call as Record + const functionRecord = record.function && typeof record.function === 'object' + ? record.function as Record + : {} + const ids = [record.id, record.call_id, record.tool_call_id, functionRecord.call_id] + .filter((value): value is string => typeof value === 'string') + if (!ids.includes(toolCallId)) continue + + const name = typeof functionRecord.name === 'string' + ? functionRecord.name + : typeof record.name === 'string' + ? record.name + : '' + const action: SkillUsageAction | null = name === 'skill_view' + ? 'view' + : name === 'skill_manage' + ? 'manage' + : null + if (!action) return null + + const args = parseJsonObject(functionRecord.arguments ?? record.arguments) + const skill = typeof args?.name === 'string' ? args.name.trim() : '' + return { action, skill } + } + + return null +} + +function mapSkillUsageEvent(row: Record): RawSkillUsageEvent | null { + const content = typeof row.content === 'string' ? row.content : '' + const toolName = typeof row.tool_name === 'string' ? row.tool_name : '' + const toolCall = extractSkillToolCall(row) + const action: SkillUsageAction | null = toolName === 'skill_view' || content.startsWith('[skill_view]') + ? 'view' + : toolName === 'skill_manage' || content.startsWith('[skill_manage]') + ? 'manage' + : toolCall?.action ?? null + + if (!action) return null + + const skill = toolCall?.skill || (action === 'view' + ? extractSkillNameFromViewContent(content) + : extractSkillNameFromManageContent(content)) + + if (!skill) return null + + return { + skill, + action, + timestamp: normalizeNullableNumber(row.timestamp), + } +} + +function formatUnixDate(timestamp: number | null): string { + if (timestamp == null) return '' + return new Date(timestamp * 1000).toISOString().slice(0, 10) +} + +export async function getSkillUsageStatsFromDb( + days = 7, + nowSeconds = Math.floor(Date.now() / 1000), + profile?: string, +): Promise { + const normalizedDays = Number.isFinite(days) ? days : 7 + const safeDays = Math.max(1, Math.floor(normalizedDays)) + const since = nowSeconds - safeDays * 24 * 60 * 60 + const db = await openSessionDb(profile) + + try { + const hasStartedIndex = db.prepare("PRAGMA index_list(sessions)").all() + .some(index => String(index.name || '') === 'idx_sessions_started') + const hasMessagesIndex = db.prepare("PRAGMA index_list(messages)").all() + .some(index => String(index.name || '') === 'idx_messages_session') + const sessionsTable = hasStartedIndex ? 'sessions s INDEXED BY idx_sessions_started' : 'sessions s' + const messagesTable = hasMessagesIndex ? 'messages m INDEXED BY idx_messages_session' : 'messages m' + const toolPredicate = ` + m.role = 'tool' + AND ( + m.tool_name IN ('skill_view', 'skill_manage') + OR m.content LIKE '[skill_view]%' + OR m.content LIKE '[skill_manage]%' + OR m.tool_call_id IS NOT NULL + ) + ` + const recentRows = db.prepare(` + SELECT + m.tool_name, + m.tool_call_id, + SUBSTR(m.content, 1, 300) AS content, + COALESCE(m.timestamp, s.started_at) AS timestamp, + ( + SELECT a.tool_calls + FROM messages a + WHERE a.session_id = m.session_id + AND a.role = 'assistant' + AND m.tool_call_id IS NOT NULL + AND a.tool_calls LIKE '%' || m.tool_call_id || '%' + ORDER BY a.timestamp DESC + LIMIT 1 + ) AS assistant_tool_calls + FROM ${sessionsTable} + JOIN ${messagesTable} ON m.session_id = s.id + WHERE s.started_at > ? + AND ${toolPredicate} + `).all(since) as Record[] + const lateRows = db.prepare(` + SELECT + m.tool_name, + m.tool_call_id, + SUBSTR(m.content, 1, 300) AS content, + COALESCE(m.timestamp, s.started_at) AS timestamp, + ( + SELECT a.tool_calls + FROM messages a + WHERE a.session_id = m.session_id + AND a.role = 'assistant' + AND m.tool_call_id IS NOT NULL + AND a.tool_calls LIKE '%' || m.tool_call_id || '%' + ORDER BY a.timestamp DESC + LIMIT 1 + ) AS assistant_tool_calls + FROM ${sessionsTable} + JOIN ${messagesTable} ON m.session_id = s.id + WHERE s.started_at <= ? + AND COALESCE(m.timestamp, s.started_at) > ? + AND ${toolPredicate} + `).all(since, since) as Record[] + + const skillMap = new Map() + const dayMap = new Map() + const daySkillMap = new Map>() + + for (const row of [...recentRows, ...lateRows]) { + const event = mapSkillUsageEvent(row) + if (!event) continue + + const entry = skillMap.get(event.skill) || { + skill: event.skill, + view_count: 0, + manage_count: 0, + last_used_at: null, + } + if (event.action === 'view') entry.view_count += 1 + else entry.manage_count += 1 + if (event.timestamp != null && (entry.last_used_at == null || event.timestamp > entry.last_used_at)) { + entry.last_used_at = event.timestamp + } + skillMap.set(event.skill, entry) + + const date = formatUnixDate(event.timestamp) + if (date) { + const day = dayMap.get(date) || { date, view_count: 0, manage_count: 0 } + if (event.action === 'view') day.view_count += 1 + else day.manage_count += 1 + dayMap.set(date, day) + + const skillsForDay = daySkillMap.get(date) || new Map() + const skillForDay = skillsForDay.get(event.skill) || { skill: event.skill, view_count: 0, manage_count: 0 } + if (event.action === 'view') skillForDay.view_count += 1 + else skillForDay.manage_count += 1 + skillsForDay.set(event.skill, skillForDay) + daySkillMap.set(date, skillsForDay) + } + } + + const totalLoads = [...skillMap.values()].reduce((sum, skill) => sum + skill.view_count, 0) + const totalEdits = [...skillMap.values()].reduce((sum, skill) => sum + skill.manage_count, 0) + const totalActions = totalLoads + totalEdits + const byDay = [...dayMap.values()] + .map(day => ({ + ...day, + total_count: day.view_count + day.manage_count, + skills: [...(daySkillMap.get(day.date)?.values() || [])] + .map(skill => ({ + ...skill, + total_count: skill.view_count + skill.manage_count, + })) + .sort((a, b) => b.total_count - a.total_count || a.skill.localeCompare(b.skill)), + })) + .sort((a, b) => a.date.localeCompare(b.date)) + const topSkills = [...skillMap.values()] + .map(skill => ({ + ...skill, + total_count: skill.view_count + skill.manage_count, + percentage: totalActions > 0 ? (skill.view_count + skill.manage_count) / totalActions * 100 : 0, + })) + .sort((a, b) => + b.total_count - a.total_count || + b.view_count - a.view_count || + b.manage_count - a.manage_count || + (b.last_used_at || 0) - (a.last_used_at || 0) || + a.skill.localeCompare(b.skill), + ) + + return { + period_days: safeDays, + summary: { + total_skill_loads: totalLoads, + total_skill_edits: totalEdits, + total_skill_actions: totalActions, + distinct_skills_used: skillMap.size, + }, + by_day: byDay, + top_skills: topSkills, + } + } finally { + db.close() + } +} + +export async function getUsageStatsFromDb( + days = 30, + nowSeconds = Math.floor(Date.now() / 1000), + profile?: string, +): Promise { + const empty: HermesUsageStats = { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + sessions: 0, + by_model: [], + by_day: [], + cost: 0, + total_api_calls: 0, + } + + const normalizedDays = Number.isFinite(days) ? days : 30 + const safeDays = Math.max(1, Math.floor(normalizedDays)) + const since = nowSeconds - safeDays * 24 * 60 * 60 + const db = await openSessionDb(profile) + + try { + const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count') + ? 'COALESCE(SUM(api_call_count), 0)' + : '0' + const totals = db.prepare(` + SELECT + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost, + COUNT(*) AS sessions, + ${apiCallsExpr} AS total_api_calls + FROM sessions + WHERE started_at > ? + `).get(since) as Record | undefined + + if (!totals) return empty + + const byModel = db.prepare(` + SELECT + COALESCE(model, '') AS model, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COUNT(*) AS sessions + FROM sessions + WHERE started_at > ? AND model IS NOT NULL + GROUP BY model + ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC + `).all(since).map(row => ({ + model: String(row.model || ''), + input_tokens: normalizeNumber(row.input_tokens), + output_tokens: normalizeNumber(row.output_tokens), + cache_read_tokens: normalizeNumber(row.cache_read_tokens), + cache_write_tokens: normalizeNumber(row.cache_write_tokens), + reasoning_tokens: normalizeNumber(row.reasoning_tokens), + sessions: normalizeNumber(row.sessions), + })) + + const byDay = db.prepare(` + SELECT + date(started_at, 'unixepoch') AS date, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COUNT(*) AS sessions, + COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost + FROM sessions + WHERE started_at > ? + GROUP BY date + ORDER BY date ASC + `).all(since).map(row => ({ + date: String(row.date || ''), + input_tokens: normalizeNumber(row.input_tokens), + output_tokens: normalizeNumber(row.output_tokens), + cache_read_tokens: normalizeNumber(row.cache_read_tokens), + cache_write_tokens: normalizeNumber(row.cache_write_tokens), + sessions: normalizeNumber(row.sessions), + errors: 0, + cost: normalizeNumber(row.cost), + })) + + return { + input_tokens: normalizeNumber(totals.input_tokens), + output_tokens: normalizeNumber(totals.output_tokens), + cache_read_tokens: normalizeNumber(totals.cache_read_tokens), + cache_write_tokens: normalizeNumber(totals.cache_write_tokens), + reasoning_tokens: normalizeNumber(totals.reasoning_tokens), + sessions: normalizeNumber(totals.sessions), + by_model: byModel, + by_day: byDay, + cost: normalizeNumber(totals.cost), + total_api_calls: normalizeNumber(totals.total_api_calls), + } + } finally { + db.close() + } +} + +export async function listSessionSummaries(source?: string, limit = 2000, profile?: string): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const { DatabaseSync } = await import('node:sqlite') + const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath() + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + + try { + const clauses = ["s.parent_session_id IS NULL", "s.source != 'tool'", "s.id NOT LIKE 'compress_%'"] + const params: any[] = [] + if (source) { + clauses.push('s.source = ?') + params.push(source) + } + params.push(Math.max(limit * 4, limit)) + + const rawRows = db.prepare(` + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE ${clauses.join(' AND ')} + ORDER BY s.started_at DESC + LIMIT ? + `).all(...params) as Record[] | undefined + const roots = (Array.isArray(rawRows) ? rawRows : []).map(mapInternalSessionRow) + + const idx = loadAllSessions(db) + return roots + .map(root => projectSessionSummary(root, collectSessionChain(root, idx))) + .sort((a, b) => Number(b.last_active || b.started_at || 0) - Number(a.last_active || a.started_at || 0)) + .slice(0, limit) + } finally { + db.close() + } +} + +export async function searchSessionSummariesWithProfile( + query: string, + profile: string, + source?: string, + limit = 20, +): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const trimmed = query.trim() + if (!trimmed) return [] + + const { DatabaseSync } = await import('node:sqlite') + const dbPath = join(getProfileDir(profile), 'state.db') + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + const normalized = sanitizeFtsQuery(trimmed) + const prefixQuery = toPrefixQuery(normalized) + const titlePattern = buildLikePattern(normalizeTitleLikeQuery(trimmed).toLowerCase()) + const useLiteralContentSearch = containsCjk(trimmed) || shouldUseLiteralContentSearch(trimmed) + const candidateLimit = searchCandidateLimit(limit) + + try { + const sourceClause = source ? 'AND s.source = ?' : '' + const sourceParams = source ? [source] : [] + const titleSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT + base.*, + NULL AS matched_message_id, + CASE + WHEN base.title IS NOT NULL AND base.title != '' THEN base.title + ELSE base.preview + END AS snippet, + 0 AS rank + FROM base + WHERE LOWER(COALESCE(base.title, '')) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC + LIMIT ? + ` + const titleRows = db.prepare(titleSql).all(...sourceParams, titlePattern, candidateLimit) as Record[] + + const contentSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT + base.*, + m.id AS matched_message_id, + snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet, + bm25(messages_fts) AS rank + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN base ON base.id = m.session_id + WHERE messages_fts MATCH ? + ORDER BY rank, base.last_active DESC + LIMIT ? + ` + + const contentRows = useLiteralContentSearch + ? runLiteralContentSearch(db, source, trimmed, candidateLimit) + : prefixQuery + ? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record[]) + : [] + + const idx = loadAllSessions(db) + const merged = new Map() + for (const row of titleRows) { + const mapped = projectSearchRow(row, idx, source) + if (mapped) merged.set(mapped.id, mapped) + } + for (const row of contentRows) { + const mapped = projectSearchRow(row, idx, source) + if (mapped && !merged.has(mapped.id)) merged.set(mapped.id, mapped) + } + + const items = [...merged.values()] + items.sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank + return b.last_active - a.last_active + }) + return items.slice(0, limit) + } catch (_err) { + return [] + } finally { + db.close() + } +} + +export async function searchSessionSummaries( + query: string, + source?: string, + limit = 20, +): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const trimmed = query.trim() + if (!trimmed) { + const recent = await listSessionSummaries(source, limit) + return recent.map(row => ({ + ...row, + matched_message_id: null, + snippet: row.preview, + rank: 0, + })) + } + + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true }) + const normalized = sanitizeFtsQuery(trimmed) + const prefixQuery = toPrefixQuery(normalized) + const titlePattern = buildLikePattern(normalizeTitleLikeQuery(trimmed).toLowerCase()) + const useLiteralContentSearch = containsCjk(trimmed) || shouldUseLiteralContentSearch(trimmed) + const candidateLimit = searchCandidateLimit(limit) + let titleRows: Record[] = [] + + try { + const sourceClause = source ? 'AND s.source = ?' : '' + const sourceParams = source ? [source] : [] + const allSessionsBaseSql = ` + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ` + + const titleSql = ` + WITH base AS ( + ${allSessionsBaseSql} + ) + SELECT + base.*, + NULL AS matched_message_id, + CASE + WHEN base.title IS NOT NULL AND base.title != '' THEN base.title + ELSE base.preview + END AS snippet, + 0 AS rank + FROM base + WHERE LOWER(COALESCE(base.title, '')) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC + LIMIT ? + ` + + const titleStatement = db.prepare(titleSql) + titleRows = titleStatement.all(...sourceParams, titlePattern, candidateLimit) as Record[] + + const contentSql = ` + WITH base AS ( + ${allSessionsBaseSql} + ) + SELECT + base.*, + m.id AS matched_message_id, + snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet, + bm25(messages_fts) AS rank + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN base ON base.id = m.session_id + WHERE messages_fts MATCH ? + ORDER BY rank, base.last_active DESC + LIMIT ? + ` + + const contentRows = useLiteralContentSearch + ? runLiteralContentSearch(db, source, trimmed, candidateLimit) + : prefixQuery + ? (db.prepare(contentSql).all(...sourceParams, prefixQuery, candidateLimit) as Record[]) + : [] + + const idx = loadAllSessions(db) + const merged = new Map() + for (const row of titleRows) { + const mapped = projectSearchRow(row, idx, source) + if (mapped) merged.set(mapped.id, mapped) + } + for (const row of contentRows) { + const mapped = projectSearchRow(row, idx, source) + if (mapped && !merged.has(mapped.id)) { + merged.set(mapped.id, mapped) + } + } + + const items = [...merged.values()] + items.sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank + return b.last_active - a.last_active + }) + return items.slice(0, limit) + } catch (_err) { + // FTS queries can fail for various inputs (pure numbers, special syntax, etc.) + // Fall back to title-only LIKE results + literal content search for CJK + const likeRows = containsCjk(normalized) + ? runLiteralContentSearch(db, source, trimmed, candidateLimit) + : [] + const idx2 = loadAllSessions(db) + const merged = new Map() + for (const row of titleRows) { + const mapped = projectSearchRow(row, idx2, source) + if (mapped) merged.set(mapped.id, mapped) + } + for (const row of likeRows) { + const mapped = projectSearchRow(row, idx2, source) + if (mapped && !merged.has(mapped.id)) { + merged.set(mapped.id, mapped) + } + } + const items = [...merged.values()] + items.sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank + return b.last_active - a.last_active + }) + return items.slice(0, limit) + } finally { + db.close() + } +} diff --git a/packages/server/src/db/hermes/usage-store.ts b/packages/server/src/db/hermes/usage-store.ts new file mode 100644 index 0000000..4420181 --- /dev/null +++ b/packages/server/src/db/hermes/usage-store.ts @@ -0,0 +1,227 @@ +import { isSqliteAvailable, getDb, jsonSet, jsonGet, jsonGetAll, jsonDelete } from '../index' +import { USAGE_TABLE as TABLE } from './schemas' + +export interface UsageRecord { + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + model: string + profile: string + created_at: number +} + +export function updateUsage( + sessionId: string, + data: { + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number + model?: string + profile?: string + }, +): void { + const cacheReadTokens = data.cacheReadTokens ?? 0 + const cacheWriteTokens = data.cacheWriteTokens ?? 0 + const reasoningTokens = data.reasoningTokens ?? 0 + const now = Date.now() + const model = data.model || '' + const profile = data.profile || 'default' + if (isSqliteAvailable()) { + const db = getDb()! + db.prepare( + `INSERT INTO ${TABLE} (session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(sessionId, data.inputTokens, data.outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, model, profile, now) + } else { + jsonSet(TABLE, sessionId, { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_read_tokens: cacheReadTokens, + cache_write_tokens: cacheWriteTokens, + reasoning_tokens: reasoningTokens, + model, + profile, + created_at: now, + }) + } +} + +export function getUsage(sessionId: string): UsageRecord | undefined { + if (isSqliteAvailable()) { + return getDb()!.prepare( + `SELECT session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at FROM ${TABLE} WHERE session_id = ? ORDER BY id DESC LIMIT 1`, + ).get(sessionId) as UsageRecord | undefined + } + const row = jsonGet(TABLE, sessionId) + if (!row) return undefined + return { + input_tokens: row.input_tokens ?? 0, + output_tokens: row.output_tokens ?? 0, + cache_read_tokens: row.cache_read_tokens ?? 0, + cache_write_tokens: row.cache_write_tokens ?? 0, + reasoning_tokens: row.reasoning_tokens ?? 0, + model: row.model ?? '', + profile: row.profile ?? 'default', + created_at: row.created_at ?? 0, + } +} + +export function getUsageBatch(sessionIds: string[]): Record { + if (sessionIds.length === 0) return {} + if (isSqliteAvailable()) { + const db = getDb()! + const placeholders = sessionIds.map(() => '?').join(',') + const rows = db.prepare( + `SELECT session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at + FROM ${TABLE} + WHERE id IN (SELECT MAX(id) FROM ${TABLE} WHERE session_id IN (${placeholders}) GROUP BY session_id)`, + ).all(...sessionIds) as unknown as Array + const map: Record = {} + for (const r of rows) { + map[r.session_id] = { + input_tokens: r.input_tokens, + output_tokens: r.output_tokens, + cache_read_tokens: r.cache_read_tokens, + cache_write_tokens: r.cache_write_tokens, + reasoning_tokens: r.reasoning_tokens, + model: r.model, + profile: r.profile, + created_at: r.created_at, + } + } + return map + } + const all = jsonGetAll(TABLE) + const map: Record = {} + for (const id of sessionIds) { + const row = all[id] + if (row) { + map[id] = { + input_tokens: row.input_tokens ?? 0, + output_tokens: row.output_tokens ?? 0, + cache_read_tokens: row.cache_read_tokens ?? 0, + cache_write_tokens: row.cache_write_tokens ?? 0, + reasoning_tokens: row.reasoning_tokens ?? 0, + model: row.model ?? '', + profile: row.profile ?? 'default', + created_at: row.created_at ?? 0, + } + } + } + return map +} + +export function deleteUsage(sessionId: string): void { + if (isSqliteAvailable()) { + getDb()!.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId) + } else { + jsonDelete(TABLE, sessionId) + } +} + +// --- Aggregation for stats endpoint --- + +export interface UsageStatsModelRow { + model: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + sessions: number +} + +export interface UsageStatsDailyRow { + date: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + sessions: number + errors: number + cost: number +} + +export interface LocalUsageStats { + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + sessions: number + by_model: UsageStatsModelRow[] + by_day: UsageStatsDailyRow[] +} + +export function getLocalUsageStats(profile?: string, days = 30): LocalUsageStats { + const empty: LocalUsageStats = { + input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, + cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0, + by_model: [], by_day: [], + } + if (!isSqliteAvailable()) return empty + + const db = getDb()! + const safeDays = Math.max(1, Math.floor(Number.isFinite(days) ? days : 30)) + const cutoffMs = Date.now() - safeDays * 24 * 60 * 60 * 1000 + const filters: string[] = ['created_at > ?'] + const params: any[] = [cutoffMs] + if (profile) { + filters.unshift('profile = ?') + params.unshift(profile) + } + const whereClause = `WHERE ${filters.join(' AND ')}` + + const totals = db.prepare(` + SELECT COALESCE(SUM(input_tokens),0) as input_tokens, + COALESCE(SUM(output_tokens),0) as output_tokens, + COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens, + COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens, + COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens, + COUNT(DISTINCT session_id) as sessions + FROM ${TABLE} + ${whereClause} + `).get(...params) as any + + const byModel = db.prepare(` + SELECT model, + COALESCE(SUM(input_tokens),0) as input_tokens, + COALESCE(SUM(output_tokens),0) as output_tokens, + COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens, + COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens, + COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens, + COUNT(DISTINCT session_id) as sessions + FROM ${TABLE} + ${whereClause} + GROUP BY model + ORDER BY COALESCE(SUM(input_tokens),0) + COALESCE(SUM(output_tokens),0) DESC + `).all(...params) as unknown as UsageStatsModelRow[] + + const byDay = db.prepare(` + SELECT DATE(created_at / 1000, 'unixepoch') as date, + COALESCE(SUM(input_tokens),0) as input_tokens, + COALESCE(SUM(output_tokens),0) as output_tokens, + COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens, + COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens, + COUNT(DISTINCT session_id) as sessions + FROM ${TABLE} + ${whereClause} + GROUP BY date + ORDER BY date + `).all(...params) as Array<{ date: string; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; sessions: number }> + + return { + input_tokens: totals.input_tokens, + output_tokens: totals.output_tokens, + cache_read_tokens: totals.cache_read_tokens, + cache_write_tokens: totals.cache_write_tokens, + reasoning_tokens: totals.reasoning_tokens, + sessions: totals.sessions, + by_model: byModel, + by_day: byDay.map(d => ({ ...d, errors: 0, cost: 0 })), + } +} diff --git a/packages/server/src/db/hermes/users-store.ts b/packages/server/src/db/hermes/users-store.ts new file mode 100644 index 0000000..705e21b --- /dev/null +++ b/packages/server/src/db/hermes/users-store.ts @@ -0,0 +1,300 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto' +import { getDb } from '../index' +import { USER_PROFILES_TABLE, USERS_TABLE } from './schemas' + +export type UserRole = 'super_admin' | 'admin' +export type UserStatus = 'active' | 'disabled' +export type UserId = number | string + +export interface UserRecord { + id: number + username: string + password_hash: string + role: UserRole + status: UserStatus + created_at: number + updated_at: number + last_login_at: number | null +} + +export interface UserProfileRecord { + user_id: number + profile_name: string + is_default: number + created_at: number +} + +export interface UserSummary { + id: number + username: string + role: UserRole + status: UserStatus + profiles: string[] + default_profile: string | null + created_at: number + updated_at: number + last_login_at: number | null +} + +export const DEFAULT_USERNAME = 'admin' +export const DEFAULT_PASSWORD = '123456' +export const DEFAULT_PROFILE_NAME = 'default' + +const SCRYPT_KEY_LEN = 64 + +function normalizeUserId(id: UserId): number | null { + const userId = typeof id === 'number' ? id : Number(id) + return Number.isInteger(userId) && userId > 0 ? userId : null +} + +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex') + const hash = scryptSync(password, salt, SCRYPT_KEY_LEN).toString('hex') + return `scrypt:${salt}:${hash}` +} + +export function verifyPassword(password: string, passwordHash: string): boolean { + const [scheme, salt, expectedHex] = passwordHash.split(':') + if (scheme !== 'scrypt' || !salt || !expectedHex) return false + try { + const expected = Buffer.from(expectedHex, 'hex') + const actual = scryptSync(password, salt, expected.length) + return actual.length === expected.length && timingSafeEqual(actual, expected) + } catch { + return false + } +} + +export function findUserById(id: UserId): UserRecord | null { + const db = getDb() + if (!db) return null + const userId = normalizeUserId(id) + if (!userId) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE id = ?`).get(userId) as UserRecord | undefined + return row || null +} + +export function findUserByUsername(username: string): UserRecord | null { + const db = getDb() + if (!db) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE username = ?`).get(username) as UserRecord | undefined + return row || null +} + +export function findFirstUser(): UserRecord | null { + const db = getDb() + if (!db) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} ORDER BY id ASC LIMIT 1`).get() as UserRecord | undefined + return row || null +} + +export function listUsers(): UserSummary[] { + const db = getDb() + if (!db) return [] + const users = db.prepare( + `SELECT id, username, role, status, created_at, updated_at, last_login_at FROM ${USERS_TABLE} ORDER BY id ASC` + ).all() as Array> + return users.map(user => { + const profiles = listUserProfiles(user.id) + return { + ...user, + profiles: profiles.map(profile => profile.profile_name), + default_profile: profiles.find(profile => profile.is_default === 1)?.profile_name || null, + } + }) +} + +export function listUserProfiles(userId: UserId): UserProfileRecord[] { + const db = getDb() + if (!db) return [] + const id = normalizeUserId(userId) + if (!id) return [] + return db.prepare( + `SELECT * FROM ${USER_PROFILES_TABLE} WHERE user_id = ? ORDER BY is_default DESC, profile_name ASC` + ).all(id) as unknown as UserProfileRecord[] +} + +export function userCanAccessProfile(userId: UserId, profileName: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const row = db.prepare( + `SELECT 1 FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND profile_name = ?` + ).get(id, profileName) + return !!row +} + +export function getDefaultProfileForUser(userId: UserId): string { + const db = getDb() + if (!db) return DEFAULT_PROFILE_NAME + const id = normalizeUserId(userId) + if (!id) return DEFAULT_PROFILE_NAME + const row = db.prepare( + `SELECT profile_name FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND is_default = 1 LIMIT 1` + ).get(id) as { profile_name?: string } | undefined + return row?.profile_name || DEFAULT_PROFILE_NAME +} + +export function countUsers(): number { + const db = getDb() + if (!db) return 0 + const row = db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE}`).get() as { count?: number } | undefined + return Number(row?.count || 0) +} + +export function countActiveSuperAdmins(excludeUserId?: UserId): number { + const db = getDb() + if (!db) return 0 + const exclude = excludeUserId == null ? null : normalizeUserId(excludeUserId) + const row = exclude + ? db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active' AND id != ?`).get(exclude) + : db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active'`).get() + return Number((row as { count?: number } | undefined)?.count || 0) +} + +export function touchUserLogin(userId: UserId, at = Date.now()): void { + const db = getDb() + if (!db) return + const id = normalizeUserId(userId) + if (!id) return + db.prepare(`UPDATE ${USERS_TABLE} SET last_login_at = ?, updated_at = ? WHERE id = ?`).run(at, at, id) +} + +export function updateUserPassword(userId: UserId, password: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const result = db.prepare(`UPDATE ${USERS_TABLE} SET password_hash = ?, updated_at = ? WHERE id = ?`) + .run(hashPassword(password), Date.now(), id) + return result.changes > 0 +} + +export function updateUsername(userId: UserId, username: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const result = db.prepare(`UPDATE ${USERS_TABLE} SET username = ?, updated_at = ? WHERE id = ?`) + .run(username, Date.now(), id) + return result.changes > 0 +} + +export function createUser(input: { + username: string + password: string + role?: UserRole + status?: UserStatus + profiles?: string[] + defaultProfile?: string | null +}): UserRecord | null { + const db = getDb() + if (!db) return null + const now = Date.now() + const role = input.role || 'admin' + const status = input.status || 'active' + db.prepare( + `INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(input.username, hashPassword(input.password), role, status, now, now) + + const user = findUserByUsername(input.username) + if (user) replaceUserProfiles(user.id, input.profiles || [], input.defaultProfile) + return user +} + +export function updateUser(input: { + userId: UserId + username?: string + role?: UserRole + status?: UserStatus + password?: string + profiles?: string[] + defaultProfile?: string | null +}): UserRecord | null { + const db = getDb() + if (!db) return null + const id = normalizeUserId(input.userId) + if (!id) return null + + const current = findUserById(id) + if (!current) return null + + const nextUsername = input.username ?? current.username + const nextRole = input.role ?? current.role + const nextStatus = input.status ?? current.status + const nextPasswordHash = input.password ? hashPassword(input.password) : current.password_hash + const now = Date.now() + + db.prepare( + `UPDATE ${USERS_TABLE} + SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ? + WHERE id = ?` + ).run(nextUsername, nextPasswordHash, nextRole, nextStatus, now, id) + + if (input.profiles) replaceUserProfiles(id, input.profiles, input.defaultProfile) + return findUserById(id) +} + +export function deleteUser(userId: UserId): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + db.exec('BEGIN') + try { + db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id) + const result = db.prepare(`DELETE FROM ${USERS_TABLE} WHERE id = ?`).run(id) + db.exec('COMMIT') + return result.changes > 0 + } catch (err) { + db.exec('ROLLBACK') + throw err + } +} + +export function replaceUserProfiles(userId: UserId, profiles: string[], defaultProfile?: string | null): void { + const db = getDb() + if (!db) return + const id = normalizeUserId(userId) + if (!id) return + + const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))] + const defaultName = defaultProfile && uniqueProfiles.includes(defaultProfile) ? defaultProfile : uniqueProfiles[0] || null + const now = Date.now() + + db.exec('BEGIN') + try { + db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id) + const stmt = db.prepare( + `INSERT INTO ${USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) VALUES (?, ?, ?, ?)` + ) + uniqueProfiles.forEach(profile => { + stmt.run(id, profile, profile === defaultName ? 1 : 0, now) + }) + db.exec('COMMIT') + } catch (err) { + db.exec('ROLLBACK') + throw err + } +} + +export function createDefaultSuperAdmin(): UserRecord | null { + const db = getDb() + if (!db) return null + + const now = Date.now() + db.prepare( + `INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(DEFAULT_USERNAME, hashPassword(DEFAULT_PASSWORD), 'super_admin', 'active', now, now) + + return findUserByUsername(DEFAULT_USERNAME) +} + +export function bootstrapDefaultSuperAdmin(username: string, password: string): UserRecord | null { + if (countUsers() > 0) return null + if (username !== DEFAULT_USERNAME || password !== DEFAULT_PASSWORD) return null + return createDefaultSuperAdmin() +} diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts new file mode 100644 index 0000000..f3768c8 --- /dev/null +++ b/packages/server/src/db/index.ts @@ -0,0 +1,128 @@ +import { DatabaseSync } from 'node:sqlite' +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs' +import { resolve } from 'path' +import { config } from '../config' + +const isDev = process.env.NODE_ENV !== 'production' +const isTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' + +// In WSL, always use home directory to avoid cross-filesystem issues +const DB_DIR = isTest + ? resolve(process.cwd(), 'packages/server/data/test-runtime') + : isDev + ? resolve(process.cwd(), 'packages/server/data') + : config.appHome +const DB_PATH = resolve(DB_DIR, 'hermes-web-ui.db') +const JSON_PATH = resolve(DB_DIR, 'hermes-web-ui.json') + +// --- SQLite availability check --- + +const SQLITE_AVAILABLE = (() => { + const [major, minor] = process.versions.node.split('.').map(Number) + return major > 22 || (major === 22 && minor >= 5) +})() + +export function isSqliteAvailable(): boolean { + return SQLITE_AVAILABLE +} + +// --- SQLite backend --- + +let _db: DatabaseSync | null = null + +export function getDb(): DatabaseSync | null { + if (!SQLITE_AVAILABLE) return null + if (!_db) { + mkdirSync(DB_DIR, { recursive: true }) + _db = new DatabaseSync(DB_PATH) + // Use WAL mode for better concurrency and WSL compatibility + if (isDev) { + _db.exec('PRAGMA journal_mode=DELETE') + } else { + _db.exec('PRAGMA journal_mode=WAL') + _db.exec('PRAGMA synchronous=NORMAL') + _db.exec('PRAGMA busy_timeout=5000') + _db.exec('PRAGMA foreign_keys=ON') + } + } + return _db +} + +// --- JSON fallback backend --- + +type JsonData = Record>> + +function readJsonStore(): JsonData { + if (!existsSync(JSON_PATH)) return {} + try { + return JSON.parse(readFileSync(JSON_PATH, 'utf-8')) + } catch { + return {} + } +} + +function writeJsonStore(data: JsonData): void { + mkdirSync(DB_DIR, { recursive: true }) + writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8') +} + +/** + * Get a record from the JSON store. + * @param table Table name (namespace) + * @param key Primary key + */ +export function jsonGet(table: string, key: string): Record | undefined { + const data = readJsonStore() + return data[table]?.[key] +} + +/** + * Set a record in the JSON store. + * @param table Table name (namespace) + * @param key Primary key + * @param value Record data + */ +export function jsonSet(table: string, key: string, value: Record): void { + const data = readJsonStore() + if (!data[table]) data[table] = {} + data[table][key] = value + writeJsonStore(data) +} + +/** + * Get all records from a table in the JSON store. + */ +export function jsonGetAll(table: string): Record> { + const data = readJsonStore() + return data[table] || {} +} + +/** + * Delete a record from the JSON store. + */ +export function jsonDelete(table: string, key: string): void { + const data = readJsonStore() + if (data[table]) { + delete data[table][key] + writeJsonStore(data) + } +} + +/** + * Get the storage path for debugging. + */ +export function getStoragePath(): string { + return SQLITE_AVAILABLE ? DB_PATH : JSON_PATH +} + +/** + * Close the SQLite database connection. + */ +export function closeDb(): void { + if (_db) { + try { + _db.close() + } catch { /* best-effort */ } + _db = null + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..bbb6e30 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,260 @@ +import Koa from 'koa' +import cors from '@koa/cors' +import bodyParser from '@koa/bodyparser' +import serve from 'koa-static' +import send from 'koa-send' +import os from 'os' +import { resolve } from 'path' +import { mkdir } from 'fs/promises' +import { readFileSync } from 'fs' +import { config, shouldCreateWebUiDataDir } from './config' +import { initLoginLimiter } from './services/login-limiter' +import { bindShutdown } from './services/shutdown' +import { setupTerminalWebSocket } from './routes/hermes/terminal' +import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events' +import { startVersionCheck } from './routes/health' +import { registerRoutes } from './routes' +import { setGroupChatServer } from './routes/hermes/group-chat' +import { setChatRunServer } from './routes/hermes/chat-run' +import { GroupChatServer } from './services/hermes/group-chat' +import { ChatRunSocket } from './services/hermes/run-chat' +import { getAgentBridgeManager, startAgentBridgeManager } from './services/hermes/agent-bridge' +import { HermesSkillInjector } from './services/hermes/skill-injector' +import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart' +import { logger } from './services/logger' +import { requireUserJwt, resolveUserProfile } from './middleware/user-auth' + +// Injected by esbuild at build time; fallback to reading package.json in dev mode +declare const __APP_VERSION__: string +const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' + ? __APP_VERSION__ + : (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } })() + +// Global error handlers +process.on('uncaughtException', (err) => { + console.error('FATAL: Uncaught exception') + console.error(err) + logger.fatal(err, 'Uncaught exception') + process.exit(1) +}) + +process.on('unhandledRejection', (reason) => { + console.error('Unhandled rejection') + console.error(reason) + logger.error(reason, 'Unhandled rejection') +}) + +let server: any = null +let servers: any[] = [] +let chatRunServer: any = null +let agentBridgeManager: any = null + +interface ListenResult { + primary: any + servers: any[] +} + +function listen(app: Koa, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const s = app.listen(port, host) + s.once('listening', () => resolve(s)) + s.once('error', reject) + }) +} + +async function listenWithFallback(app: Koa, port: number, host?: string): Promise { + const bindHost = host || '0.0.0.0' + console.log(`[bootstrap] listening on ${bindHost}:${port}`) + const primary = await listen(app, port, bindHost) + return { primary, servers: [primary] } +} + +/** + * 安全获取网络接口信息(兼容 Termux/proot 环境) + * 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13) + */ +function safeNetworkInterfaces() { + try { + return os.networkInterfaces() + } catch { + return {} + } +} + +function isDesktopRuntime(): boolean { + return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' +} + +async function startRuntimeServicesBeforeListen(): Promise { + try { + await ensureProfileGatewaysRunning() + console.log('[bootstrap] profile gateways checked') + } catch (err) { + logger.warn(err, '[bootstrap] failed to ensure profile gateways') + console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err) + } + + try { + agentBridgeManager = await startAgentBridgeManager() + console.log('[bootstrap] agent bridge started') + } catch (err) { + logger.warn(err, '[bootstrap] agent bridge failed to start') + console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err) + } +} + +function startRuntimeServicesAfterListen(): void { + void (async () => { + try { + await ensureProfileGatewaysRunning() + console.log('[bootstrap] profile gateways checked') + } catch (err) { + logger.warn(err, '[bootstrap] failed to ensure profile gateways') + console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err) + } + })() + + void (async () => { + try { + agentBridgeManager = await startAgentBridgeManager() + console.log('[bootstrap] agent bridge started') + } catch (err) { + logger.warn(err, '[bootstrap] agent bridge failed to start') + console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err) + } + })() +} + +export async function bootstrap() { + console.log(`hermes-web-ui v${APP_VERSION} starting...`) + await mkdir(config.uploadDir, { recursive: true }) + if (shouldCreateWebUiDataDir()) { + await mkdir(config.dataDir, { recursive: true }) + } + + await initLoginLimiter() + try { + const skillInjector = new HermesSkillInjector() + const injectionResult = await skillInjector.injectMissingSkills() + if (injectionResult.injected.length > 0) { + logger.info({ + injected: [...new Set(injectionResult.injected)], + targetCount: injectionResult.targets.length, + }, '[bootstrap] bundled skills injected') + } + if (injectionResult.updated.length > 0) { + logger.info({ + updated: [...new Set(injectionResult.updated)], + targetCount: injectionResult.targets.length, + }, '[bootstrap] bundled skills updated') + } + } catch (err) { + logger.warn(err, '[bootstrap] failed to inject bundled skills') + console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err) + } + + if (!isDesktopRuntime()) { + await startRuntimeServicesBeforeListen() + } + + const app = new Koa() + await new Promise(resolve => setTimeout(resolve, 1000)) + // Initialize all web-ui SQLite tables + const { initAllStores } = await import('./db/hermes/init') + // Wait 1 second before initializing stores to ensure all resources are ready + initAllStores() + await new Promise(resolve => setTimeout(resolve, 1000)) + console.log('[bootstrap] all stores initialized') + + app.use(cors({ origin: config.corsOrigins })) + // Raise JSON/text limits above the default 1mb: profile avatars are posted + // as base64 data URLs (up to ~1MB raw → ~1.37MB base64), which otherwise + // tripped a 413 in the body parser before reaching the handler. + app.use(bodyParser({ encoding: 'utf-8', jsonLimit: '4mb', textLimit: '4mb' })) + console.log('[bootstrap] cors + bodyParser registered') + + // Register all routes (handles auth internally) + const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile]) + app.use(proxyMiddleware) + console.log('[bootstrap] routes registered') + + // SPA fallback + const distDir = resolve(__dirname, '..', 'client') + app.use(serve(distDir)) + app.use(async (ctx) => { + if (!ctx.path.startsWith('/api') && + ctx.path !== '/health' && + ctx.path !== '/upload' && + ctx.path !== '/webhook') { + await send(ctx, 'index.html', { root: distDir }) + } + }) + console.log('[bootstrap] SPA fallback registered') + + // Start server using the configured bind host. Default is IPv4 for WSL stability. + const listenResult = await listenWithFallback(app, config.port, config.host) + server = listenResult.primary + servers = listenResult.servers + console.log('[bootstrap] app.listen called') + + setupTerminalWebSocket(servers) + setupKanbanEventsWebSocket(servers) + console.log('[bootstrap] terminal + kanban websocket setup') + + // Group chat Socket.IO (must be after server is created) + const groupChatServer = new GroupChatServer(servers) + setGroupChatServer(groupChatServer) + + // Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace + chatRunServer = new ChatRunSocket(groupChatServer.getIO()) + setChatRunServer(chatRunServer) + chatRunServer.init() + + // Session deleter — periodically drain pending session deletes + const { SessionDeleter } = await import('./services/hermes/session-deleter') + const sessionDeleter = SessionDeleter.getInstance() + const activeProfile = process.env.PROFILE || 'default' + sessionDeleter.start(activeProfile) + console.log('[bootstrap] session deleter started, profile=%s', activeProfile) + + // Catch-all: destroy upgrade requests not handled by terminal or Socket.IO + servers.forEach((httpServer) => { + httpServer.on('upgrade', (req: any, socket: any) => { + const url = new URL(req.url || '', `http://${req.headers.host}`) + if (url.pathname !== '/api/hermes/terminal' && url.pathname !== '/api/hermes/kanban/events' && !url.pathname.startsWith('/socket.io/')) { + socket.destroy() + } + }) + }) + + const interfaces = safeNetworkInterfaces() + const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost' + console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) + console.log(`Log: ${config.appHome}/logs/server.log`) + logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) + + if (isDesktopRuntime()) { + agentBridgeManager = getAgentBridgeManager() + startRuntimeServicesAfterListen() + } + + // Restore group chat agents after server is ready. + groupChatServer.restoreWhenReady() + + servers.forEach((httpServer) => { + httpServer.on('error', (err: any) => { + console.error('[bootstrap] server error:', err.code || err.message) + logger.error({ err }, 'Server error') + }) + }) + + bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager) + startVersionCheck() +} + +bootstrap().catch((error) => { + console.error('FATAL: Failed to start Hermes Web UI') + console.error(error) + logger.fatal(error, 'Fatal error during bootstrap') + process.exit(1) +}) diff --git a/packages/server/src/lib/context-compressor/export-compressor.ts b/packages/server/src/lib/context-compressor/export-compressor.ts new file mode 100644 index 0000000..503b401 --- /dev/null +++ b/packages/server/src/lib/context-compressor/export-compressor.ts @@ -0,0 +1,150 @@ +/** + * Export Compressor + * + * Compresses session context for export purposes. + * Reuses the LLM summarization logic from ChatContextCompressor + * but does NOT read or write compression snapshots. + * Always forces LLM compression regardless of token count. + * No tail reservation — all messages are compressed. + */ + +import { logger } from '../../services/logger' +import { + type ChatMessage, + type CompressionConfig, + type CompressedResult, + type SummarizerOptions, + DEFAULT_COMPRESSION_CONFIG, + countTokens, + serializeForSummary, + buildFullPrompt, + buildIncrementalPrompt, + buildConversationHistory, + callSummarizer, +} from './index' +import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot' + +export class ExportCompressor { + private config: CompressionConfig + + constructor(opts?: { config?: Partial }) { + this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config } + } + + async compress( + messages: ChatMessage[], + upstream: string, + apiKey: string | undefined, + sessionId?: string, + summarizer?: string | SummarizerOptions, + ): Promise { + const total = messages.length + + const meta: CompressedResult['meta'] = { + totalMessages: total, + compressed: false, + llmCompressed: false, + summaryTokenEstimate: 0, + verbatimCount: 0, + compressedStartIndex: -1, + } + + // Read snapshot for incremental context, but never write + const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null + + if (snapshot) { + logger.info( + '[export-compressor] session=%s: incremental compress with existing snapshot at index %d', + sessionId, snapshot.lastMessageIndex, + ) + return this.incrementalCompress( + messages, snapshot, upstream, apiKey, meta, summarizer, + ) + } + + logger.info( + '[export-compressor] session=%s: full compress %d messages', + sessionId, total, + ) + return this.fullCompress(messages, upstream, apiKey, meta, summarizer) + } + + private async incrementalCompress( + messages: ChatMessage[], + snapshot: { summary: string; lastMessageIndex: number }, + upstream: string, + apiKey: string | undefined, + meta: CompressedResult['meta'], + summarizer?: string | SummarizerOptions, + ): Promise { + const { summary: previousSummary, lastMessageIndex } = snapshot + const newMessages = messages.slice(lastMessageIndex + 1) + + let summary: string | null = null + try { + const contentToSummarize = serializeForSummary(newMessages) + const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget) + const history = buildConversationHistory(newMessages) + + const t0 = Date.now() + summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer) + logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length) + } catch (err: any) { + logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message) + summary = previousSummary + } + + const summaryText = summary || previousSummary + + return { + messages: [{ role: 'user', content: summaryText }], + meta: { + ...meta, + compressed: true, + llmCompressed: true, + summaryTokenEstimate: countTokens(summaryText), + verbatimCount: 0, + }, + } + } + + private async fullCompress( + messages: ChatMessage[], + upstream: string, + apiKey: string | undefined, + meta: CompressedResult['meta'], + summarizer?: string | SummarizerOptions, + ): Promise { + if (messages.length === 0) { + return { messages: [], meta } + } + + let summary: string | null = null + try { + const contentToSummarize = serializeForSummary(messages) + const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget) + const history = buildConversationHistory(messages) + + const t0 = Date.now() + summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer) + logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length) + } catch (err: any) { + logger.warn('[export-compressor] full-llm failed: %s', err.message) + } + + if (!summary) { + return { messages, meta } + } + + return { + messages: [{ role: 'user', content: summary }], + meta: { + ...meta, + compressed: true, + llmCompressed: true, + summaryTokenEstimate: countTokens(summary), + verbatimCount: 0, + }, + } + } +} diff --git a/packages/server/src/lib/context-compressor/index.ts b/packages/server/src/lib/context-compressor/index.ts new file mode 100644 index 0000000..653e4c3 --- /dev/null +++ b/packages/server/src/lib/context-compressor/index.ts @@ -0,0 +1,842 @@ +/** + * Chat Context Compressor + * + * Compresses 1:1 chat conversation history before sending to upstream. + * Uses the Hermes structured summary prompt for LLM-based compression. + * + * Algorithm: + * 1. If total tokens < trigger threshold → return as-is + * 2. Pre-clean: truncate old tool results (no LLM call) + * 3. Load snapshot from SQLite for incremental update + * 4. Keep last 10 messages verbatim (tail protection by message count) + * 5. Summarize everything before the tail + * 6. Save snapshot: last_message_index = index where compression ends + */ + +import { encodingForModel, getEncoding } from 'js-tiktoken' +import { randomUUID } from 'crypto' +import { mkdir, writeFile } from 'fs/promises' +import { resolve } from 'path' +import { logger } from '../../services/logger' +import { AgentBridgeClient, type AgentBridgeRunResult } from '../../services/hermes/agent-bridge' +import { + getCompressionSnapshot, + saveCompressionSnapshot, + deleteCompressionSnapshot, +} from '../../db/hermes/compression-snapshot' + +// ─── Types ─────────────────────────────────────────────── + +export interface ContentBlock { + type: 'text' | 'image' | 'file' + text?: string + path?: string + source?: { type: string; media_type?: string; data?: string } +} + +export interface ChatMessage { + role: string + content: string | ContentBlock[] + tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> + tool_call_id?: string + name?: string + reasoning_content?: string | null +} + +export interface CompressionConfig { + /** Token threshold to trigger compression (default: contextLength / 2) */ + triggerTokens: number + /** Summary token target (default: 8000) */ + summaryBudget: number + /** Number of earliest messages to keep verbatim (default: 0) */ + headMessageCount: number + /** Number of recent messages to keep verbatim (default: 10) */ + tailMessageCount: number + /** Timeout for LLM summarization call (default: 300_000ms) */ + summarizationTimeoutMs: number +} + +export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { + triggerTokens: 100_000, + summaryBudget: 8_000, + headMessageCount: 0, + tailMessageCount: 10, + summarizationTimeoutMs: 300_000, +} + +export interface CompressedResult { + messages: ChatMessage[] + meta: { + totalMessages: number + compressed: boolean + /** true = actually called LLM to summarize; false = assembled from existing snapshot or returned as-is */ + llmCompressed: boolean + summaryTokenEstimate: number + verbatimCount: number + compressedStartIndex: number + } +} + +export interface SummarizerOptions { + profile?: string + model?: string | null + provider?: string | null + workerKey?: string +} + +const SUMMARIZER_TRIGGER_MESSAGE = 'Generate the context checkpoint summary now.' +const SUMMARIZER_DEBUG_DIR = 'logs/context-compressor' +const SUMMARIZER_DEBUG_FILE = 'summarizer-debug.json' + +async function writeSummarizerDebugDump(payload: Record): Promise { + if (process.env.NODE_ENV !== 'development') return + try { + const debugDir = resolve(process.cwd(), SUMMARIZER_DEBUG_DIR) + await mkdir(debugDir, { recursive: true }) + await writeFile( + resolve(debugDir, SUMMARIZER_DEBUG_FILE), + `${JSON.stringify(payload, null, 2)}\n`, + 'utf8', + ) + } catch (err) { + logger.warn(err, '[context-compressor] failed to write summarizer debug dump') + } +} + +// ─── Token counting ───────────────────────────────────── + +let _encoder: ReturnType | null = null + +function getEncoder() { + if (!_encoder) { + _encoder = getEncoding('cl100k_base') + } + return _encoder +} + +export function countTokens(text: string): number { + try { + return getEncoder().encode(text).length + } catch { + const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length + const other = text.length - cjk + return Math.ceil(cjk * 1.5 + other / 4) + } +} + +export function countTokensForModel(text: string, model: string): number { + try { + const enc = encodingForModel(model as any) + return enc.encode(text).length + } catch { + return countTokens(text) + } +} + +function messageTokenEstimate(message: ChatMessage): number { + if (typeof message.content === 'string') return countTokens(message.content) + if (Array.isArray(message.content)) { + return countTokens(message.content.map(block => { + if (block.type === 'text') return block.text || '' + if (block.type === 'image') return `[Image: ${block.path || ''}]` + if (block.type === 'file') return `[File: ${block.path || ''}]` + return '' + }).join('')) + } + return 0 +} + +function messagesTokenEstimate(messages: ChatMessage[]): number { + return messages.reduce((sum, message) => sum + messageTokenEstimate(message), 0) +} + +function truncateTextToTokenBudget(text: string, tokenBudget: number): string { + if (tokenBudget <= 0 || countTokens(text) <= tokenBudget) return text + let lo = 0 + let hi = text.length + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (countTokens(text.slice(0, mid)) <= tokenBudget) lo = mid + else hi = mid - 1 + } + return text.slice(0, lo).trimEnd() + '\n\n[Summary truncated to fit context budget]' +} + +function enforceCompressedBudget( + messages: ChatMessage[], + triggerTokens: number, + summaryIndex: number, +): ChatMessage[] { + if (triggerTokens <= 0 || messagesTokenEstimate(messages) <= triggerTokens) return messages + + const summaryMessage = messages[summaryIndex] + if (!summaryMessage || typeof summaryMessage.content !== 'string') return messages + + const summaryOnly = [{ ...summaryMessage }] + if (messagesTokenEstimate(summaryOnly) <= triggerTokens) return summaryOnly + + return [{ + ...summaryMessage, + content: truncateTextToTokenBudget(summaryMessage.content, triggerTokens), + }] +} + +// ─── Prompts ──────────────────────────────────────────── + +export const SUMMARY_PREFIX = `[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted +into the summary below. This is a handoff from a previous context +window — treat it as background reference, NOT as active instructions. +Do NOT answer questions or fulfill requests mentioned in this summary; +they were already addressed. +Your current task is identified in the '## Active Task' section of the +summary — resume exactly from there. +Respond ONLY to the latest user message +that appears AFTER this summary. The current session state (files, +config, etc.) may reflect work described here — avoid repeating it:` + +const TEMPLATE_SECTIONS = `Use this exact structure: + +## Active Task +[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or +task assignment verbatim — the exact words they used. If multiple tasks +were requested and only some are done, list only the ones NOT yet completed. +The next assistant must pick up exactly here. Example: +"User asked: 'Now refactor the auth module to use JWT instead of sessions'" +If no outstanding task exists, write "None."] + +## Goal +[What the user is trying to accomplish overall] + +## Constraints & Preferences +[User preferences, coding style, constraints, important decisions] + +## Completed Actions +[Numbered list of concrete actions taken — include tool used, target, and outcome. +Format each as: N. ACTION target — outcome [tool: name] +Example: +1. READ config.py:45 — found == should be != [tool: read_file] +2. PATCH config.py:45 — changed == to != [tool: patch] +3. TEST pytest tests/ — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal] +Be specific with file paths, commands, line numbers, and results.] + +## Active State +[Current working state — include: +- Working directory and branch (if applicable) +- Modified/created files with brief note on each +- Test status (X/Y passing) +- Any running processes or servers +- Environment details that matter] + +## In Progress +[Work currently underway — what was being done when compaction fired] + +## Blocked +[Any blockers, errors, or issues not yet resolved. Include exact error messages.] + +## Key Decisions +[Important technical decisions and WHY they were made] + +## Resolved Questions +[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them] + +## Pending User Asks +[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."] + +## Relevant Files +[Files read, modified, or created — with brief note on each] + +## Remaining Work +[What remains to be done — framed as context, not instructions] + +## Critical Context +[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]` + +export function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string { + return `You are a summarization agent creating a context checkpoint. +Your output will be injected as reference material for a DIFFERENT +assistant that continues the conversation. +Do NOT respond to any questions or requests in the conversation — +only output the structured summary. +Do NOT include any preamble, greeting, or prefix. + +Create a structured handoff summary for a different assistant that will continue +this conversation after earlier turns are compacted. The next assistant should be +able to understand what happened without re-reading the original turns. + +TURNS TO SUMMARIZE: +${contentToSummarize} + +${TEMPLATE_SECTIONS} + +Target ~${summaryBudget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. + +Write only the summary body. Do not include any preamble or prefix.` +} + +export function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string { + return `You are a summarization agent creating a context checkpoint. +Your output will be injected as reference material for a DIFFERENT +assistant that continues the conversation. +Do NOT respond to any questions or requests in the conversation — +only output the structured summary. +Do NOT include any preamble, greeting, or prefix. + +You are updating a context compaction summary. A previous compaction produced the +summary below. New conversation turns have occurred since then and need to be +incorporated. + +PREVIOUS SUMMARY: +${previousSummary} + +NEW TURNS TO INCORPORATE: +${contentToSummarize} + +Update the summary using this exact structure. PRESERVE all existing information +that is still relevant. ADD new completed actions to the numbered list +(continue numbering). Move items from "In Progress" to "Completed Actions" when +done. Move answered questions to "Resolved Questions". Update "Active State" +to reflect current state. Remove information only if it is clearly obsolete. +CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled +request — this is the most important field for task continuity. + +${TEMPLATE_SECTIONS} + +Target ~${summaryBudget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. + +Write only the summary body. Do not include any preamble or prefix.` +} + +// ─── Pre-cleaning ─────────────────────────────────────── + +export function serializeForSummary(messages: ChatMessage[]): string { + const parts: string[] = [] + + function contentToString(content: string | ContentBlock[]): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content.map(block => { + if (block.type === 'text') return block.text || '' + if (block.type === 'image') return `[Image: ${block.path || ''}]` + if (block.type === 'file') return `[File: ${block.path || ''}]` + return '' + }).join('') + } + return '' + } + + for (const msg of messages) { + const role = msg.role === 'tool' ? `[tool:${msg.name || 'unknown'}]` : msg.role + let content = contentToString(msg.content || '') + + if (msg.role === 'tool' && content.length > 5500) { + content = content.slice(0, 4000) + '\n... [truncated]\n...' + content.slice(-1500) + } + + if (msg.role === 'assistant' && msg.tool_calls?.length) { + const toolsInfo = msg.tool_calls.map(tc => { + let args = tc.function.arguments + if (args.length > 1500) args = args.slice(0, 1500) + '...' + return `[tool_call: ${tc.function.name}(${args})]` + }).join('\n') + parts.push(`${role}: ${toolsInfo}`) + if (content.trim()) parts.push(`${role}: ${content}`) + } else { + parts.push(`${role}: ${content}`) + } + } + return parts.join('\n\n') +} + +/** + * Convert messages to conversation history format for LLM API. + * Tool calls are converted to text format within assistant messages. + */ +export function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> { + const result: Array<{ role: string; content: string }> = [] + + for (const msg of messages) { + if (msg.role === 'tool') { + // Convert tool result to text and append to previous assistant message + const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 4000)}${msg.content && msg.content.length > 4000 ? '...' : ''}` + // Find the last assistant message and append to it + const lastAssistant = result.findLast(m => m.role === 'assistant') + if (lastAssistant) { + lastAssistant.content += `\n\n${toolText}` + } else { + // Fallback: create an assistant message + result.push({ role: 'assistant', content: toolText }) + } + } else if (msg.role === 'assistant' && msg.tool_calls?.length) { + // Include tool calls in assistant message + const toolsInfo = msg.tool_calls.map(tc => { + let args = tc.function.arguments + if (args.length > 4000) args = args.slice(0, 4000) + '...' + return `[Calling tool: ${tc.function.name} with arguments: ${args}]` + }).join('\n') + const content = msg.content ? `${msg.content}\n\n${toolsInfo}` : toolsInfo + result.push({ role: msg.role, content }) + } else if (msg.role === 'user') { + // Handle ContentBlock[] format: { type: 'text', text: '...' } or { type: 'image', path: '...' } + let contentStr = '' + const content = msg.content || '' + if (typeof content === 'string') { + contentStr = content + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text') { + contentStr += block.text || '' + } else if (block.type === 'image') { + contentStr += `[Image: ${block.path || ''}]` + } else if (block.type === 'file') { + contentStr += `[File: ${block.path || ''}]` + } + } + } + if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...' + result.push({ role: 'user', content: contentStr }) + } else if (msg.role === 'assistant' || msg.role === 'system') { + let contentStr = '' + const content = msg.content + if (typeof content === 'string') { + contentStr = content + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text') { + contentStr += block.text || '' + } else if (block.type === 'image') { + contentStr += `[Image: ${block.path || ''}]` + } else if (block.type === 'file') { + contentStr += `[File: ${block.path || ''}]` + } + } + } + if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...' + result.push({ role: msg.role, content: contentStr }) + } + // Skip other roles + } + + return result +} + +export function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] { + if (messages.length <= keepRecentCount) return messages + + const tail = messages.slice(-keepRecentCount) + const head = messages.slice(0, -keepRecentCount) + + const pruned = head.map(msg => { + if (msg.role !== 'tool') return msg + let content = '' + if (typeof msg.content === 'string') { + content = msg.content + } else if (Array.isArray(msg.content)) { + content = msg.content.map(block => { + if (block.type === 'text') return block.text || '' + return `[${block.type}]` + }).join('') + } + const preview = content.slice(0, 100).replace(/\n/g, ' ') + const truncated = content.length > 100 ? '...' : '' + return { ...msg, content: `[${msg.name || 'tool'}] ${preview}${truncated}` } + }) + + return [...pruned, ...tail] +} + +function pruneFallbackToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] { + return pruneOldToolResults(messages, keepRecentCount) +} + +// ─── LLM Summarization ────────────────────────────────── + +export async function callSummarizer( + upstream: string, + apiKey: string | undefined, + prompt: string, + history: Array<{ role: string; content: string }>, + timeoutMs: number, + previousSummary?: string, + summarizer?: string | SummarizerOptions, +): Promise { + void upstream + void apiKey + const options: SummarizerOptions = typeof summarizer === 'string' + ? { profile: summarizer } + : summarizer || {} + const profile = options.profile || 'default' + void history + const convHistory: Array<{ role: string; content: string }> = [] + + if (previousSummary) { + convHistory.unshift( + { role: 'user', content: `[Previous summary]\n${previousSummary}` }, + { role: 'assistant', content: 'Understood, I will update the summary.' }, + { role: 'user', content: prompt }, + ) + } else { + convHistory.unshift({ role: 'user', content: prompt }) + } + + const bridge = new AgentBridgeClient({ timeoutMs: timeoutMs + 15_000 }) + const sessionId = `compress_${Date.now().toString(36)}_${randomUUID().replace(/-/g, '').slice(0, 12)}` + const workerKey = options.workerKey || `${profile}:compression:${sessionId}` + const message = SUMMARIZER_TRIGGER_MESSAGE + + await writeSummarizerDebugDump({ + writtenAt: new Date().toISOString(), + sessionId, + workerKey, + profile, + model: options.model || null, + provider: options.provider || null, + message, + convHistory, + }) + + try { + const result = await bridge.request({ + action: 'chat', + session_id: sessionId, + message, + conversation_history: convHistory, + profile, + worker_key: workerKey, + source: 'api_server', + wait: true, + timeout: Math.ceil(timeoutMs / 1000), + ...(options.model ? { model: options.model } : {}), + ...(options.provider ? { provider: options.provider } : {}), + }, { timeoutMs: timeoutMs + 15_000 }) + + if (result.status === 'error') { + throw new Error(result.error || 'Summarization bridge run failed') + } + + const payload = result.result as any + const output = String( + payload?.final_response || + result.output || + '', + ).trim() + if (!output) throw new Error('Empty summarization response') + return output + } finally { + await bridge.destroy(sessionId, profile, workerKey).catch(() => undefined) + } +} + +// ─── Main Compressor ──────────────────────────────────── + +export class ChatContextCompressor { + private config: CompressionConfig + + constructor(opts?: { + config?: Partial + }) { + this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config } + } + + /** + * Assemble and compress conversation history. + * + * Flow: + * 1. Check snapshot → if exists, assemble = summary + new messages after snapshot index + * 2. If no snapshot → assemble = all messages + * 3. Count tokens of assembled context + * 4. Under threshold → return assembled as-is (no LLM call) + * 5. Over threshold → LLM compress, keep last N messages, save new snapshot + */ + async compress( + messages: ChatMessage[], + upstream: string, + apiKey: string | undefined, + sessionId?: string, + summarizer?: string | SummarizerOptions, + ): Promise { + const total = messages.length + + const makeMeta = (opts: Partial = {}): CompressedResult['meta'] => ({ + totalMessages: total, + compressed: false, + llmCompressed: false, + summaryTokenEstimate: 0, + verbatimCount: total, + compressedStartIndex: -1, + ...opts, + }) + + // Check if we have a previous compression snapshot + const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null + + if (snapshot && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < messages.length) { + // Has snapshot → incremental compress (merge old summary with new messages) + logger.info( + '[context-compressor] session=%s: incremental compress with snapshot at index %d', + sessionId, snapshot.lastMessageIndex, + ) + return this.incrementalCompress( + messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), summarizer, + ) + } else { + if (snapshot && sessionId) { + const fallbackLastMessageIndex = Math.max(-1, messages.length - this.config.tailMessageCount - 1) + logger.warn( + '[context-compressor] session=%s: stale snapshot index %d for %d messages; using summary plus tail from index %d', + sessionId, snapshot.lastMessageIndex, messages.length, fallbackLastMessageIndex, + ) + return this.incrementalCompress( + messages, + { summary: snapshot.summary, lastMessageIndex: fallbackLastMessageIndex }, + upstream, + apiKey, + sessionId, + makeMeta(), + summarizer, + ) + } + // No snapshot → full compress (compress all messages) + logger.info( + '[context-compressor] session=%s: full compress %d messages', + sessionId, total, + ) + return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), summarizer) + } + } + + private async incrementalCompress( + messages: ChatMessage[], + snapshot: { summary: string; lastMessageIndex: number }, + upstream: string, + apiKey: string | undefined, + sessionId: string, + meta: CompressedResult['meta'], + summarizer?: string | SummarizerOptions, + ): Promise { + const { summary: previousSummary, lastMessageIndex } = snapshot + const total = messages.length + const headCount = Math.min(this.config.headMessageCount, Math.max(0, lastMessageIndex + 1)) + const head = messages.slice(0, headCount) + const newMessages = messages.slice(lastMessageIndex + 1) + const tailCount = this.config.tailMessageCount + const previousSummaryMessage: ChatMessage = { role: 'user', content: SUMMARY_PREFIX + '\n\n' + previousSummary } + const assembledWithPrevious = [ + ...head, + previousSummaryMessage, + ...newMessages, + ] + const assembledOverBudget = messagesTokenEstimate(assembledWithPrevious) > this.config.triggerTokens + const canKeepTailWindow = newMessages.length > tailCount + + // If the new segment itself is too small to split but already over budget, + // fold all new messages into the existing summary instead of preserving them verbatim. + const tailStart = assembledOverBudget && !canKeepTailWindow + ? newMessages.length + : Math.max(0, newMessages.length - tailCount) + const toCompress = newMessages.slice(0, tailStart) + const tail = newMessages.slice(tailStart) + + if (toCompress.length === 0) { + return { + messages: assembledWithPrevious, + meta: { + ...meta, + compressed: true, + llmCompressed: false, + summaryTokenEstimate: countTokens(SUMMARY_PREFIX + previousSummary), + verbatimCount: head.length + newMessages.length, + compressedStartIndex: lastMessageIndex, + }, + } + } + + logger.info( + '[context-compressor] [incremental-llm] compressing %d of %d new messages, keeping %d tail', + toCompress.length, newMessages.length, tail.length, + ) + + let summary: string | null = null + try { + const contentToSummarize = serializeForSummary(toCompress) + const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget) + + const t0 = Date.now() + summary = await callSummarizer(upstream, apiKey, prompt, [], this.config.summarizationTimeoutMs, previousSummary, summarizer) + logger.info('[context-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary.length) + } catch (err: any) { + logger.warn('[context-compressor] incremental-llm failed: %s — keeping new messages verbatim', err.message) + const fallback = [ + ...head, + previousSummaryMessage, + ...newMessages, + ] + const prunedFallback = pruneFallbackToolResults(fallback, this.config.tailMessageCount) + const budgetedFallback = enforceCompressedBudget(prunedFallback, this.config.triggerTokens, head.length) + return { + messages: budgetedFallback, + meta: { + ...meta, + compressed: true, + llmCompressed: false, + summaryTokenEstimate: countTokens(SUMMARY_PREFIX + previousSummary), + verbatimCount: budgetedFallback.length === fallback.length ? head.length + newMessages.length : 0, + compressedStartIndex: lastMessageIndex, + }, + } + } + + let result: ChatMessage[] = [ + ...head, + { role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary }, + ...tail, + ] + result = enforceCompressedBudget(result, this.config.triggerTokens, head.length) + + const newLastIndex = lastMessageIndex + tailStart + if (sessionId) { + saveCompressionSnapshot(sessionId, summary, newLastIndex, total) + } + + return { + messages: result, + meta: { + ...meta, + compressed: true, + llmCompressed: true, + summaryTokenEstimate: countTokens(SUMMARY_PREFIX + summary), + verbatimCount: result.length === head.length + 1 + tail.length ? head.length + tail.length : 0, + compressedStartIndex: newLastIndex, + }, + } + } + + private async fullCompress( + messages: ChatMessage[], + upstream: string, + apiKey: string | undefined, + sessionId: string, + meta: CompressedResult['meta'], + summarizer?: string | SummarizerOptions, + ): Promise { + const total = messages.length + const requestedHeadCount = Math.min(this.config.headMessageCount, total) + const requestedTailCount = this.config.tailMessageCount + const canKeepProtectedWindows = total > requestedHeadCount + requestedTailCount + const headCount = canKeepProtectedWindows ? requestedHeadCount : 0 + const tailCount = canKeepProtectedWindows ? requestedTailCount : 0 + + const tailStart = total - tailCount + const head = messages.slice(0, headCount) + const toCompress = messages.slice(headCount, tailStart) + const tail = messages.slice(tailStart) + + logger.info( + '[context-compressor] [full-llm] compressing messages %d-%d, keeping first %d and last %d', + headCount, tailStart - 1, head.length, tail.length, + ) + + const contentToSummarize = serializeForSummary(toCompress) + const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget) + + let summary: string | null = null + try { + const t0 = Date.now() + summary = await callSummarizer(upstream, apiKey, prompt, [], this.config.summarizationTimeoutMs, undefined, summarizer) + logger.info('[context-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary.length) + } catch (err: any) { + logger.warn('[context-compressor] full-llm failed: %s', err.message) + } + + if (!summary) { + return { messages: pruneFallbackToolResults(messages, this.config.tailMessageCount), meta } + } + + const result: ChatMessage[] = [] + + result.push(...head) + result.push({ role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary }) + if (sessionId) { + saveCompressionSnapshot(sessionId, summary, tailStart - 1, total) + } + + result.push(...tail) + const budgetedResult = enforceCompressedBudget(result, this.config.triggerTokens, head.length) + + return { + messages: budgetedResult, + meta: { + ...meta, + compressed: true, + llmCompressed: !!summary, + summaryTokenEstimate: summary ? countTokens(SUMMARY_PREFIX + summary) : 0, + verbatimCount: budgetedResult.length === result.length ? head.length + tail.length : 0, + compressedStartIndex: tailStart - 1, + }, + } + } + + /** Remove snapshot for a session (e.g. when session is deleted) */ + static invalidateSnapshot(sessionId: string): void { + deleteCompressionSnapshot(sessionId) + } +} + +async function* readSseFrames(stream: ReadableStream): AsyncGenerator<{ event?: string; data: string }> { + const decoder = new TextDecoder() + const reader = stream.getReader() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let boundary = buffer.indexOf('\n\n') + while (boundary >= 0) { + const raw = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const frame = parseSseFrame(raw) + if (frame?.data) yield frame + boundary = buffer.indexOf('\n\n') + } + } + + buffer += decoder.decode() + const frame = parseSseFrame(buffer) + if (frame?.data) yield frame + } finally { + reader.releaseLock() + } +} + +function parseSseFrame(raw: string): { event?: string; data: string } | null { + let event: string | undefined + const data: string[] = [] + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith(':')) continue + if (line.startsWith('event:')) { + event = line.slice(6).trim() + } else if (line.startsWith('data:')) { + data.push(line.slice(5).trimStart()) + } + } + if (data.length === 0) return null + return { event, data: data.join('\n') } +} + +function extractResponseText(response: any): string { + const output = Array.isArray(response?.output) ? response.output : [] + const parts: string[] = [] + for (const item of output) { + if (item.type !== 'message') continue + const content = Array.isArray(item.content) ? item.content : [] + for (const part of content) { + if (part.type === 'output_text' || part.type === 'text') { + parts.push(part.text || '') + } + } + } + if (parts.length > 0) return parts.join('') + return typeof response?.output_text === 'string' ? response.output_text : '' +} diff --git a/packages/server/src/lib/llm-json.ts b/packages/server/src/lib/llm-json.ts new file mode 100644 index 0000000..57a6dd9 --- /dev/null +++ b/packages/server/src/lib/llm-json.ts @@ -0,0 +1,267 @@ +/** + * LLM JSON Parsing Utilities + * + * Handles unreliable JSON output from large language models. + * Provides extraction, tolerant parsing, and validation. + * + * Based on production-grade patterns for handling LLM JSON: + * - Extract JSON from text (code blocks, plain objects) + * - Fix common LLM mistakes (single quotes, missing quotes, trailing commas) + * - Validate against schema (zod) + * - Retry on failure + */ + +/** + * Extract JSON string from LLM text output. + * Handles: ```json code blocks, plain {...} objects + */ +export function extractJSON(text: string): string { + if (!text || typeof text !== 'string') { + throw new Error('Invalid text: must be non-empty string') + } + + const trimmed = text.trim() + + // Extract from ```json ... ``` code block + const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/) + if (codeBlockMatch) { + return codeBlockMatch[1].trim() + } + + // Extract first {...} object (greedy match for nested objects) + const objectMatch = trimmed.match(/\{[\s\S]*\}/) + if (objectMatch) { + return objectMatch[0] + } + + // Extract first [...] array (greedy match for nested arrays) + const arrayMatch = trimmed.match(/\[[\s\S]*\]/) + if (arrayMatch) { + return arrayMatch[0] + } + + throw new Error('No JSON found in text (no code blocks, objects, or arrays detected)') +} + +/** + * Fix common LLM JSON mistakes before parsing. + * Handles: single quotes, unquoted keys, trailing commas, Python booleans/null + */ +export function fixLLMJSON(jsonStr: string): string { + if (!jsonStr || typeof jsonStr !== 'string') { + throw new Error('Invalid JSON string') + } + + let fixed = jsonStr + + // Fix 1: Python boolean/null literals + fixed = fixed.replace(/\bTrue\b/g, 'true') + fixed = fixed.replace(/\bFalse\b/g, 'false') + fixed = fixed.replace(/\bNone\b/g, 'null') + + // Fix 2: Single quotes to double quotes (but be careful with escaped quotes) + // This is a simple replacement - works for most cases but may fail on edge cases + fixed = fixed.replace(/'/g, '"') + + // Fix 3: Unquoted object keys (e.g., {name: "value"} -> {"name": "value"}) + // Match word followed by : (not already quoted) + fixed = fixed.replace(/(\w+):/g, '"$1":') + + // Fix 4: Trailing commas in objects + fixed = fixed.replace(/,\s*}/g, '}') + + // Fix 5: Trailing commas in arrays + fixed = fixed.replace(/,\s*]/g, ']') + + // Fix 6: Remove extra text before/after JSON (common in LLM outputs) + // Find first { or [ and match to closing bracket + const firstBrace = fixed.indexOf('{') + const firstBracket = fixed.indexOf('[') + + if (firstBrace >= 0 && (firstBracket < 0 || firstBrace < firstBracket)) { + // Object first + let depth = 0 + let start = firstBrace + let end = -1 + for (let i = start; i < fixed.length; i++) { + if (fixed[i] === '{') depth++ + else if (fixed[i] === '}') depth-- + if (depth === 0) { + end = i + 1 + break + } + } + if (end > 0) fixed = fixed.substring(start, end) + } else if (firstBracket >= 0) { + // Array first + let depth = 0 + let start = firstBracket + let end = -1 + for (let i = start; i < fixed.length; i++) { + if (fixed[i] === '[') depth++ + else if (fixed[i] === ']') depth-- + if (depth === 0) { + end = i + 1 + break + } + } + if (end > 0) fixed = fixed.substring(start, end) + } + + return fixed +} + +/** + * Parse LLM JSON with fallback attempts. + * Tries: direct parse -> fixed parse -> extracted parse + */ +export function parseLLMJSON(text: string, retries = 3): any { + const errors: Error[] = [] + + // Attempt 1: Direct parse (already valid JSON) + try { + return JSON.parse(text) + } catch (e) { + errors.push(e as Error) + } + + for (let attempt = 0; attempt < retries; attempt++) { + try { + // Attempt 2: Extract and fix + const extracted = extractJSON(text) + const fixed = fixLLMJSON(extracted) + return JSON.parse(fixed) + } catch (e) { + errors.push(e as Error) + // If extraction failed, try fixing the whole text + try { + const fixed = fixLLMJSON(text) + return JSON.parse(fixed) + } catch (e2) { + errors.push(e2 as Error) + } + } + } + + // All attempts failed + const error = new Error(`Failed to parse LLM JSON after ${retries + 1} attempts`) + error.cause = errors + throw error +} + +/** + * Parse LLM JSON with schema validation (zod). + * Returns validated data or throws validation error. + */ +export async function parseLLMJSONWithSchema( + text: string, + schema: { parse: (data: any) => T }, + retries = 3 +): Promise { + const data = parseLLMJSON(text, retries) + + try { + return schema.parse(data) + } catch (e) { + const error = new Error('LLM JSON schema validation failed') + error.cause = e + throw error + } +} + +/** + * Safe parse - returns null on failure instead of throwing. + * Useful for optional JSON fields in LLM responses. + */ +export function safeParseLLMJSON(text: string): any | null { + try { + return parseLLMJSON(text, 1) + } catch { + return null + } +} + +/** + * Parse tool_call arguments from LLM output. + * Specifically optimized for OpenAI-style tool calls. + */ +export function parseToolArguments(args: string | object): any { + if (typeof args === 'object') { + return args // Already parsed + } + + if (typeof args !== 'string') { + throw new Error('Invalid arguments: must be string or object') + } + + const trimmed = args.trim() + + // Handle empty object + if (trimmed === '{}' || trimmed === '[]') { + return trimmed === '{}' ? {} : [] + } + + try { + // Try direct parse first + return JSON.parse(trimmed) + } catch { + // Fall back to LLM JSON parsing + return parseLLMJSON(trimmed, 2) + } +} + +/** + * Parse array content from LLM (common in Anthropic-style messages). + * Handles Python-style arrays with thinking/text/tool_use blocks. + */ +export function parseAnthropicContentArray(content: string): Array<{ + type: string + text?: string + thinking?: string + id?: string + name?: string + input?: any +}> { + if (!content || typeof content !== 'string') { + return [] + } + + const trimmed = content.trim() + + // Handle double-serialized content: "[{...}]" -> "[{...}]" + let contentToParse = trimmed + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { + contentToParse = trimmed.slice(1, -1) + } + + if (!contentToParse.startsWith('[') || !contentToParse.endsWith(']')) { + throw new Error('Content is not an array') + } + + try { + // Parse with Python-to-JSON conversion + const parsed = JSON.parse( + contentToParse + .replace(/'/g, '"') // Python single quotes + .replace(/True/g, 'true') + .replace(/False/g, 'false') + .replace(/None/g, 'null') + ) + + if (!Array.isArray(parsed)) { + throw new Error('Parsed content is not an array') + } + + return parsed + } catch (e) { + // Fall back to full LLM JSON parsing + const fixed = fixLLMJSON(contentToParse) + const parsed = JSON.parse(fixed) + + if (!Array.isArray(parsed)) { + throw new Error('Parsed content is not an array') + } + + return parsed + } +} diff --git a/packages/server/src/lib/llm-prompt.ts b/packages/server/src/lib/llm-prompt.ts new file mode 100644 index 0000000..405f785 --- /dev/null +++ b/packages/server/src/lib/llm-prompt.ts @@ -0,0 +1,91 @@ +/** + * LLM System Prompts and Instructions + * + * This module contains system prompts and format guidelines for LLM agents. + * These prompts ensure that AI outputs are correctly rendered by the frontend. + */ + +/** + * System prompt for AI output format guidelines + * Add this to your agent's system prompt to ensure proper formatting + */ +export const AI_OUTPUT_FORMAT_GUIDELINES = ` +# 输出格式规范 + +当你的回复中包含图片、视频或文件引用时,必须使用 Markdown,并引用本地绝对路径。 + +## 路径规则 + +- Unix/macOS/WSL:使用 \`/path/to/file\`,例如 \`/tmp/screenshot.png\` +- Windows:使用盘符绝对路径,并把反斜杠 \`\\\` 转成正斜杠 \`/\`,例如 \`C:/Users/Administrator/Desktop/screenshot.png\` +- Windows 路径必须用尖括号包住链接目标,避免盘符冒号或特殊字符被 Markdown 误解析,例如 \`\` +- 路径包含空格、中文或特殊字符时,必须使用尖括号包住链接目标,或对路径做 URL 编码 +- 确保文件确实存在且路径正确 + +## 图片格式 + +使用 Markdown 图片语法: + +\`\`\` +![图片描述](/tmp/screenshot.png) +![Sub2API Dashboard](/tmp/sub2api-dashboard.png) +![桌面截图]() +\`\`\` + +## 视频格式 + +使用 Markdown 链接语法引用视频文件,支持格式:.mp4、.webm、.mov。视频会显示为可播放的视频播放器(最大 640x480),支持原生播放控件。 + +\`\`\` +[屏幕录制](/tmp/screen-recording.mp4) +[操作演示](/tmp/demo.webm) +[录屏2026-05-08 15.19.46](/Users/ekko/Desktop/录屏2026-05-08%2015.19.46.mov) +[录屏2026-05-08 15.19.46]() +[Windows 录屏]() +\`\`\` + +错误示例: +\`\`\` +[录屏2026-05-08 15.19.46](/Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov) +![桌面截图](C:\\Users\\Administrator\\Desktop\\screenshot.png) +\`\`\` + +## 文件链接格式 + +使用 Markdown 链接语法: + +\`\`\` +[下载报告](/tmp/monthly-report.pdf) +[下载报告]() +\`\`\` + +## 发送文件给用户 + +当用户要求"发给我"、"发送给我"、"传给我"等请求文件时,使用上述格式返回文件路径: + +\`\`\` +![图片描述](/path/to/image.png) +![Windows 图片]() +[视频名](/path/to/video.mp4) +[Windows 视频]() +[文件名](/path/to/file.pdf) +[Windows 文件]() +\`\`\` +`; + +/** + * Get the complete system prompt with format guidelines + * @param customPrompt - Optional custom system prompt to prepend + * @returns Complete system prompt string + */ +export function getSystemPrompt(customPrompt?: string): string { + const parts: string[] = []; + + if (customPrompt) { + parts.push(customPrompt); + } + + parts.push(AI_OUTPUT_FORMAT_GUIDELINES); + + return parts.join('\n\n'); +} diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts new file mode 100644 index 0000000..4922350 --- /dev/null +++ b/packages/server/src/middleware/user-auth.ts @@ -0,0 +1,245 @@ +import type { Context, Next } from 'koa' +import { createHmac, timingSafeEqual } from 'crypto' +import { getToken } from '../services/auth' +import { + findUserById, + listUserProfiles, + touchUserLogin, + userCanAccessProfile, + type UserRecord, + type UserRole, +} from '../db/hermes/users-store' + +export interface AuthenticatedUser { + id: number + username: string + role: UserRole + profiles?: string[] +} + +export interface RequestProfile { + name: string +} + +interface JwtPayload { + sub: string + username: string + role: UserRole + type: 'access' + aud: 'hermes-web-ui' + iat: number + exp: number +} + +declare module 'koa' { + interface DefaultState { + user?: AuthenticatedUser + profile?: RequestProfile + serverTokenAuth?: boolean + } +} + +const JWT_AUDIENCE = 'hermes-web-ui' +const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30 + +function base64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function sign(input: string, secret: string): string { + return createHmac('sha256', secret).update(input).digest('base64url') +} + +function safeEqual(a: string, b: string): boolean { + try { + const left = Buffer.from(a) + const right = Buffer.from(b) + return left.length === right.length && timingSafeEqual(left, right) + } catch { + return false + } +} + +async function getJwtSecret(): Promise { + return process.env.AUTH_JWT_SECRET || await getToken() +} + +function requestToken(ctx: Context): string { + const auth = ctx.headers.authorization || '' + if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim() + return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : '' +} + +const SERVER_TOKEN_MEDIA_PATHS = new Set([ + '/api/hermes/media/apikey-image-generate', + '/api/hermes/media/grok-image-to-video', +]) + +async function allowServerTokenForMedia(ctx: Context, token: string): Promise { + if (!token || !SERVER_TOKEN_MEDIA_PATHS.has(ctx.path)) return false + const serverToken = await getToken() + if (token !== serverToken) return false + ctx.state.serverTokenAuth = true + return true +} + +function isProtectedHttpPath(path: string): boolean { + const lowerPath = path.toLowerCase() + return lowerPath.startsWith('/api') || + lowerPath.startsWith('/v1') || + lowerPath.startsWith('/upload') +} + +export function signUserJwt(user: Pick, secret: string, now = Date.now()): string { + const iat = Math.floor(now / 1000) + const payload: JwtPayload = { + sub: String(user.id), + username: user.username, + role: user.role, + type: 'access', + aud: JWT_AUDIENCE, + iat, + exp: iat + DEFAULT_EXPIRES_SECONDS, + } + const header = base64UrlJson({ alg: 'HS256', typ: 'JWT' }) + const body = base64UrlJson(payload) + const unsigned = `${header}.${body}` + return `${unsigned}.${sign(unsigned, secret)}` +} + +export function verifyUserJwt(token: string, secret: string, now = Date.now()): JwtPayload | null { + const parts = token.split('.') + if (parts.length !== 3) return null + + const [header, body, signature] = parts + const expected = sign(`${header}.${body}`, secret) + if (!safeEqual(signature, expected)) return null + + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial + if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null + if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null + if (Math.floor(now / 1000) >= payload.exp) return null + return payload as JwtPayload + } catch { + return null + } +} + +export async function issueUserJwt(user: Pick): Promise { + const secret = await getJwtSecret() + return signUserJwt(user, secret) +} + +export function toAuthenticatedUser(user: Pick): AuthenticatedUser { + const authenticated: AuthenticatedUser = { + id: user.id, + username: user.username, + role: user.role, + } + if (user.role !== 'super_admin') { + authenticated.profiles = listUserProfiles(user.id).map(profile => profile.profile_name) + } + return authenticated +} + +export async function authenticateUserToken(token: string): Promise { + const secret = await getJwtSecret() + + const payload = token ? verifyUserJwt(token, secret) : null + if (!payload) return null + + const user = findUserById(payload.sub) + if (!user || user.status !== 'active') return null + return toAuthenticatedUser(user) +} + +export async function isAuthEnabled(): Promise { + await getJwtSecret() + return true +} + +export async function requireUserJwt(ctx: Context, next: Next): Promise { + if (!isProtectedHttpPath(ctx.path)) { + await next() + return + } + + const secret = await getJwtSecret() + const token = requestToken(ctx) + const payload = token ? verifyUserJwt(token, secret) : null + if (!payload) { + if (await allowServerTokenForMedia(ctx, token)) { + await next() + return + } + ctx.status = 401 + ctx.body = { error: 'Unauthorized' } + return + } + + const user = findUserById(payload.sub) + if (!user || user.status !== 'active') { + ctx.status = 403 + ctx.body = { error: 'User is disabled or does not exist' } + return + } + + ctx.state.user = toAuthenticatedUser(user) + touchUserLogin(user.id) + await next() +} + +export async function requireSuperAdmin(ctx: Context, next: Next): Promise { + if (ctx.state.user?.role !== 'super_admin') { + ctx.status = 403 + ctx.body = { error: 'Super administrator privileges are required' } + return + } + await next() +} + +export function resolveRequestedProfile(ctx: Context): string { + if (ctx.path === '/api/hermes/available-models' && typeof ctx.query.profile !== 'string') { + return '' + } + const headerProfile = ctx.get('x-hermes-profile') + const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : '' + const body = ctx.request.body as { profile?: unknown } | undefined + const bodyProfile = typeof body?.profile === 'string' ? body.profile : '' + return (headerProfile || queryProfile || bodyProfile || '').trim() +} + +export async function resolveUserProfile(ctx: Context, next: Next): Promise { + const user = ctx.state.user + if (!user) { + await next() + return + } + + const profileName = resolveRequestedProfile(ctx) + if (!profileName) { + await next() + return + } + + if (user.role !== 'super_admin' && !userCanAccessProfile(user.id, profileName)) { + ctx.status = 403 + ctx.body = { error: `Profile "${profileName}" is not available for this user` } + return + } + + ctx.state.profile = { name: profileName } + await next() +} + +export async function requireUserProfile(ctx: Context, next: Next): Promise { + if (!ctx.state.profile?.name) { + ctx.status = 400 + ctx.body = { error: 'Profile is required' } + return + } + await next() +} + +export const userAuthMiddleware = [requireUserJwt, resolveUserProfile] diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000..9144f11 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,22 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/auth' +import { requireSuperAdmin } from '../middleware/user-auth' + +// Public routes (no auth required) +export const authPublicRoutes = new Router() +authPublicRoutes.get('/api/auth/status', ctrl.authStatus) +authPublicRoutes.post('/api/auth/login', ctrl.login) + +// Protected routes (auth required) +export const authProtectedRoutes = new Router() +authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword) +authProtectedRoutes.get('/api/auth/me', ctrl.currentUser) +authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword) +authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername) +authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword) +authProtectedRoutes.get('/api/auth/users', requireSuperAdmin, ctrl.listManagedUsers) +authProtectedRoutes.post('/api/auth/users', requireSuperAdmin, ctrl.createManagedUser) +authProtectedRoutes.put('/api/auth/users/:id', requireSuperAdmin, ctrl.updateManagedUser) +authProtectedRoutes.delete('/api/auth/users/:id', requireSuperAdmin, ctrl.deleteManagedUser) +authProtectedRoutes.get('/api/auth/locked-ips', ctrl.listLockedIps) +authProtectedRoutes.delete('/api/auth/locked-ips', ctrl.unlockIpHandler) diff --git a/packages/server/src/routes/claude-code-proxy.ts b/packages/server/src/routes/claude-code-proxy.ts new file mode 100644 index 0000000..f8a815e --- /dev/null +++ b/packages/server/src/routes/claude-code-proxy.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import { claudeProxyMessages, claudeProxyModels } from '../services/claude-code-proxy' + +export const claudeCodeProxyRoutes = new Router() + +claudeCodeProxyRoutes.get('/api/claude-code-proxy/:key/v1/models', claudeProxyModels) +claudeCodeProxyRoutes.post('/api/claude-code-proxy/:key/v1/messages', claudeProxyMessages) diff --git a/packages/server/src/routes/codex-proxy.ts b/packages/server/src/routes/codex-proxy.ts new file mode 100644 index 0000000..f425884 --- /dev/null +++ b/packages/server/src/routes/codex-proxy.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import { codexProxyModels, codexProxyResponses } from '../services/codex-proxy' + +export const codexProxyRoutes = new Router() + +codexProxyRoutes.get('/api/codex-proxy/:key/v1/models', codexProxyModels) +codexProxyRoutes.post('/api/codex-proxy/:key/v1/responses', codexProxyResponses) diff --git a/packages/server/src/routes/coding-agents.ts b/packages/server/src/routes/coding-agents.ts new file mode 100644 index 0000000..3c86826 --- /dev/null +++ b/packages/server/src/routes/coding-agents.ts @@ -0,0 +1,12 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/coding-agents' + +export const codingAgentRoutes = new Router() + +codingAgentRoutes.get('/api/coding-agents', ctrl.status) +codingAgentRoutes.post('/api/coding-agents/:id/install', ctrl.install) +codingAgentRoutes.post('/api/coding-agents/:id/launch/prepare', ctrl.prepareLaunch) +codingAgentRoutes.post('/api/coding-agents/:id/launch/native', ctrl.nativeLaunch) +codingAgentRoutes.delete('/api/coding-agents/:id', ctrl.remove) +codingAgentRoutes.get('/api/coding-agents/:id/config-files/:key', ctrl.readConfigFile) +codingAgentRoutes.put('/api/coding-agents/:id/config-files/:key', ctrl.writeConfigFile) diff --git a/packages/server/src/routes/health.ts b/packages/server/src/routes/health.ts new file mode 100644 index 0000000..d860f49 --- /dev/null +++ b/packages/server/src/routes/health.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/health' + +export const healthRoutes = new Router() + +healthRoutes.get('/health', ctrl.healthCheck) + +export { startVersionCheck } from '../controllers/health' diff --git a/packages/server/src/routes/hermes/chat-run.ts b/packages/server/src/routes/hermes/chat-run.ts new file mode 100644 index 0000000..b0fc393 --- /dev/null +++ b/packages/server/src/routes/hermes/chat-run.ts @@ -0,0 +1,11 @@ +import type { ChatRunSocket } from '../../services/hermes/run-chat' + +let chatRunServer: ChatRunSocket | null = null + +export function setChatRunServer(server: ChatRunSocket): void { + chatRunServer = server +} + +export function getChatRunServer(): ChatRunSocket | null { + return chatRunServer +} diff --git a/packages/server/src/routes/hermes/codex-auth.ts b/packages/server/src/routes/hermes/codex-auth.ts new file mode 100644 index 0000000..79d10c7 --- /dev/null +++ b/packages/server/src/routes/hermes/codex-auth.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/codex-auth' + +export const codexAuthRoutes = new Router() + +codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start) +codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll) +codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status) diff --git a/packages/server/src/routes/hermes/config.ts b/packages/server/src/routes/hermes/config.ts new file mode 100644 index 0000000..8c41945 --- /dev/null +++ b/packages/server/src/routes/hermes/config.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/config' + +export const configRoutes = new Router() + +configRoutes.get('/api/hermes/config', ctrl.getConfig) +configRoutes.put('/api/hermes/config', ctrl.updateConfig) +configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials) diff --git a/packages/server/src/routes/hermes/copilot-auth.ts b/packages/server/src/routes/hermes/copilot-auth.ts new file mode 100644 index 0000000..6152b09 --- /dev/null +++ b/packages/server/src/routes/hermes/copilot-auth.ts @@ -0,0 +1,10 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/copilot-auth' + +export const copilotAuthRoutes = new Router() + +copilotAuthRoutes.post('/api/hermes/auth/copilot/start', ctrl.start) +copilotAuthRoutes.get('/api/hermes/auth/copilot/poll/:sessionId', ctrl.poll) +copilotAuthRoutes.get('/api/hermes/auth/copilot/check-token', ctrl.checkToken) +copilotAuthRoutes.post('/api/hermes/auth/copilot/enable', ctrl.enable) +copilotAuthRoutes.post('/api/hermes/auth/copilot/disable', ctrl.disable) diff --git a/packages/server/src/routes/hermes/cron-history.ts b/packages/server/src/routes/hermes/cron-history.ts new file mode 100644 index 0000000..cb8c41f --- /dev/null +++ b/packages/server/src/routes/hermes/cron-history.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/cron-history' + +export const cronHistoryRoutes = new Router() + +cronHistoryRoutes.get('/api/cron-history', ctrl.listRuns) +cronHistoryRoutes.get('/api/cron-history/:jobId/:fileName', ctrl.readRun) diff --git a/packages/server/src/routes/hermes/download.ts b/packages/server/src/routes/hermes/download.ts new file mode 100644 index 0000000..d67ca71 --- /dev/null +++ b/packages/server/src/routes/hermes/download.ts @@ -0,0 +1,120 @@ +import Router from '@koa/router' +import { basename, extname, isAbsolute } from 'path' +import { + createFileProvider, + localProvider, + isInUploadDir, + validatePath, + resolveHermesPath, +} from '../../services/hermes/file-provider' +import { getActiveProfileName } from '../../services/hermes/hermes-profile' + +export const downloadRoutes = new Router() + +// MIME type mapping for common extensions +const MIME_MAP: Record = { + '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.xml': 'application/xml', + '.csv': 'text/csv', + '.md': 'text/markdown', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.gz': 'application/gzip', + '.tar': 'application/x-tar', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.py': 'text/x-python', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.rs': 'text/x-rust', + '.go': 'text/x-go', + '.java': 'text/x-java', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.sh': 'text/x-shellscript', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'text/toml', + '.log': 'text/plain', +} + +function getMimeType(fileName: string): string { + const ext = extname(fileName).toLowerCase() + return MIME_MAP[ext] || 'application/octet-stream' +} + +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +downloadRoutes.get('/api/hermes/download', async (ctx) => { + const filePath = ctx.query.path as string | undefined + const fileName = ctx.query.name as string | undefined + + if (!filePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + + try { + const profile = requestedProfile(ctx) + // Validate the path first + // Support both absolute and relative paths + const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath, profile) + + // Choose provider: always use local for upload directory files + let data: Buffer + if (isInUploadDir(validPath)) { + data = await localProvider.readFile(validPath) + } else { + const provider = await createFileProvider(profile) + data = await provider.readFile(validPath) + } + + // Determine filename and MIME type + const name = fileName || basename(validPath) + const mime = getMimeType(name) + + // Set response headers + ctx.set('Content-Type', mime) + ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`) + ctx.set('Content-Length', String(data.length)) + ctx.set('Cache-Control', 'no-cache') + ctx.body = data + } catch (err: any) { + const code = err.code || 'unknown' + const statusMap: Record = { + missing_path: 400, + invalid_path: 400, + not_found: 404, + ENOENT: 404, + file_too_large: 413, + unsupported_backend: 501, + backend_error: 502, + backend_timeout: 504, + } + ctx.status = statusMap[code] || 500 + ctx.body = { error: err.message, code } + } +}) diff --git a/packages/server/src/routes/hermes/files.ts b/packages/server/src/routes/hermes/files.ts new file mode 100644 index 0000000..b9746ce --- /dev/null +++ b/packages/server/src/routes/hermes/files.ts @@ -0,0 +1,299 @@ +import Router from '@koa/router' +import { + createFileProvider, + resolveHermesPath, + isSensitivePath, + MAX_EDIT_SIZE, +} from '../../services/hermes/file-provider' + +function requestedProfile(ctx: any): string | undefined { + return ctx.state?.profile?.name +} + +function resolveRequestPath(ctx: any, relativePath: string): string { + return resolveHermesPath(relativePath, requestedProfile(ctx)) +} + +async function createRequestFileProvider(ctx: any) { + return createFileProvider(requestedProfile(ctx)) +} + +function withAbsolutePath(ctx: any, entry: T): T & { absolutePath: string } { + return { ...entry, absolutePath: resolveRequestPath(ctx, entry.path) } +} + +export const fileRoutes = new Router() + +function handleError(ctx: any, err: any) { + const code = err.code || 'unknown' + const statusMap: Record = { + missing_path: 400, + invalid_path: 400, + not_found: 404, + ENOENT: 404, + already_exists: 409, + permission_denied: 403, + file_too_large: 413, + not_a_directory: 400, + not_a_file: 400, + unsupported_backend: 501, + backend_error: 502, + backend_timeout: 504, + } + ctx.status = statusMap[code] || 500 + ctx.body = { error: err.message, code } +} + +// GET /api/hermes/files/list?path= +fileRoutes.get('/api/hermes/files/list', async (ctx) => { + const relativePath = (ctx.query.path as string) || '' + try { + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + const entries = await provider.listDir(absPath) + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + ctx.body = { entries: entries.map(entry => withAbsolutePath(ctx, entry)), path: relativePath, absolutePath: absPath } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// GET /api/hermes/files/stat?path= +fileRoutes.get('/api/hermes/files/stat', async (ctx) => { + const relativePath = ctx.query.path as string + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + const info = await provider.stat(absPath) + ctx.body = withAbsolutePath(ctx, info) + } catch (err: any) { + handleError(ctx, err) + } +}) + +// GET /api/hermes/files/read?path= +fileRoutes.get('/api/hermes/files/read', async (ctx) => { + const relativePath = ctx.query.path as string + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + const data = await provider.readFile(absPath) + if (data.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: 'File too large to edit', code: 'file_too_large' } + return + } + ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// PUT /api/hermes/files/write body: { path, content } +fileRoutes.put('/api/hermes/files/write', async (ctx) => { + const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + if (isSensitivePath(relativePath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' } + return + } + try { + const buf = Buffer.from(content || '', 'utf-8') + if (buf.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: 'Content too large', code: 'file_too_large' } + return + } + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + await provider.writeFile(absPath, buf) + ctx.body = { ok: true, path: relativePath } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// DELETE /api/hermes/files/delete body: { path, recursive? } +fileRoutes.delete('/api/hermes/files/delete', async (ctx) => { + const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + if (isSensitivePath(relativePath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' } + return + } + try { + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + if (recursive) { + await provider.deleteDir(absPath) + } else { + await provider.deleteFile(absPath) + } + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/rename body: { oldPath, newPath } +fileRoutes.post('/api/hermes/files/rename', async (ctx) => { + const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string } + if (!oldPath || !newPath) { + ctx.status = 400 + ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' } + return + } + if (isSensitivePath(oldPath)) { + ctx.status = 403 + ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' } + return + } + try { + const absOld = resolveRequestPath(ctx, oldPath) + const absNew = resolveRequestPath(ctx, newPath) + const provider = await createRequestFileProvider(ctx) + await provider.renameFile(absOld, absNew) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/mkdir body: { path } +fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => { + const { path: relativePath } = ctx.request.body as { path?: string } + if (!relativePath) { + ctx.status = 400 + ctx.body = { error: 'Missing path parameter', code: 'missing_path' } + return + } + try { + const absPath = resolveRequestPath(ctx, relativePath) + const provider = await createRequestFileProvider(ctx) + await provider.mkDir(absPath) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/copy body: { srcPath, destPath } +fileRoutes.post('/api/hermes/files/copy', async (ctx) => { + const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string } + if (!srcPath || !destPath) { + ctx.status = 400 + ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' } + return + } + try { + const absSrc = resolveRequestPath(ctx, srcPath) + const absDest = resolveRequestPath(ctx, destPath) + const provider = await createRequestFileProvider(ctx) + await provider.copyFile(absSrc, absDest) + ctx.body = { ok: true } + } catch (err: any) { + handleError(ctx, err) + } +}) + +// POST /api/hermes/files/upload?path= (multipart/form-data) +fileRoutes.post('/api/hermes/files/upload', async (ctx) => { + const targetDir = (ctx.query.path as string) || '' + const contentType = ctx.get('content-type') || '' + if (!contentType.startsWith('multipart/form-data')) { + ctx.status = 400 + ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' } + return + } + + const boundary = '--' + contentType.split('boundary=')[1] + if (!boundary || boundary === '--undefined') { + ctx.status = 400 + ctx.body = { error: 'Missing boundary', code: 'invalid_request' } + return + } + + const chunks: Buffer[] = [] + for await (const chunk of ctx.req) chunks.push(chunk) + const raw = Buffer.concat(chunks) + + const boundaryBuf = Buffer.from(boundary) + const parts = splitMultipart(raw, boundaryBuf) + const provider = await createRequestFileProvider(ctx) + const results: { name: string; path: string }[] = [] + + for (const part of parts) { + const headerEnd = part.indexOf(Buffer.from('\r\n\r\n')) + if (headerEnd === -1) continue + const headerBuf = part.subarray(0, headerEnd) + const header = headerBuf.toString('utf-8') + const data = part.subarray(headerEnd + 4, part.length - 2) + + let filename = '' + const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i) + if (filenameStarMatch) { + filename = decodeURIComponent(filenameStarMatch[1]) + } else { + const filenameMatch = header.match(/filename="([^"]+)"/) + if (!filenameMatch) continue + filename = filenameMatch[1] + } + + if (data.length > MAX_EDIT_SIZE) { + ctx.status = 413 + ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' } + return + } + + const filePath = targetDir ? `${targetDir}/${filename}` : filename + if (isSensitivePath(filePath)) { + ctx.status = 403 + ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' } + return + } + + const absPath = resolveRequestPath(ctx, filePath) + await provider.writeFile(absPath, data) + results.push({ name: filename, path: filePath }) + } + + ctx.body = { files: results } +}) + +function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] { + const parts: Buffer[] = [] + let start = 0 + while (true) { + const idx = raw.indexOf(boundary, start) + if (idx === -1) break + if (start > 0) { + const partStart = start + 2 + parts.push(raw.subarray(partStart, idx)) + } + start = idx + boundary.length + } + return parts +} diff --git a/packages/server/src/routes/hermes/group-chat.ts b/packages/server/src/routes/hermes/group-chat.ts new file mode 100644 index 0000000..7d0aa45 --- /dev/null +++ b/packages/server/src/routes/hermes/group-chat.ts @@ -0,0 +1,415 @@ +import Router from '@koa/router' +import type { GroupChatServer } from '../../services/hermes/group-chat' +import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing' + +export const groupChatRoutes = new Router() + +let chatServer: GroupChatServer | null = null + +export function setGroupChatServer(server: GroupChatServer) { + chatServer = server +} + +export function getGroupChatServer(): GroupChatServer | null { + return chatServer +} + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +function generateInviteCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let code = '' + for (let i = 0; i < 6; i++) { + code += chars[Math.floor(Math.random() * chars.length)] + } + return code +} + +type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number } + +function sanitizeAgentConnectReason(reason?: string): string { + return (reason || 'agent runtime connection failed') + .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]') + .replace(/(api[_-]?key|token|secret|password)=([^\s]+)/gi, '$1=[REDACTED]') + .split('\n')[0] + .slice(0, 240) +} + +function agentConnectFailureBody(profile: string, err: any) { + return { + code: 'PROFILE_AGENT_CONNECT_FAILED', + error: `Failed to connect agent "${profile}" to room`, + profile, + reason: sanitizeAgentConnectReason(err?.message), + } +} + +async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) { + const profile = input.profile + const name = input.name || profile + const description = input.description || '' + const invited = input.invited ? 1 : 0 + const client = await server.agentClients.createAgent({ + agentId, + profile, + name, + description, + invited, + }) + + try { + await server.agentClients.addAgentToRoom(roomId, client) + return server.getStorage().addRoomAgent(roomId, agentId, profile, name, description, invited) + } catch (err) { + server.agentClients.removeAgentFromRoom(roomId, client.agentId) + throw err + } +} + +// Create room +groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const { name, inviteCode, agents, compression } = ctx.request.body as { + name?: string + inviteCode?: string + agents?: { profile: string; name?: string; description?: string; invited?: boolean }[] + compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number } + } + if (!name || !inviteCode) { + ctx.status = 400 + ctx.body = { error: 'name and inviteCode are required' } + return + } + const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile)) + if (reservedAgent) { + ctx.status = 400 + ctx.body = { error: '`all` is reserved for @all mentions' } + return + } + + const roomId = generateId() + const storage = chatServer.getStorage() + storage.saveRoom(roomId, name, inviteCode, compression) + + const addedAgents = [] + const agentResults = [] + for (const a of agents || []) { + try { + const agent = await connectAndPersistRoomAgent(chatServer, roomId, { + profile: a.profile, + name: a.name || a.profile, + description: a.description || '', + invited: a.invited, + }) + addedAgents.push(agent) + agentResults.push({ profile: a.profile, ok: true, agent }) + } catch (err: any) { + console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) + agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) }) + } + } + + const room = storage.getRoom(roomId) + ctx.body = { room, agents: addedAgents, agentResults } +}) + +// Clone room roles/config without copying the conversation context. +groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const sourceRoom = chatServer.getStorage().getRoom(ctx.params.roomId) + if (!sourceRoom) { + ctx.status = 404 + ctx.body = { error: 'Room not found' } + return + } + + const { name, inviteCode } = ctx.request.body as { name?: string; inviteCode?: string } + const roomId = generateId() + const storage = chatServer.getStorage() + const code = inviteCode?.trim() || generateInviteCode() + storage.saveRoom(roomId, name?.trim() || `${sourceRoom.name} Copy`, code, { + triggerTokens: sourceRoom.triggerTokens, + maxHistoryTokens: sourceRoom.maxHistoryTokens, + tailMessageCount: sourceRoom.tailMessageCount, + }) + + const addedAgents = [] + const agentResults = [] + for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) { + try { + const agent = await connectAndPersistRoomAgent(chatServer, roomId, { + profile: sourceAgent.profile, + name: sourceAgent.name, + description: sourceAgent.description, + invited: sourceAgent.invited, + }) + addedAgents.push(agent) + agentResults.push({ profile: sourceAgent.profile, ok: true, agent }) + } catch (err: any) { + console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) + agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) }) + } + } + + const room = storage.getRoom(roomId) + ctx.body = { room, agents: addedAgents, agentResults } +}) + +// Get room detail and messages +groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const room = chatServer.getStorage().getRoom(ctx.params.roomId) + if (!room) { + ctx.status = 404 + ctx.body = { error: 'Room not found' } + return + } + + const offset = ctx.query.offset ? Math.max(0, parseInt(ctx.query.offset as string, 10) || 0) : 0 + const limit = ctx.query.limit ? Math.max(1, parseInt(ctx.query.limit as string, 10) || 300) : 300 + const messages = chatServer.getStorage().getMessages(ctx.params.roomId, limit, offset) + const total = chatServer.getStorage().getMessageCount(ctx.params.roomId) + const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId) + const members = chatServer.getStorage().getRoomMembers(ctx.params.roomId) + ctx.body = { room, messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total } +}) + +// List rooms +groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const user = ctx.state.user + const storage = chatServer.getStorage() + const rooms = !user || user.role === 'super_admin' + ? storage.getAllRooms() + : storage.getRoomsForProfiles(user.profiles || []) + ctx.body = { rooms } +}) + +// Get room by invite code +groupChatRoutes.get('/api/hermes/group-chat/rooms/join/:code', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const room = chatServer.getStorage().getRoomByInviteCode(ctx.params.code) + if (!room) { + ctx.status = 404 + ctx.body = { error: 'Room not found' } + return + } + + ctx.body = { room } +}) + +// Update room invite code +groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/invite-code', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const { inviteCode } = ctx.request.body as { inviteCode?: string } + if (!inviteCode) { + ctx.status = 400 + ctx.body = { error: 'inviteCode is required' } + return + } + + chatServer.getStorage().updateRoomInviteCode(ctx.params.roomId, inviteCode) + ctx.body = { success: true } +}) + +// Add agent to room +groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const { profile, name, description, invited } = ctx.request.body as { profile?: string; name?: string; description?: string; invited?: boolean } + if (!profile) { + ctx.status = 400 + ctx.body = { error: 'profile is required' } + return + } + if (isReservedMentionName(name || profile)) { + ctx.status = 400 + ctx.body = { error: '`all` is reserved for @all mentions' } + return + } + + // Prevent duplicate agent in same room + const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId) + if (existing.find(a => a.profile === profile)) { + ctx.status = 409 + ctx.body = { error: 'Agent already in room' } + return + } + + try { + const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, { + profile, + name: name || profile, + description: description || '', + invited, + }) + ctx.body = { agent } + } catch (err: any) { + console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`) + ctx.status = 502 + ctx.body = agentConnectFailureBody(profile, err) + } +}) + +// List agents in room +groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId) + ctx.body = { agents } +}) + +// Remove agent from room +groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + const requestedAgentId = ctx.params.agentId + const storage = chatServer.getStorage() + const agent = storage.getRoomAgent(roomId, requestedAgentId) + if (!agent) { + ctx.status = 404 + ctx.body = { error: 'Agent not found' } + return + } + + storage.removeRoomMembersForAgent(roomId, agent) + storage.removeRoomAgent(roomId, requestedAgentId) + chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId) + ctx.body = { + success: true, + agents: storage.getRoomAgents(roomId), + members: storage.getRoomMembers(roomId), + } +}) + +// Delete room +groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + // Disconnect all agents in room + chatServer.agentClients.disconnectRoom(roomId) + // Delete all data + chatServer.getStorage().deleteRoom(roomId) + ctx.body = { success: true } +}) + +// Clear current room context while keeping members, agents, and room config. +groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clear-context', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + if (!chatServer.getStorage().getRoom(roomId)) { + ctx.status = 404 + ctx.body = { error: 'Room not found' } + return + } + + chatServer.getStorage().clearRoomContext(roomId) + chatServer.clearRoomRuntimeState(roomId) + ctx.body = { success: true, room: chatServer.getStorage().getRoom(roomId) } +}) + +// Update room compression config +groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + const { triggerTokens, maxHistoryTokens, tailMessageCount } = ctx.request.body as { + triggerTokens?: number + maxHistoryTokens?: number + tailMessageCount?: number + } + + chatServer.getStorage().updateRoomConfig(roomId, { triggerTokens, maxHistoryTokens, tailMessageCount }) + const room = chatServer.getStorage().getRoom(roomId) + ctx.body = { room } +}) + +// Force compress a room's context +groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + if (!chatServer.getStorage().getRoom(roomId)) { + ctx.status = 404 + ctx.body = { error: 'Room not found' } + return + } + + const engine = chatServer.getContextEngine() + if (!engine) { + ctx.status = 503 + ctx.body = { error: 'Context engine not available' } + return + } + + try { + const result = await engine.forceCompress(roomId) + ctx.body = { success: true, summary: result } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) diff --git a/packages/server/src/routes/hermes/jobs.ts b/packages/server/src/routes/hermes/jobs.ts new file mode 100644 index 0000000..b98d607 --- /dev/null +++ b/packages/server/src/routes/hermes/jobs.ts @@ -0,0 +1,13 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/jobs' + +export const jobRoutes = new Router() + +jobRoutes.get('/api/hermes/jobs', ctrl.list) +jobRoutes.get('/api/hermes/jobs/:id', ctrl.get) +jobRoutes.post('/api/hermes/jobs', ctrl.create) +jobRoutes.patch('/api/hermes/jobs/:id', ctrl.update) +jobRoutes.delete('/api/hermes/jobs/:id', ctrl.remove) +jobRoutes.post('/api/hermes/jobs/:id/pause', ctrl.pause) +jobRoutes.post('/api/hermes/jobs/:id/resume', ctrl.resume) +jobRoutes.post('/api/hermes/jobs/:id/run', ctrl.run) diff --git a/packages/server/src/routes/hermes/kanban-events.ts b/packages/server/src/routes/hermes/kanban-events.ts new file mode 100644 index 0000000..3159bb6 --- /dev/null +++ b/packages/server/src/routes/hermes/kanban-events.ts @@ -0,0 +1,109 @@ +import { WebSocketServer } from 'ws' +import type { WebSocket } from 'ws' +import type { Server as HttpServer, IncomingMessage } from 'http' +import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth' +import { userCanAccessProfile } from '../../db/hermes/users-store' +import { logger } from '../../services/logger' +import * as kanbanCli from '../../services/hermes/hermes-kanban' + +interface KanbanEventsRequest extends IncomingMessage { + kanbanBoard?: string + kanbanProfile?: string +} + +function sendJson(ws: WebSocket, payload: Record) { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload)) +} + +function streamLines(onLine: (line: string) => void) { + let buffer = '' + return (chunk: Buffer | string) => { + buffer += chunk.toString() + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) onLine(trimmed) + } + } +} + +export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[]) { + const wss = new WebSocketServer({ noServer: true }) + const servers = Array.isArray(httpServers) ? httpServers : [httpServers] + + servers.forEach((httpServer) => { + httpServer.on('upgrade', async (req: KanbanEventsRequest, socket, head) => { + const url = new URL(req.url || '', `http://${req.headers.host}`) + if (url.pathname !== '/api/hermes/kanban/events') return + + if (await isAuthEnabled()) { + const token = url.searchParams.get('token') || '' + const user = await authenticateUserToken(token) + if (!user) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + const profile = (url.searchParams.get('profile') || '').trim() + if (profile && user.role !== 'super_admin' && !userCanAccessProfile(user.id, profile)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') + socket.destroy() + return + } + req.kanbanProfile = profile || undefined + } + + try { + req.kanbanBoard = kanbanCli.normalizeBoardSlug(url.searchParams.get('board')) + } catch { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n') + socket.destroy() + return + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) + }) + }) + + wss.on('connection', (ws, req: KanbanEventsRequest) => { + const board = req.kanbanBoard || 'default' + const child = kanbanCli.watchEvents({ board, interval: 0.5 }) + let closed = false + + sendJson(ws, { type: 'connected', board }) + + const closeChild = () => { + if (closed) return + closed = true + if (!child.killed) child.kill() + } + + child.stdout?.on('data', streamLines((line) => { + if (line.toLowerCase().startsWith('watching kanban events')) return + sendJson(ws, { type: 'event', board }) + })) + + child.stderr?.on('data', streamLines((line) => { + sendJson(ws, { type: 'error', board, message: line }) + })) + + child.on('error', (err) => { + logger.error(err, 'Hermes CLI: kanban watch failed') + sendJson(ws, { type: 'error', board, message: err.message }) + if (ws.readyState === ws.OPEN) ws.close() + }) + + child.on('exit', (code, signal) => { + sendJson(ws, { type: 'stopped', board, code, signal }) + if (ws.readyState === ws.OPEN) ws.close() + }) + + ws.on('close', closeChild) + ws.on('error', closeChild) + }) + + logger.info('WebSocket ready at /api/hermes/kanban/events (kanban watch bridge)') +} diff --git a/packages/server/src/routes/hermes/kanban.ts b/packages/server/src/routes/hermes/kanban.ts new file mode 100644 index 0000000..254b117 --- /dev/null +++ b/packages/server/src/routes/hermes/kanban.ts @@ -0,0 +1,30 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/kanban' + +export const kanbanRoutes = new Router() + +kanbanRoutes.get('/api/hermes/kanban/boards', ctrl.listBoards) +kanbanRoutes.post('/api/hermes/kanban/boards', ctrl.createBoard) +kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard) +kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities) +kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats) +kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees) +kanbanRoutes.get('/api/hermes/kanban/diagnostics', ctrl.diagnostics) +kanbanRoutes.post('/api/hermes/kanban/dispatch', ctrl.dispatch) +kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact) +kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions) +kanbanRoutes.post('/api/hermes/kanban/links', ctrl.linkTasks) +kanbanRoutes.delete('/api/hermes/kanban/links', ctrl.unlinkTasks) +kanbanRoutes.post('/api/hermes/kanban/tasks/bulk', ctrl.bulkUpdateTasks) +kanbanRoutes.get('/api/hermes/kanban', ctrl.list) +kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get) +kanbanRoutes.post('/api/hermes/kanban', ctrl.create) +kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete) +kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock) +kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block) +kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign) +kanbanRoutes.post('/api/hermes/kanban/:id/comments', ctrl.addComment) +kanbanRoutes.get('/api/hermes/kanban/:id/log', ctrl.taskLog) +kanbanRoutes.post('/api/hermes/kanban/:id/reclaim', ctrl.reclaim) +kanbanRoutes.post('/api/hermes/kanban/:id/reassign', ctrl.reassign) +kanbanRoutes.post('/api/hermes/kanban/:id/specify', ctrl.specify) diff --git a/packages/server/src/routes/hermes/logs.ts b/packages/server/src/routes/hermes/logs.ts new file mode 100644 index 0000000..10d535c --- /dev/null +++ b/packages/server/src/routes/hermes/logs.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/logs' + +export const logRoutes = new Router() + +logRoutes.get('/api/hermes/logs', ctrl.list) +logRoutes.get('/api/hermes/logs/:name', ctrl.read) diff --git a/packages/server/src/routes/hermes/mcp.ts b/packages/server/src/routes/hermes/mcp.ts new file mode 100644 index 0000000..8f46dfd --- /dev/null +++ b/packages/server/src/routes/hermes/mcp.ts @@ -0,0 +1,12 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/mcp' + +export const mcpRoutes = new Router() + +mcpRoutes.get('/api/hermes/mcp/servers', ctrl.listServers) +mcpRoutes.post('/api/hermes/mcp/servers', ctrl.addServer) +mcpRoutes.patch('/api/hermes/mcp/servers/:name', ctrl.updateServer) +mcpRoutes.delete('/api/hermes/mcp/servers/:name', ctrl.removeServer) +mcpRoutes.post('/api/hermes/mcp/servers/:name/test', ctrl.testServer) +mcpRoutes.get('/api/hermes/mcp/tools', ctrl.listTools) +mcpRoutes.post('/api/hermes/mcp/reload', ctrl.reloadMcp) diff --git a/packages/server/src/routes/hermes/media.ts b/packages/server/src/routes/hermes/media.ts new file mode 100644 index 0000000..f217e59 --- /dev/null +++ b/packages/server/src/routes/hermes/media.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/media' + +export const mediaRoutes = new Router() + +mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo) +mediaRoutes.post('/api/hermes/media/apikey-image-generate', ctrl.apiKeyImageGenerate) diff --git a/packages/server/src/routes/hermes/memory.ts b/packages/server/src/routes/hermes/memory.ts new file mode 100644 index 0000000..abaa86c --- /dev/null +++ b/packages/server/src/routes/hermes/memory.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/memory' + +export const memoryRoutes = new Router() + +memoryRoutes.get('/api/hermes/memory', ctrl.get) +memoryRoutes.post('/api/hermes/memory', ctrl.save) diff --git a/packages/server/src/routes/hermes/models.ts b/packages/server/src/routes/hermes/models.ts new file mode 100644 index 0000000..584f0c3 --- /dev/null +++ b/packages/server/src/routes/hermes/models.ts @@ -0,0 +1,19 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/models' + +export const modelRoutes = new Router() + +modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable) +modelRoutes.post('/api/hermes/provider-models', ctrl.fetchProviderModelList) +modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels) +modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel) +modelRoutes.put('/api/hermes/model-alias', ctrl.setModelAlias) +modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility) +modelRoutes.put('/api/hermes/custom-model', ctrl.addCustomModel) +modelRoutes.delete('/api/hermes/custom-model', ctrl.removeCustomModel) + +// Model context routes +modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext) +modelRoutes.get('/api/hermes/model-context/:provider/:model', ctrl.getModelContext) +modelRoutes.put('/api/hermes/model-context/:provider/:model', ctrl.updateModelContext) +modelRoutes.put('/api/hermes/model-context', ctrl.updateModelContext) diff --git a/packages/server/src/routes/hermes/nous-auth.ts b/packages/server/src/routes/hermes/nous-auth.ts new file mode 100644 index 0000000..68eaae4 --- /dev/null +++ b/packages/server/src/routes/hermes/nous-auth.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/nous-auth' + +export const nousAuthRoutes = new Router() + +nousAuthRoutes.post('/api/hermes/auth/nous/start', ctrl.start) +nousAuthRoutes.get('/api/hermes/auth/nous/poll/:sessionId', ctrl.poll) +nousAuthRoutes.get('/api/hermes/auth/nous/status', ctrl.status) diff --git a/packages/server/src/routes/hermes/performance-monitor.ts b/packages/server/src/routes/hermes/performance-monitor.ts new file mode 100644 index 0000000..e60284c --- /dev/null +++ b/packages/server/src/routes/hermes/performance-monitor.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/performance-monitor' +import { requireSuperAdmin } from '../../middleware/user-auth' + +export const performanceMonitorRoutes = new Router() + +performanceMonitorRoutes.get('/api/hermes/performance/runtime', requireSuperAdmin, ctrl.runtime) diff --git a/packages/server/src/routes/hermes/plugins.ts b/packages/server/src/routes/hermes/plugins.ts new file mode 100644 index 0000000..7381caa --- /dev/null +++ b/packages/server/src/routes/hermes/plugins.ts @@ -0,0 +1,6 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/plugins' + +export const pluginRoutes = new Router() + +pluginRoutes.get('/api/hermes/plugins', ctrl.list) diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts new file mode 100644 index 0000000..163bed7 --- /dev/null +++ b/packages/server/src/routes/hermes/profiles.ts @@ -0,0 +1,20 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/profiles' +import { requireSuperAdmin } from '../../middleware/user-auth' + +export const profileRoutes = new Router() + +profileRoutes.get('/api/hermes/profiles', ctrl.list) +profileRoutes.post('/api/hermes/profiles', ctrl.create) +profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses) +profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus) +profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime) +profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile) +profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar) +profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar) +profileRoutes.get('/api/hermes/profiles/:name', ctrl.get) +profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove) +profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename) +profileRoutes.put('/api/hermes/profiles/active', requireSuperAdmin, ctrl.switchProfile) +profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile) +profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile) diff --git a/packages/server/src/routes/hermes/providers.ts b/packages/server/src/routes/hermes/providers.ts new file mode 100644 index 0000000..be722ab --- /dev/null +++ b/packages/server/src/routes/hermes/providers.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/providers' + +export const providerRoutes = new Router() + +providerRoutes.post('/api/hermes/config/providers', ctrl.create) +providerRoutes.put('/api/hermes/config/providers/:poolKey', ctrl.update) +providerRoutes.delete('/api/hermes/config/providers/:poolKey', ctrl.remove) diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts new file mode 100644 index 0000000..a7bcf20 --- /dev/null +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -0,0 +1,295 @@ +import type { Context } from 'koa' +import { updateUsage } from '../../db/hermes/usage-store' + +let gatewayManager: any = null + +export function setGatewayManagerForTest(manager: any): void { + gatewayManager = manager +} + +function getGatewayManager() { return gatewayManager } + +// --- run_id → session_id mapping (in-memory, ephemeral) --- + +const runSessionMap = new Map() + +export function setRunSession(runId: string, sessionId: string): void { + runSessionMap.set(runId, sessionId) + // Auto-cleanup after 30 minutes + setTimeout(() => runSessionMap.delete(runId), 30 * 60 * 1000) +} + +export function getSessionForRun(runId: string): string | undefined { + return runSessionMap.get(runId) +} + +// --- Helpers --- + +function isTransientGatewayError(err: any): boolean { + const msg = String(err?.message || '') + const causeCode = String(err?.cause?.code || '') + return ( + causeCode === 'ECONNREFUSED' || + causeCode === 'ECONNRESET' || + /ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg) + ) +} + +async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise { + const deadline = Date.now() + timeoutMs + const healthUrl = `${upstream}/health` + while (Date.now() < deadline) { + try { + const res = await fetch(healthUrl, { + method: 'GET', + signal: AbortSignal.timeout(1200), + }) + if (res.ok) return true + } catch { } + await new Promise(resolve => setTimeout(resolve, 250)) + } + return false +} + +/** Resolve profile name from request */ +function resolveProfile(ctx: Context): string { + // Use header/query from request, but fall back to authoritative source if not provided + const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string) + + if (requestedProfile) { + return requestedProfile + } + + // Fallback: read from authoritative source (active_profile file) + try { + const { getActiveProfileName } = require('../../services/hermes/hermes-profile') + return getActiveProfileName() + } catch { + return 'default' + } +} + +/** Resolve upstream URL for a request based on profile header/query */ +function resolveUpstream(ctx: Context): string { + const mgr = getGatewayManager() + if (!mgr) { + throw new Error('GatewayManager not initialized') + } + const profile = resolveProfile(ctx) + if (profile && profile !== 'default') { + return mgr.getUpstream(profile) + } + return mgr.getUpstream() +} + +function buildProxyHeaders(ctx: Context, upstream: string): Record { + const headers: Record = {} + for (const [key, value] of Object.entries(ctx.headers)) { + if (value == null) continue + const lower = key.toLowerCase() + if (lower === 'host') { + headers['host'] = new URL(upstream).host + } else if (lower === 'origin' || lower === 'referer' || lower === 'connection' || lower === 'authorization') { + continue + } else { + const v = Array.isArray(value) ? value[0] : value + if (v) headers[key] = v + } + } + + const mgr = getGatewayManager() + if (mgr) { + const apiKey = mgr.getApiKey(resolveProfile(ctx)) + if (apiKey) { + headers['authorization'] = `Bearer ${apiKey}` + } + } + + return headers +} + +// --- SSE stream interception --- + +const SSE_EVENTS_PATH = /^\/v1\/runs\/([^/]+)\/events$/ + +/** + * Parse SSE text chunks and extract run.completed events. + * Returns the run_id if a run.completed was found. + */ +function extractRunCompletedFromChunk(chunk: string, profile: string): string | null { + // SSE format: each line is "data: {...}\n\n" + const lines = chunk.split('\n') + for (const line of lines) { + if (!line.startsWith('data: ')) continue + try { + const data = JSON.parse(line.slice(6)) + if (data.event === 'run.completed' && data.usage && data.run_id) { + const sessionId = getSessionForRun(data.run_id) + if (sessionId) { + updateUsage(sessionId, { + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens, + cacheReadTokens: data.usage.cache_read_tokens, + cacheWriteTokens: data.usage.cache_write_tokens, + reasoningTokens: data.usage.reasoning_tokens, + model: data.model || '', + profile, + }) + return data.run_id + } + } + } catch { /* not JSON, skip */ } + } + return null +} + +/** + * Stream an SSE response while intercepting run.completed events. + */ +async function streamSSE(ctx: Context, res: Response, profile: string): Promise { + if (!res.body) { + ctx.res.end() + return + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + // Forward raw bytes to client immediately + ctx.res.write(value) + + // Also decode for interception + buffer += decoder.decode(value, { stream: true }) + + // Process complete SSE lines (delimited by double newline) + let newlineIdx: number + while ((newlineIdx = buffer.indexOf('\n\n')) !== -1) { + const eventBlock = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 2) + extractRunCompletedFromChunk(eventBlock, profile) + } + } + + // Process remaining buffer + if (buffer.trim()) { + extractRunCompletedFromChunk(buffer, profile) + } + } finally { + ctx.res.end() + } +} + +// --- Main proxy function --- + +export async function proxy(ctx: Context) { + const profile = resolveProfile(ctx) + let upstream: string + try { + upstream = resolveUpstream(ctx) + } catch (e: any) { + ctx.status = 503 + ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } } + return + } + const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api') + const params = new URLSearchParams(ctx.search || '') + params.delete('token') + const search = params.toString() + const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}` + + const headers = buildProxyHeaders(ctx, upstream) + + try { + let body: string | undefined + if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') { + // @koa/bodyparser parses JSON into ctx.request.body but doesn't store rawBody + // by default. Re-serialize the parsed body to get the string form. + const parsed = (ctx as any).request.body + if (typeof parsed === 'string') { + body = parsed + } else if (parsed && typeof parsed === 'object') { + body = JSON.stringify(parsed) + } + } + + const requestInit: RequestInit = { method: ctx.req.method, headers, body } + + let res: Response + try { + res = await fetch(url, requestInit) + } catch (err: any) { + if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) { + res = await fetch(url, requestInit) + } else { + throw err + } + } + + // Set response headers + res.headers.forEach((value, key) => { + const lower = key.toLowerCase() + if (lower !== 'transfer-encoding' && lower !== 'connection') { + ctx.set(key, value) + } + }) + ctx.status = res.status + + // Intercept POST /v1/runs to capture run_id → session_id mapping + if (ctx.req.method === 'POST' && /\/v1\/runs$/.test(upstreamPath) && body) { + try { + const parsed = JSON.parse(body) + if (parsed.session_id) { + const resBody = await res.text() + ctx.res.write(resBody) + ctx.res.end() + + try { + const result = JSON.parse(resBody) + if (result.run_id) { + setRunSession(result.run_id, parsed.session_id) + } + } catch { /* response not JSON, ignore */ } + return + } + } catch { /* body not JSON, fall through to normal stream */ } + // No session_id in body — fall through to normal response handling below + } + + // Intercept SSE streams for /v1/runs/{id}/events + const sseMatch = upstreamPath.match(SSE_EVENTS_PATH) + if (sseMatch) { + await streamSSE(ctx, res, profile) + return + } + + // Default: pipe response body directly + if (res.body) { + const reader = res.body.getReader() + const pump = async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + ctx.res.write(value) + } + ctx.res.end() + } + await pump() + } else { + ctx.res.end() + } + } catch (err: any) { + if (!ctx.res.headersSent) { + ctx.status = 502 + ctx.set('Content-Type', 'application/json') + ctx.body = { error: { message: `Proxy error: ${err.message}` } } + } else { + ctx.res.end() + } + } +} diff --git a/packages/server/src/routes/hermes/proxy.ts b/packages/server/src/routes/hermes/proxy.ts new file mode 100644 index 0000000..17f9c9a --- /dev/null +++ b/packages/server/src/routes/hermes/proxy.ts @@ -0,0 +1,17 @@ +import Router from '@koa/router' +import type { Context, Next } from 'koa' +import { proxy } from './proxy-handler' + +export const proxyRoutes = new Router() + +// Proxy unmatched /api/hermes/* and /v1/* to upstream Hermes API +proxyRoutes.all('/api/hermes/{*any}', proxy) +proxyRoutes.all('/v1/{*any}', proxy) + +// Also register as middleware so it works reliably with nested .use() +export async function proxyMiddleware(ctx: Context, next: Next) { + if (ctx.path.startsWith('/api/hermes/') || ctx.path.startsWith('/v1/')) { + return proxy(ctx) + } + await next() +} diff --git a/packages/server/src/routes/hermes/sessions.ts b/packages/server/src/routes/hermes/sessions.ts new file mode 100644 index 0000000..fb7612c --- /dev/null +++ b/packages/server/src/routes/hermes/sessions.ts @@ -0,0 +1,26 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/sessions' + +export const sessionRoutes = new Router() + +sessionRoutes.get('/api/hermes/sessions/conversations', ctrl.listConversations) +sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages', ctrl.getConversationMessages) +sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages/paginated', ctrl.getConversationMessagesPaginated) +sessionRoutes.get('/api/hermes/sessions', ctrl.list) +sessionRoutes.get('/api/hermes/sessions/hermes', ctrl.listHermesSessions) +sessionRoutes.get('/api/hermes/sessions/hermes/:id', ctrl.getHermesSession) +sessionRoutes.post('/api/hermes/sessions/hermes/:id/import', ctrl.importHermesSession) +sessionRoutes.get('/api/hermes/search/sessions', ctrl.search) +sessionRoutes.get('/api/hermes/sessions/search', ctrl.search) +sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch) +sessionRoutes.get('/api/hermes/usage/stats', ctrl.usageStats) +sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength) +sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get) +sessionRoutes.get('/api/hermes/sessions/:id/export', ctrl.exportSession) +sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle) +sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove) +sessionRoutes.post('/api/hermes/sessions/batch-delete', ctrl.batchRemove) +sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename) +sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace) +sessionRoutes.post('/api/hermes/sessions/:id/model', ctrl.setModel) +sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders) diff --git a/packages/server/src/routes/hermes/skills.ts b/packages/server/src/routes/hermes/skills.ts new file mode 100644 index 0000000..b5c42f7 --- /dev/null +++ b/packages/server/src/routes/hermes/skills.ts @@ -0,0 +1,11 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/skills' + +export const skillRoutes = new Router() + +skillRoutes.get('/api/hermes/skills', ctrl.list) +skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats) +skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle) +skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_) +skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles) +skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_) diff --git a/packages/server/src/routes/hermes/terminal.ts b/packages/server/src/routes/hermes/terminal.ts new file mode 100644 index 0000000..04cdd6c --- /dev/null +++ b/packages/server/src/routes/hermes/terminal.ts @@ -0,0 +1,352 @@ +import { WebSocketServer } from 'ws' +import type { Server as HttpServer } from 'http' +import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs' +import { dirname, join, isAbsolute, resolve as resolvePath } from 'path' +import { homedir } from 'os' +import { getActiveProfileDir } from '../../services/hermes/hermes-profile' +import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider' +import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth' +import { logger } from '../../services/logger' + +let pty: any = null + +function ensureNodePtySpawnHelperExecutable() { + if (process.platform !== 'darwin') return + + try { + const nodePtyRoot = dirname(require.resolve('node-pty/package.json')) + const helperCandidates = [ + join(nodePtyRoot, 'build', 'Release', 'spawn-helper'), + join(nodePtyRoot, 'build', 'Debug', 'spawn-helper'), + join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'), + ] + + for (const helperPath of helperCandidates) { + if (!existsSync(helperPath)) continue + try { + accessSync(helperPath, fsConstants.X_OK) + } catch { + chmodSync(helperPath, 0o755) + logger.debug('Restored execute bit for node-pty helper: %s', helperPath) + } + } + } catch (err: any) { + logger.warn(err, 'Could not normalize node-pty helper permissions') + } +} + +try { + ensureNodePtySpawnHelperExecutable() + // eslint-disable-next-line @typescript-eslint/no-require-imports + pty = require('node-pty') +} catch (err: any) { + logger.warn(err, 'node-pty failed to load, terminal feature disabled') +} + +// ─── Shell detection ──────────────────────────────────────────── + +function findShell(): string { + // Windows 平台:使用 PowerShell + if (process.platform === 'win32') { + return 'powershell.exe' + } + + // Unix 平台:使用 SHELL 环境变量,或回退到常用 shells + const candidates = [ + process.env.SHELL, + '/bin/zsh', + '/bin/bash', + ].filter(Boolean) as string[] + + for (const shell of candidates) { + if (existsSync(shell)) return shell + } + return '/bin/bash' +} + +function shellName(shell: string): string { + return shell.split('/').pop() || 'shell' +} + +export function resolveTerminalCwd( + cfg: Pick = getTerminalConfig(), + profileDir = getActiveProfileDir(), +): string { + const configured = cfg.cwd?.trim() + const fallback = existsSync(profileDir) ? profileDir : homedir() + if (!configured) return fallback + + const cwd = isAbsolute(configured) ? configured : resolvePath(profileDir, configured) + if (!existsSync(cwd)) { + logger.warn({ cwd }, 'Configured terminal cwd does not exist; falling back to Hermes profile directory') + return fallback + } + return cwd +} + +// ─── Session types ────────────────────────────────────────────── + +interface PtySession { + id: string + pty: { pid: number; onData: (cb: (data: string) => void) => void; onExit: (cb: (e: { exitCode: number }) => void) => void; write: (data: string) => void; kill: (signal?: string) => void; resize: (cols: number, rows: number) => void } + shell: string + pid: number + createdAt: number +} + +interface Connection { + sessions: Map + activeSessionId: string | null + outputBuffers: Map +} + +// ─── Helpers ──────────────────────────────────────────────────── + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +function createSession(shell: string): PtySession { + const id = generateId() + let ptyProcess: PtySession['pty'] + try { + ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: resolveTerminalCwd(), + }) + } catch (err: any) { + throw new Error(`Failed to spawn shell "${shell}": ${err.message}`) + } + + const session: PtySession = { + id, + pty: ptyProcess, + shell, + pid: ptyProcess.pid, + createdAt: Date.now(), + } + + return session +} + +// ─── WebSocket server setup ───────────────────────────────────── + +export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) { + if (!pty) { + logger.warn('node-pty not available, skipping terminal WebSocket setup') + return + } + + const wss = new WebSocketServer({ noServer: true }) + const defaultShell = findShell() + const servers = Array.isArray(httpServers) ? httpServers : [httpServers] + + servers.forEach((httpServer) => { + httpServer.on('upgrade', async (req, socket, head) => { + const url = new URL(req.url || '', `http://${req.headers.host}`) + if (url.pathname !== '/api/hermes/terminal') { + return + } + + // Auth check + if (await isAuthEnabled()) { + const token = url.searchParams.get('token') || '' + if (!await authenticateUserToken(token)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) + }) + }) + + wss.on('connection', (ws) => { + const conn: Connection = { + sessions: new Map(), + activeSessionId: null, + outputBuffers: new Map(), + } + + // ─── PTY output → WebSocket ────────────────────────────────── + + function attachPtyOutput(session: PtySession) { + session.pty.onData((data: string) => { + if (ws.readyState !== ws.OPEN) return + if (conn.activeSessionId === session.id) { + ws.send(data) + } else { + // Buffer output for inactive sessions + let buf = conn.outputBuffers.get(session.id) + if (!buf) { + buf = [] + conn.outputBuffers.set(session.id, buf) + } + buf.push(data) + // Cap buffer at 1MB to prevent memory issues + if (buf.length > 5000) { + buf.splice(0, buf.length - 5000) + } + } + }) + + session.pty.onExit(({ exitCode }: { exitCode: number }) => { + conn.outputBuffers.delete(session.id) + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode })) + } + conn.sessions.delete(session.id) + logger.info('Session %s exited (pid %d, code %d)', session.id, session.pid, exitCode) + }) + } + + // ─── Message handler ──────────────────────────────────────── + + ws.on('message', (raw) => { + const msg = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw) + + // JSON control message + if (msg.charCodeAt(0) === 0x7B) { + try { + const parsed = JSON.parse(msg) + handleControl(parsed) + } catch { + // Not valid JSON, fall through to raw input + writeRaw(msg) + } + return + } + + writeRaw(msg) + }) + + function writeRaw(data: string) { + const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null + if (session) { + session.pty.write(data) + } + } + + function handleControl(parsed: any) { + switch (parsed.type) { + case 'create': { + const shell = parsed.shell || defaultShell + let session: PtySession + try { + session = createSession(shell) + } catch (err: any) { + ws.send(JSON.stringify({ type: 'error', message: err.message })) + return + } + conn.sessions.set(session.id, session) + conn.activeSessionId = session.id + attachPtyOutput(session) + ws.send(JSON.stringify({ + type: 'created', + id: session.id, + pid: session.pid, + shell: shellName(shell), + })) + logger.info('Session created: %s (%s, pid %d)', session.id, shellName(shell), session.pid) + break + } + + case 'switch': { + const { sessionId } = parsed + const session = conn.sessions.get(sessionId) + if (!session) { + ws.send(JSON.stringify({ type: 'error', message: 'Session not found' })) + return + } + conn.activeSessionId = sessionId + + // Send switched first so frontend mounts the correct terminal + ws.send(JSON.stringify({ type: 'switched', id: sessionId })) + + // Then flush buffered output for this session + const buf = conn.outputBuffers.get(sessionId) + if (buf && buf.length > 0) { + for (const chunk of buf) { + ws.send(chunk) + } + conn.outputBuffers.delete(sessionId) + } + + logger.debug('Switched to session %s', sessionId) + break + } + + case 'close': { + const { sessionId } = parsed + const session = conn.sessions.get(sessionId) + if (!session) return + session.pty.kill() + conn.sessions.delete(sessionId) + conn.outputBuffers.delete(sessionId) + if (conn.activeSessionId === sessionId) { + // Auto-switch to the first remaining session + const remaining = Array.from(conn.sessions.keys()) + conn.activeSessionId = remaining.length > 0 ? remaining[0] : null + } + logger.info('Session closed: %s', sessionId) + break + } + + case 'resize': { + const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null + if (!session) return + const cols = Math.max(1, parsed.cols || 0) + const rows = Math.max(1, parsed.rows || 0) + try { session.pty.resize(cols, rows) } catch { } + break + } + } + } + + // ─── Cleanup ──────────────────────────────────────────────── + + ws.on('close', () => { + for (const session of Array.from(conn.sessions.values())) { + try { session.pty.kill() } catch { } + } + conn.sessions.clear() + logger.info('Connection closed, all sessions killed') + }) + + ws.on('error', () => { + for (const session of Array.from(conn.sessions.values())) { + try { session.pty.kill() } catch { } + } + conn.sessions.clear() + }) + + // ─── Auto-create first session ────────────────────────────── + + let firstSession: PtySession + try { + firstSession = createSession(defaultShell) + } catch (err: any) { + ws.send(JSON.stringify({ type: 'error', message: err.message })) + logger.error(err, 'Failed to create session') + ws.close() + return + } + conn.sessions.set(firstSession.id, firstSession) + conn.activeSessionId = firstSession.id + attachPtyOutput(firstSession) + ws.send(JSON.stringify({ + type: 'created', + id: firstSession.id, + pid: firstSession.pid, + shell: shellName(defaultShell), + })) + logger.info('First session created: %s (%s, pid %d)', firstSession.id, shellName(defaultShell), firstSession.pid) + }) + + logger.info('WebSocket ready at /terminal (shell: %s, transport: node-pty)', defaultShell) +} diff --git a/packages/server/src/routes/hermes/tts.ts b/packages/server/src/routes/hermes/tts.ts new file mode 100644 index 0000000..f1e6c99 --- /dev/null +++ b/packages/server/src/routes/hermes/tts.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/tts' + +export const ttsRoutes = new Router() + +ttsRoutes.post('/api/hermes/tts', ctrl.generate) +ttsRoutes.post('/api/tts/proxy/audio/speech', ctrl.openaiProxy) diff --git a/packages/server/src/routes/hermes/weixin.ts b/packages/server/src/routes/hermes/weixin.ts new file mode 100644 index 0000000..f65ce99 --- /dev/null +++ b/packages/server/src/routes/hermes/weixin.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/weixin' + +export const weixinRoutes = new Router() + +weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode) +weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus) +weixinRoutes.post('/api/hermes/weixin/save', ctrl.save) diff --git a/packages/server/src/routes/hermes/xai-auth.ts b/packages/server/src/routes/hermes/xai-auth.ts new file mode 100644 index 0000000..cc15f6d --- /dev/null +++ b/packages/server/src/routes/hermes/xai-auth.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/xai-auth' + +export const xaiAuthRoutes = new Router() + +xaiAuthRoutes.post('/api/hermes/auth/xai/start', ctrl.start) +xaiAuthRoutes.get('/api/hermes/auth/xai/poll/:sessionId', ctrl.poll) +xaiAuthRoutes.get('/api/hermes/auth/xai/status', ctrl.status) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts new file mode 100644 index 0000000..419e91c --- /dev/null +++ b/packages/server/src/routes/index.ts @@ -0,0 +1,89 @@ +import type { Context, Next } from 'koa' + +// Shared route modules +import { healthRoutes } from './health' +import { webhookRoutes } from './webhook' +import { uploadRoutes } from './upload' +import { updateRoutes } from './update' +import { authPublicRoutes, authProtectedRoutes } from './auth' +import { codingAgentRoutes } from './coding-agents' +import { claudeCodeProxyRoutes } from './claude-code-proxy' +import { codexProxyRoutes } from './codex-proxy' + +// Hermes route modules +import { sessionRoutes } from './hermes/sessions' +import { profileRoutes } from './hermes/profiles' +import { skillRoutes } from './hermes/skills' +import { pluginRoutes } from './hermes/plugins' +import { memoryRoutes } from './hermes/memory' +import { modelRoutes } from './hermes/models' +import { providerRoutes } from './hermes/providers' +import { configRoutes } from './hermes/config' +import { logRoutes } from './hermes/logs' +import { codexAuthRoutes } from './hermes/codex-auth' +import { nousAuthRoutes } from './hermes/nous-auth' +import { copilotAuthRoutes } from './hermes/copilot-auth' +import { xaiAuthRoutes } from './hermes/xai-auth' +import { weixinRoutes } from './hermes/weixin' +import { fileRoutes } from './hermes/files' +import { downloadRoutes } from './hermes/download' +import { jobRoutes } from './hermes/jobs' +import { cronHistoryRoutes } from './hermes/cron-history' +import { kanbanRoutes } from './hermes/kanban' +import { ttsRoutes } from './hermes/tts' +import { mediaRoutes } from './hermes/media' +import { proxyRoutes, proxyMiddleware } from './hermes/proxy' +import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat' +import { performanceMonitorRoutes } from './hermes/performance-monitor' +import { mcpRoutes } from './hermes/mcp' + +/** + * Register all routes on the Koa app. + * Public routes are registered first, then auth middleware, + * then all protected routes. Returns the proxy middleware (must be mounted last). + */ +export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, next: Next) => Promise>) { + // --- Public routes (no auth required) --- + app.use(healthRoutes.routes()) + app.use(webhookRoutes.routes()) + app.use(authPublicRoutes.routes()) + app.use(claudeCodeProxyRoutes.routes()) + app.use(codexProxyRoutes.routes()) + + // --- Auth middleware: all routes below require authentication --- + authMiddleware.forEach((middleware) => app.use(middleware)) + + // --- Protected routes (auth required) --- + app.use(authProtectedRoutes.routes()) + app.use(ttsRoutes.routes()) + app.use(uploadRoutes.routes()) + app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) + app.use(codingAgentRoutes.routes()) + app.use(sessionRoutes.routes()) + app.use(profileRoutes.routes()) + app.use(skillRoutes.routes()) + app.use(pluginRoutes.routes()) + app.use(memoryRoutes.routes()) + app.use(modelRoutes.routes()) + app.use(providerRoutes.routes()) + app.use(configRoutes.routes()) + app.use(logRoutes.routes()) + app.use(codexAuthRoutes.routes()) + app.use(nousAuthRoutes.routes()) + app.use(copilotAuthRoutes.routes()) + app.use(xaiAuthRoutes.routes()) + app.use(weixinRoutes.routes()) + app.use(groupChatRoutes.routes()) // Must be before proxy + app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) + app.use(downloadRoutes.routes()) // Must be before proxy + app.use(jobRoutes.routes()) // Must be before proxy + app.use(cronHistoryRoutes.routes()) // Must be before proxy + app.use(kanbanRoutes.routes()) // Must be before proxy + app.use(mediaRoutes.routes()) // Must be before proxy + app.use(performanceMonitorRoutes.routes()) // Must be before proxy + app.use(mcpRoutes.routes()) // MCP management + app.use(proxyRoutes.routes()) + + // Proxy catch-all middleware (must be last) + return proxyMiddleware +} diff --git a/packages/server/src/routes/update.ts b/packages/server/src/routes/update.ts new file mode 100644 index 0000000..da9e1ca --- /dev/null +++ b/packages/server/src/routes/update.ts @@ -0,0 +1,13 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/update' +import { requireSuperAdmin } from '../middleware/user-auth' + +export const updateRoutes = new Router() + +updateRoutes.post('/api/hermes/update', ctrl.handleUpdate) +updateRoutes.get('/api/hermes/update/preview', requireSuperAdmin, ctrl.previewStatus) +updateRoutes.get('/api/hermes/update/preview/tags', requireSuperAdmin, ctrl.previewTags) +updateRoutes.post('/api/hermes/update/preview/prepare', requireSuperAdmin, ctrl.preparePreview) +updateRoutes.post('/api/hermes/update/preview/install', requireSuperAdmin, ctrl.installPreview) +updateRoutes.post('/api/hermes/update/preview/start', requireSuperAdmin, ctrl.startPreview) +updateRoutes.post('/api/hermes/update/preview/stop', requireSuperAdmin, ctrl.stopPreview) diff --git a/packages/server/src/routes/upload.ts b/packages/server/src/routes/upload.ts new file mode 100644 index 0000000..0407e82 --- /dev/null +++ b/packages/server/src/routes/upload.ts @@ -0,0 +1,6 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/upload' + +export const uploadRoutes = new Router() + +uploadRoutes.post('/upload', ctrl.handleUpload) diff --git a/packages/server/src/routes/webhook.ts b/packages/server/src/routes/webhook.ts new file mode 100644 index 0000000..f282bde --- /dev/null +++ b/packages/server/src/routes/webhook.ts @@ -0,0 +1,6 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/webhook' + +export const webhookRoutes = new Router() + +webhookRoutes.post('/webhook', ctrl.handleWebhook) diff --git a/packages/server/src/services/app-config.ts b/packages/server/src/services/app-config.ts new file mode 100644 index 0000000..4d3fdb3 --- /dev/null +++ b/packages/server/src/services/app-config.ts @@ -0,0 +1,62 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { config } from '../config' + +const APP_HOME = config.appHome +const APP_CONFIG_FILE = join(APP_HOME, 'config.json') + +export interface ModelVisibilityRule { + mode: 'all' | 'include' + models: string[] +} + +export interface AppConfig { + // Whether GitHub Copilot has been explicitly added by the user in web-ui. + // Default false: even when COPILOT_GITHUB_TOKEN / gh-cli / apps.json can + // resolve a token, the Copilot provider is hidden until the user opts in + // via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui + // owns the provider list, system credentials are merely a fallback source. + copilotEnabled?: boolean + + // Web UI-only model display aliases. Keys are provider -> canonical model ID -> display label. + // These aliases never replace the canonical model ID sent back to Hermes. + modelAliases?: Record> + + // Web UI-only manually entered model IDs. Keys are provider -> model IDs. + // This lets users persist provider-supported models that are absent from a + // provider catalog response without changing Hermes Agent config.yaml. + customModels?: Record + + // Web UI-only model picker visibility. This filters what the WUI exposes in + // its sidebar/model pages and never renames or rewrites Hermes canonical + // provider/model IDs. Hermes CLI config remains the upstream source of truth. + modelVisibility?: Record +} + +let cache: AppConfig | null = null + +export async function readAppConfig(): Promise { + if (cache) return cache + try { + const raw = await readFile(APP_CONFIG_FILE, 'utf-8') + const parsed = JSON.parse(raw) as AppConfig + cache = parsed + return parsed + } catch { + cache = {} + return cache + } +} + +export async function writeAppConfig(patch: Partial): Promise { + const current = await readAppConfig() + const merged: AppConfig = { ...current, ...patch } + await mkdir(APP_HOME, { recursive: true }) + await writeFile(APP_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 }) + cache = merged + return merged +} + +export function __resetAppConfigCacheForTest(): void { + cache = null +} diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts new file mode 100644 index 0000000..b437005 --- /dev/null +++ b/packages/server/src/services/auth.ts @@ -0,0 +1,76 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { randomBytes } from 'crypto' +import { checkToken, recordTokenFailure, extractIp } from './login-limiter' +import { config } from '../config' + +const APP_HOME = config.appHome +const TOKEN_FILE = join(APP_HOME, '.token') + +function generateToken(): string { + return randomBytes(32).toString('hex') +} + +/** + * Get or create the auth token. + */ +export async function getToken(): Promise { + if (process.env.AUTH_TOKEN) { + return process.env.AUTH_TOKEN + } + + try { + const token = await readFile(TOKEN_FILE, 'utf-8') + return token.trim() + } catch { + const token = generateToken() + await mkdir(APP_HOME, { recursive: true }) + // Only set mode on Unix systems (Windows ignores this) + const options: any = {} + if (process.platform !== 'win32') { + options.mode = 0o600 + } + await writeFile(TOKEN_FILE, token + '\n', options) + return token + } +} + +/** + * Koa middleware: check Authorization header or query token. + * No path whitelisting — applied globally after public routes. + */ +export function requireAuth(token: string | null) { + return async (ctx: any, next: () => Promise) => { + const auth = ctx.headers.authorization || '' + const provided = auth.startsWith('Bearer ') + ? auth.slice(7) + : (ctx.query.token as string) || '' + + if (!provided || provided !== token) { + // Skip auth for non-API paths (SPA static files) + const lowerPath = ctx.path.toLowerCase() + if (!lowerPath.startsWith('/api') && !lowerPath.startsWith('/v1') && !lowerPath.startsWith('/upload')) { + await next() + return + } + + // Check rate limiter for token auth failures (separate IP counters from password login) + const ip = extractIp(ctx) + const result = checkToken(ip) + if (!result.allowed) { + ctx.status = result.status + ctx.set('Content-Type', 'application/json') + ctx.body = { error: 'Too many login attempts, please try again later' } + return + } + + recordTokenFailure(ip) + ctx.status = 401 + ctx.set('Content-Type', 'application/json') + ctx.body = { error: 'Unauthorized' } + return + } + + await next() + } +} diff --git a/packages/server/src/services/claude-code-proxy.ts b/packages/server/src/services/claude-code-proxy.ts new file mode 100644 index 0000000..cedc3bc --- /dev/null +++ b/packages/server/src/services/claude-code-proxy.ts @@ -0,0 +1,995 @@ +import { randomBytes } from 'crypto' +import { Readable } from 'stream' +import type { Context } from 'koa' +import { config } from '../config' + +export type ApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server' + +export interface ClaudeCodeProxyTargetInput { + provider: string + model: string + baseUrl: string + apiKey: string + apiMode?: ApiMode +} + +interface ClaudeCodeProxyTarget extends ClaudeCodeProxyTargetInput { + key: string + routeKey: string + token: string + updatedAt: number +} + +const targets = new Map() +const CLAUDE_PROXY_VISIBLE_MODELS = [ + 'claude-haiku-4-5', + 'claude-sonnet-4-6', + 'claude-opus-4-7', +] + +function targetKey(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return `${provider}\0${model}\0${apiMode}\0${baseUrl}` +} + +function routeKeyFor(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return Buffer.from(targetKey(provider, model, apiMode, baseUrl), 'utf-8').toString('base64url') +} + +function localProxyBaseUrl(routeKey: string): string { + return `http://127.0.0.1:${config.port}/api/claude-code-proxy/${routeKey}` +} + +export function registerClaudeCodeProxyTarget(input: ClaudeCodeProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { + const provider = input.provider.trim() + const model = input.model.trim() + const baseUrl = input.baseUrl.replace(/\/+$/, '') + const apiMode = input.apiMode || 'chat_completions' + const key = targetKey(provider, model, apiMode, baseUrl) + const existing = targets.get(key) + const routeKey = existing?.routeKey || routeKeyFor(provider, model, apiMode, baseUrl) + const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}` + + targets.set(key, { + ...input, + provider, + model, + baseUrl, + apiMode, + key, + routeKey, + token, + updatedAt: Date.now(), + }) + + return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey } +} + +function findTarget(routeKey: string): ClaudeCodeProxyTarget | null { + for (const target of targets.values()) { + if (target.routeKey === routeKey) return target + } + return null +} + +function authToken(ctx: Context): string { + const apiKey = ctx.get('x-api-key').trim() + if (apiKey) return apiKey + const auth = ctx.get('authorization').trim() + const match = auth.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || '' +} + +function requireTarget(ctx: Context): ClaudeCodeProxyTarget | null { + const target = findTarget(String(ctx.params.key || '')) + if (!target) { + ctx.status = 404 + ctx.body = { type: 'error', error: { type: 'not_found_error', message: 'Claude proxy target not found' } } + return null + } + if (authToken(ctx) !== target.token) { + ctx.status = 401 + ctx.body = { type: 'error', error: { type: 'authentication_error', message: 'Invalid Claude proxy token' } } + return null + } + return target +} + +function stringifyContent(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === 'string') return item + if (item && typeof item === 'object' && 'text' in item) return String((item as any).text || '') + return JSON.stringify(item) + }).filter(Boolean).join('\n') + } + if (value == null) return '' + return JSON.stringify(value) +} + +function shouldPreserveReasoningContent(target: ClaudeCodeProxyTarget): boolean { + const identifier = `${target.provider} ${target.model} ${target.baseUrl}`.toLowerCase() + return [ + 'deepseek', + 'moonshot', + 'kimi', + 'mimo', + 'xiaomimimo', + ].some(part => identifier.includes(part)) +} + +function anthropicContentToOpenAiMessages(message: any, preserveReasoningContent = false): any[] { + const content = message?.content + if (!Array.isArray(content)) { + return [{ role: message.role, content: stringifyContent(content) }] + } + + if (message.role === 'assistant') { + const textParts: string[] = [] + const reasoningParts: string[] = [] + const toolCalls: any[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking)) + if (block?.type === 'redacted_thinking' && preserveReasoningContent) reasoningParts.push('[redacted thinking]') + if (block?.type === 'tool_use') { + toolCalls.push({ + id: String(block.id || `tool_${toolCalls.length}`), + type: 'function', + function: { + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }, + }) + } + } + const openAiMessage: any = { + role: 'assistant', + content: textParts.join('\n') || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + } + if (preserveReasoningContent && (reasoningParts.length || toolCalls.length)) { + openAiMessage.reasoning_content = reasoningParts.join('\n') || 'tool call' + } + return [openAiMessage] + } + + const messages: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_result') { + if (textParts.length) { + messages.push({ role: 'user', content: textParts.splice(0).join('\n') }) + } + messages.push({ + role: 'tool', + tool_call_id: String(block.tool_use_id || ''), + content: stringifyContent(block.content), + }) + } + } + if (textParts.length) messages.push({ role: message.role || 'user', content: textParts.join('\n') }) + return messages.length ? messages : [{ role: message.role || 'user', content: '' }] +} + +function anthropicToOpenAiChat(body: any, target: ClaudeCodeProxyTarget, stream = false): any { + const messages: any[] = [] + const preserveReasoningContent = shouldPreserveReasoningContent(target) + const system = body?.system + if (system) messages.push({ role: 'system', content: stringifyContent(system) }) + for (const message of Array.isArray(body?.messages) ? body.messages : []) { + messages.push(...anthropicContentToOpenAiMessages(message, preserveReasoningContent)) + } + + const tools = Array.isArray(body?.tools) + ? body.tools.map((tool: any) => ({ + type: 'function', + function: { + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.input_schema || { type: 'object', properties: {} }, + }, + })).filter((tool: any) => tool.function.name) + : undefined + + return { + model: target.model, + messages, + ...(typeof body?.max_tokens === 'number' ? { max_tokens: body.max_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function anthropicToOpenAiResponsesInput(message: any): any[] { + const content = Array.isArray(message?.content) ? message.content : [{ type: 'text', text: stringifyContent(message?.content) }] + + if (message.role === 'assistant') { + const items: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_use') { + if (textParts.length) { + items.push({ role: 'assistant', content: textParts.splice(0).join('\n') }) + } + items.push({ + type: 'function_call', + call_id: String(block.id || `tool_${items.length}`), + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }) + } + } + if (textParts.length) items.push({ role: 'assistant', content: textParts.join('\n') }) + return items + } + + const items: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_result') { + if (textParts.length) { + items.push({ role: 'user', content: textParts.splice(0).join('\n') }) + } + items.push({ + type: 'function_call_output', + call_id: String(block.tool_use_id || ''), + output: stringifyContent(block.content), + }) + } + } + if (textParts.length) items.push({ role: message.role || 'user', content: textParts.join('\n') }) + return items.length ? items : [{ role: message.role || 'user', content: '' }] +} + +function anthropicToOpenAiResponses(body: any, target: ClaudeCodeProxyTarget, stream = false): any { + const input: any[] = [] + for (const message of Array.isArray(body?.messages) ? body.messages : []) { + input.push(...anthropicToOpenAiResponsesInput(message)) + } + + const tools = Array.isArray(body?.tools) + ? body.tools.map((tool: any) => ({ + type: 'function', + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.input_schema || { type: 'object', properties: {} }, + })).filter((tool: any) => tool.name) + : undefined + + return { + model: target.model, + input, + ...(body?.system ? { instructions: stringifyContent(body.system) } : {}), + ...(typeof body?.max_tokens === 'number' ? { max_output_tokens: body.max_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(tools?.length ? { tools } : {}), + stream, + store: false, + } +} + +function safeJsonParse(value: string): any { + try { + return JSON.parse(value) + } catch { + return {} + } +} + +function mapStopReason(reason: string | null | undefined, hasTools: boolean): string { + if (hasTools) return 'tool_use' + if (reason === 'length') return 'max_tokens' + if (reason === 'content_filter') return 'stop_sequence' + return 'end_turn' +} + +function openAiToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any { + const choice = data?.choices?.[0] || {} + const message = choice.message || {} + const content: any[] = [] + if (shouldPreserveReasoningContent(target) && message.reasoning_content) { + content.push({ type: 'thinking', thinking: String(message.reasoning_content) }) + } + if (message.content) content.push({ type: 'text', text: String(message.content) }) + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + content.push({ + type: 'tool_use', + id: String(call.id || `toolu_${content.length}`), + name: String(call.function?.name || 'tool'), + input: safeJsonParse(String(call.function?.arguments || '{}')), + }) + } + + const hasTools = content.some(block => block.type === 'tool_use') + return { + id: String(data?.id || `msg_${Date.now()}`), + type: 'message', + role: 'assistant', + model: target.model, + content, + stop_reason: mapStopReason(choice.finish_reason, hasTools), + stop_sequence: null, + usage: { + input_tokens: Number(data?.usage?.prompt_tokens || 0), + output_tokens: Number(data?.usage?.completion_tokens || 0), + }, + } +} + +function sseEvent(event: string, data: any): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +} + +function anthropicMessageToSse(message: any): string { + let output = '' + output += sseEvent('message_start', { + type: 'message_start', + message: { ...message, content: [], stop_reason: null, usage: { input_tokens: message.usage.input_tokens, output_tokens: 0 } }, + }) + + message.content.forEach((block: any, index: number) => { + if (block.type === 'text') { + output += sseEvent('content_block_start', { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }) + if (block.text) output += sseEvent('content_block_delta', { type: 'content_block_delta', index, delta: { type: 'text_delta', text: block.text } }) + output += sseEvent('content_block_stop', { type: 'content_block_stop', index }) + } else if (block.type === 'tool_use') { + output += sseEvent('content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + output += sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input || {}) }, + }) + output += sseEvent('content_block_stop', { type: 'content_block_stop', index }) + } + }) + + output += sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: message.stop_reason, stop_sequence: null }, + usage: { output_tokens: message.usage.output_tokens }, + }) + output += sseEvent('message_stop', { type: 'message_stop' }) + return output +} + +function anthropicMessagesUrl(target: ClaudeCodeProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages` + return `${target.baseUrl}/v1/messages` +} + +async function readProviderJson(res: Response): Promise { + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { error: { message: text || `Provider returned HTTP ${res.status}` } } + } +} + +function throwProviderError(res: Response, data: any): never { + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err +} + +function anthropicRequestBody(body: any, target: ClaudeCodeProxyTarget): any { + return { + ...body, + model: target.model, + } +} + +async function callAnthropicMessages(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicRequestBody(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callOpenAiChat(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(`${target.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiChat(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callOpenAiResponses(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'codex_responses') { + const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(`${target.baseUrl}/responses`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiResponses(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +function responseOutputText(item: any): string { + if (item?.type === 'output_text') return String(item.text || '') + if (item?.type === 'message' && Array.isArray(item.content)) { + return item.content + .map((part: any) => { + if (part?.type === 'output_text' || part?.type === 'text') return String(part.text || '') + return '' + }) + .filter(Boolean) + .join('') + } + return '' +} + +function openAiResponsesToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any { + const content: any[] = [] + const output = Array.isArray(data?.output) ? data.output : [] + + for (const item of output) { + const text = responseOutputText(item) + if (text) content.push({ type: 'text', text }) + if (item?.type === 'function_call') { + content.push({ + type: 'tool_use', + id: String(item.call_id || item.id || `toolu_${content.length}`), + name: String(item.name || 'tool'), + input: safeJsonParse(String(item.arguments || '{}')), + }) + } + } + + if (!content.length && data?.output_text) { + content.push({ type: 'text', text: String(data.output_text) }) + } + + const hasTools = content.some(block => block.type === 'tool_use') + return { + id: String(data?.id || `msg_${Date.now()}`), + type: 'message', + role: 'assistant', + model: target.model, + content, + stop_reason: hasTools ? 'tool_use' : (data?.status === 'incomplete' ? 'max_tokens' : 'end_turn'), + stop_sequence: null, + usage: { + input_tokens: Number(data?.usage?.input_tokens || 0), + output_tokens: Number(data?.usage?.output_tokens || 0), + }, + } +} + +function getReadableStream(res: Response): AsyncIterable { + const body = res.body + if (!body) throw new Error('Provider returned an empty stream') + return body as any +} + +function parseOpenAiSse(buffer: string): { events: string[]; rest: string } { + const events: string[] = [] + let cursor = 0 + while (true) { + const index = buffer.indexOf('\n\n', cursor) + if (index < 0) break + events.push(buffer.slice(cursor, index)) + cursor = index + 2 + } + return { events, rest: buffer.slice(cursor) } +} + +function extractSseData(event: string): string[] { + return event + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) +} + +function openAiFinishToAnthropic(finishReason: string | null | undefined, sawTool: boolean): string { + return mapStopReason(finishReason, sawTool) +} + +async function openAiChatToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(`${target.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiChat(body, target, true)), + }) + if (!res.ok) { + let data: any + const text = await res.text() + try { + data = JSON.parse(text) + } catch { + data = { error: { message: text || `Provider returned HTTP ${res.status}` } } + } + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + const messageId = `msg_${Date.now()}` + let buffer = '' + let thinkingBlockIndex: number | null = null + let thinkingBlockStopped = false + let textBlockStarted = false + let textBlockStopped = false + let textBlockIndex: number | null = null + let nextIndex = 0 + let stopReason: string | null = null + let outputTokens = 0 + const toolBlocks = new Map() + + yield sseEvent('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: target.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + + const ensureThinkingBlock = function* () { + if (thinkingBlockIndex == null) { + thinkingBlockIndex = nextIndex++ + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: thinkingBlockIndex, + content_block: { type: 'thinking', thinking: '' }, + }) + } + return thinkingBlockIndex + } + + const stopThinkingBlock = function* () { + if (thinkingBlockIndex != null && !thinkingBlockStopped) { + thinkingBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingBlockIndex }) + } + } + + const ensureTextBlock = function* () { + if (!textBlockStarted) { + textBlockStarted = true + textBlockIndex = nextIndex + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: textBlockIndex, + content_block: { type: 'text', text: '' }, + }) + nextIndex += 1 + } + return textBlockIndex ?? 0 + } + + const ensureToolBlock = function* (toolIndex: number, id?: string, name?: string) { + let block = toolBlocks.get(toolIndex) + if (!block) { + block = { + blockIndex: nextIndex++, + id: id || `toolu_${toolIndex}`, + name: name || 'tool', + started: false, + } + toolBlocks.set(toolIndex, block) + } else { + if (id) block.id = id + if (name) block.name = name + } + if (!block.started && block.name) { + block.started = true + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: block.blockIndex, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const choice = data?.choices?.[0] + if (!choice) continue + + const delta = choice.delta || {} + if (shouldPreserveReasoningContent(target) && typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + const index = yield* ensureThinkingBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: delta.reasoning_content }, + }) + } + + if (typeof delta.content === 'string' && delta.content) { + yield* stopThinkingBlock() + const index = yield* ensureTextBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: delta.content }, + }) + } + + for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { + yield* stopThinkingBlock() + if (textBlockStarted && !textBlockStopped) { + textBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 }) + } + const toolIndex = Number(toolCall.index || 0) + const block = yield* ensureToolBlock( + toolIndex, + toolCall.id ? String(toolCall.id) : undefined, + toolCall.function?.name ? String(toolCall.function.name) : undefined, + ) + const argsDelta = toolCall.function?.arguments + if (typeof argsDelta === 'string' && argsDelta) { + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: argsDelta }, + }) + } + } + + if (choice.finish_reason) stopReason = String(choice.finish_reason) + if (data?.usage?.completion_tokens) outputTokens = Number(data.usage.completion_tokens) + } + } + } + + yield* stopThinkingBlock() + if (textBlockStarted && !textBlockStopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 }) + } + for (const block of toolBlocks.values()) { + if (block.started) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + yield sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }) + yield sseEvent('message_stop', { type: 'message_stop' }) + } + + return Readable.from(generate()) +} + +async function anthropicMessagesSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicRequestBody(body, target)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + return Readable.from(getReadableStream(res)) +} + +async function openAiResponsesToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'codex_responses') { + const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(`${target.baseUrl}/responses`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiResponses(body, target, true)), + }) + if (!res.ok) { + let data: any + const text = await res.text() + try { + data = JSON.parse(text) + } catch { + data = { error: { message: text || `Provider returned HTTP ${res.status}` } } + } + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + let messageId = `msg_${Date.now()}` + let buffer = '' + let textBlockIndex: number | null = null + let textBlockStopped = false + let nextIndex = 0 + let stopReason: string | null = null + let outputTokens = 0 + const toolBlocks = new Map() + + yield sseEvent('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: target.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + + const ensureTextBlock = function* () { + if (textBlockIndex == null) { + textBlockIndex = nextIndex++ + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: textBlockIndex, + content_block: { type: 'text', text: '' }, + }) + } + return textBlockIndex + } + + const ensureToolBlock = function* (key: string, id?: string, name?: string) { + let block = toolBlocks.get(key) + if (!block) { + block = { + blockIndex: nextIndex++, + id: id || key || `toolu_${toolBlocks.size}`, + name: name || 'tool', + argsDeltaSeen: false, + stopped: false, + } + toolBlocks.set(key, block) + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: block.blockIndex, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + } else { + if (id) block.id = id + if (name && block.name === 'tool') block.name = name + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const eventType = data?.type + + if (eventType === 'response.created') { + messageId = String(data?.response?.id || messageId) + } + + if (eventType === 'response.output_text.delta') { + const deltaText = String(data?.delta || data?.text || '') + if (deltaText) { + const index = yield* ensureTextBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: deltaText }, + }) + } + } + + if (eventType === 'response.output_text.done' && textBlockIndex != null && !textBlockStopped) { + textBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex }) + } + + if (eventType === 'response.output_item.added') { + const item = data?.item || data?.output_item + if (item?.type === 'function_call') { + const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) + yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) + } + } + + if (eventType === 'response.function_call_arguments.delta') { + const key = String(data.call_id || data.item_id || data.output_index || toolBlocks.size) + const block = yield* ensureToolBlock(key) + const argsDelta = String(data.delta || '') + if (argsDelta) { + block.argsDeltaSeen = true + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: argsDelta }, + }) + } + } + + if (eventType === 'response.output_item.done') { + const item = data?.item || data?.output_item + if (item?.type === 'function_call') { + const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) + const block = yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) + const args = String(item.arguments || '') + if (args && !block.argsDeltaSeen) { + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: args }, + }) + } + if (!block.stopped) { + block.stopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + } + + if (eventType === 'response.completed') { + const response = data?.response || data + outputTokens = Number(response?.usage?.output_tokens || outputTokens) + stopReason = response?.status === 'incomplete' ? 'length' : 'stop' + } + } + } + } + + if (textBlockIndex != null && !textBlockStopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex }) + } + for (const block of toolBlocks.values()) { + if (!block.stopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + yield sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }) + yield sseEvent('message_stop', { type: 'message_stop' }) + } + + return Readable.from(generate()) +} + +export async function claudeProxyModels(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + const ids = [...new Set([...CLAUDE_PROXY_VISIBLE_MODELS, target.model])] + ctx.body = { + data: ids.map(id => ({ + type: 'model', + id, + display_name: id, + created_at: '2026-01-01T00:00:00Z', + })), + has_more: false, + first_id: ids[0], + last_id: ids[ids.length - 1], + } +} + +export async function claudeProxyMessages(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + try { + const requestBody = ctx.request.body || {} + if ((requestBody as any).stream === true) { + const stream = target.apiMode === 'anthropic_messages' + ? await anthropicMessagesSseStream(target, requestBody) + : target.apiMode === 'codex_responses' + ? await openAiResponsesToAnthropicSseStream(target, requestBody) + : await openAiChatToAnthropicSseStream(target, requestBody) + ctx.set('Content-Type', 'text/event-stream; charset=utf-8') + ctx.set('Cache-Control', 'no-cache') + ctx.body = stream + } else { + const message = target.apiMode === 'anthropic_messages' + ? await callAnthropicMessages(target, requestBody) + : target.apiMode === 'codex_responses' + ? openAiResponsesToAnthropicMessage(await callOpenAiResponses(target, requestBody), target) + : openAiToAnthropicMessage(await callOpenAiChat(target, requestBody), target) + ctx.body = message + } + } catch (err: any) { + ctx.status = err.status || 502 + ctx.body = { + type: 'error', + error: { + type: 'api_error', + message: err?.message || 'Claude proxy request failed', + provider_error: err?.providerError, + }, + } + } +} diff --git a/packages/server/src/services/codex-proxy.ts b/packages/server/src/services/codex-proxy.ts new file mode 100644 index 0000000..bb5cb9e --- /dev/null +++ b/packages/server/src/services/codex-proxy.ts @@ -0,0 +1,908 @@ +import { randomBytes } from 'crypto' +import { Readable } from 'stream' +import type { Context } from 'koa' +import { config } from '../config' +import type { ApiMode } from './claude-code-proxy' + +export interface CodexProxyTargetInput { + profile: string + provider: string + model: string + baseUrl: string + apiKey: string + apiMode?: ApiMode +} + +interface CodexProxyTarget extends CodexProxyTargetInput { + key: string + routeKey: string + token: string + updatedAt: number +} + +const targets = new Map() + +function targetKey(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return `${profile}\0${provider}\0${model}\0${apiMode}\0${baseUrl}` +} + +function routeKeyFor(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return Buffer.from(targetKey(profile, provider, model, apiMode, baseUrl), 'utf-8').toString('base64url') +} + +function localProxyBaseUrl(routeKey: string): string { + return `http://127.0.0.1:${config.port}/api/codex-proxy/${routeKey}/v1` +} + +export function registerCodexProxyTarget(input: CodexProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { + const profile = input.profile.trim() + const provider = input.provider.trim() + const model = input.model.trim() + const baseUrl = input.baseUrl.replace(/\/+$/, '') + const apiMode = input.apiMode || 'chat_completions' + const key = targetKey(profile, provider, model, apiMode, baseUrl) + const existing = targets.get(key) + const routeKey = existing?.routeKey || routeKeyFor(profile, provider, model, apiMode, baseUrl) + const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}` + + targets.set(key, { + ...input, + profile, + provider, + model, + baseUrl, + apiMode, + key, + routeKey, + token, + updatedAt: Date.now(), + }) + + return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey } +} + +function findTarget(routeKey: string): CodexProxyTarget | null { + for (const target of targets.values()) { + if (target.routeKey === routeKey) return target + } + return null +} + +function authToken(ctx: Context): string { + const apiKey = ctx.get('x-api-key').trim() + if (apiKey) return apiKey + const auth = ctx.get('authorization').trim() + const match = auth.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || '' +} + +function requireTarget(ctx: Context): CodexProxyTarget | null { + const target = findTarget(String(ctx.params.key || '')) + if (!target) { + ctx.status = 404 + ctx.body = { error: { type: 'not_found_error', message: 'Codex proxy target not found' } } + return null + } + if (authToken(ctx) !== target.token) { + ctx.status = 401 + ctx.body = { error: { type: 'authentication_error', message: 'Invalid Codex proxy token' } } + return null + } + return target +} + +function stringifyContent(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === 'string') return item + if (item && typeof item === 'object') { + const block = item as any + if (typeof block.text === 'string') return block.text + if (typeof block.output === 'string') return block.output + } + return JSON.stringify(item) + }).filter(Boolean).join('\n') + } + if (value == null) return '' + return JSON.stringify(value) +} + +function responseContentToText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return stringifyContent(content) + return content.map((part: any) => { + if (typeof part === 'string') return part + if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { + return String(part.text || '') + } + return stringifyContent(part) + }).filter(Boolean).join('\n') +} + +function responsesInputToChatMessages(body: any): any[] { + const messages: any[] = [] + if (body?.instructions) { + messages.push({ role: 'system', content: stringifyContent(body.instructions) }) + } + + const input = body?.input + if (typeof input === 'string') { + messages.push({ role: 'user', content: input }) + return messages + } + + for (const item of Array.isArray(input) ? input : []) { + if (!item || typeof item !== 'object') continue + if (item.type === 'function_call') { + const callId = String(item.call_id || item.id || `call_${messages.length}`) + messages.push({ + role: 'assistant', + content: null, + tool_calls: [{ + id: callId, + type: 'function', + function: { + name: String(item.name || 'tool'), + arguments: String(item.arguments || '{}'), + }, + }], + }) + continue + } + if (item.type === 'function_call_output') { + messages.push({ + role: 'tool', + tool_call_id: String(item.call_id || ''), + content: stringifyContent(item.output), + }) + continue + } + if (item.role) { + messages.push({ + role: chatRoleForResponsesRole(item.role), + content: responseContentToText(item.content), + }) + } + } + + return messages.length ? messages : [{ role: 'user', content: '' }] +} + +function chatRoleForResponsesRole(role: unknown): string { + const value = String(role || '').trim() + if (value === 'developer') return 'system' + if (value === 'system' || value === 'user' || value === 'assistant' || value === 'tool') return value + return 'user' +} + +function responsesToolsToChatTools(tools: unknown): any[] | undefined { + if (!Array.isArray(tools)) return undefined + const mapped = tools.map((tool: any) => { + if (tool?.type !== 'function') return null + return { + type: 'function', + function: { + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.parameters || { type: 'object', properties: {} }, + }, + } + }).filter((tool: any) => tool?.function?.name) + return mapped.length ? mapped : undefined +} + +function responsesToOpenAiChat(body: any, target: CodexProxyTarget, stream = false): any { + const tools = responsesToolsToChatTools(body?.tools) + return { + model: target.model, + messages: responsesInputToChatMessages(body), + ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function responsesRoleToAnthropicRole(role: unknown): 'user' | 'assistant' { + return String(role || '') === 'assistant' ? 'assistant' : 'user' +} + +function responsesContentToAnthropicContent(content: unknown, role: 'user' | 'assistant'): any[] { + const parts = Array.isArray(content) ? content : [{ type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyContent(content) }] + const mapped = parts.map((part: any) => { + if (typeof part === 'string') return { type: 'text', text: part } + if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { + return { type: 'text', text: String(part.text || '') } + } + return null + }).filter(Boolean) + return mapped.length ? mapped : [{ type: 'text', text: '' }] +} + +function responsesInputToAnthropicMessages(body: any): any[] { + const messages: any[] = [] + const input = body?.input + if (typeof input === 'string') return [{ role: 'user', content: [{ type: 'text', text: input }] }] + + for (const item of Array.isArray(input) ? input : []) { + if (!item || typeof item !== 'object') continue + if (item.type === 'function_call') { + messages.push({ + role: 'assistant', + content: [{ + type: 'tool_use', + id: String(item.call_id || item.id || `toolu_${messages.length}`), + name: String(item.name || 'tool'), + input: safeJsonParse(String(item.arguments || '{}')), + }], + }) + continue + } + if (item.type === 'function_call_output') { + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: String(item.call_id || ''), + content: stringifyContent(item.output), + }], + }) + continue + } + if (item.role) { + const role = responsesRoleToAnthropicRole(item.role) + messages.push({ + role, + content: responsesContentToAnthropicContent(item.content, role), + }) + } + } + + return messages.length ? messages : [{ role: 'user', content: [{ type: 'text', text: '' }] }] +} + +function responsesToolsToAnthropicTools(tools: unknown): any[] | undefined { + if (!Array.isArray(tools)) return undefined + const mapped = tools.map((tool: any) => { + if (tool?.type !== 'function') return null + return { + name: String(tool.name || ''), + description: String(tool.description || ''), + input_schema: tool.parameters || { type: 'object', properties: {} }, + } + }).filter((tool: any) => tool?.name) + return mapped.length ? mapped : undefined +} + +function responsesToAnthropicMessages(body: any, target: CodexProxyTarget, stream = false): any { + const tools = responsesToolsToAnthropicTools(body?.tools) + return { + model: target.model, + messages: responsesInputToAnthropicMessages(body), + ...(body?.instructions ? { system: stringifyContent(body.instructions) } : {}), + ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : { max_tokens: 4096 }), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function chatCompletionsUrl(target: CodexProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/chat/completions` + return `${target.baseUrl}/v1/chat/completions` +} + +function anthropicMessagesUrl(target: CodexProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages` + return `${target.baseUrl}/v1/messages` +} + +async function readProviderJson(res: Response): Promise { + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { error: { message: text || `Provider returned HTTP ${res.status}` } } + } +} + +function throwProviderError(res: Response, data: any): never { + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err +} + +function responseId(data: any): string { + return String(data?.id || `resp_${Date.now()}`) +} + +function usageFromChat(data: any) { + return { + input_tokens: Number(data?.usage?.prompt_tokens || 0), + output_tokens: Number(data?.usage?.completion_tokens || 0), + total_tokens: Number(data?.usage?.total_tokens || 0), + } +} + +function usageFromAnthropic(data: any) { + const inputTokens = Number(data?.usage?.input_tokens || 0) + const outputTokens = Number(data?.usage?.output_tokens || 0) + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + } +} + +function openAiChatToResponses(data: any, target: CodexProxyTarget): any { + const choice = data?.choices?.[0] || {} + const message = choice.message || {} + const output: any[] = [] + + if (message.content) { + output.push({ + type: 'message', + id: `msg_${responseId(data)}`, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: String(message.content), annotations: [] }], + }) + } + + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + output.push({ + type: 'function_call', + id: String(call.id || `fc_${output.length}`), + call_id: String(call.id || `call_${output.length}`), + name: String(call.function?.name || 'tool'), + arguments: String(call.function?.arguments || '{}'), + }) + } + + return { + id: responseId(data), + object: 'response', + created_at: Number(data?.created || Math.floor(Date.now() / 1000)), + status: 'completed', + model: target.model, + output, + usage: usageFromChat(data), + } +} + +function anthropicMessageToResponses(data: any, target: CodexProxyTarget): any { + const output: any[] = [] + const textParts: string[] = [] + for (const block of Array.isArray(data?.content) ? data.content : []) { + if (block?.type === 'text' && block.text) textParts.push(String(block.text)) + if (block?.type === 'tool_use') { + output.push({ + type: 'function_call', + id: String(block.id || `fc_${output.length}`), + call_id: String(block.id || `call_${output.length}`), + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }) + } + } + if (textParts.length) { + output.unshift({ + type: 'message', + id: `msg_${responseId(data)}`, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: textParts.join('\n'), annotations: [] }], + }) + } + + return { + id: responseId(data), + object: 'response', + created_at: Math.floor(Date.now() / 1000), + status: 'completed', + model: target.model, + output, + usage: usageFromAnthropic(data), + } +} + +async function callOpenAiChat(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(chatCompletionsUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToOpenAiChat(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callAnthropicMessages(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToAnthropicMessages(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +function sseEvent(event: string, data: any): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +} + +function safeJsonParse(value: string): any { + try { + return JSON.parse(value) + } catch { + return {} + } +} + +function getReadableStream(res: Response): AsyncIterable { + const body = res.body + if (!body) throw new Error('Provider returned an empty stream') + return body as any +} + +function parseOpenAiSse(buffer: string): { events: string[]; rest: string } { + const events: string[] = [] + let cursor = 0 + while (true) { + const index = buffer.indexOf('\n\n', cursor) + if (index < 0) break + events.push(buffer.slice(cursor, index)) + cursor = index + 2 + } + return { events, rest: buffer.slice(cursor) } +} + +function extractSseData(event: string): string[] { + return event + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) +} + +async function openAiChatToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(chatCompletionsUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToOpenAiChat(body, target, true)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + const id = `resp_${Date.now()}` + const messageId = `msg_${id}` + let buffer = '' + let textStarted = false + let text = '' + const toolCalls = new Map() + + yield sseEvent('response.created', { + type: 'response.created', + response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, + }) + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const choice = data?.choices?.[0] + if (!choice) continue + + const delta = choice.delta || {} + if (typeof delta.content === 'string' && delta.content) { + if (!textStarted) { + textStarted = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'message', + id: messageId, + status: 'in_progress', + role: 'assistant', + content: [], + }, + }) + yield sseEvent('response.content_part.added', { + type: 'response.content_part.added', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '', annotations: [] }, + }) + } + text += delta.content + yield sseEvent('response.output_text.delta', { + type: 'response.output_text.delta', + item_id: messageId, + output_index: 0, + content_index: 0, + delta: delta.content, + }) + } + + for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { + const index = Number(toolCall.index || 0) + let call = toolCalls.get(index) + if (!call) { + call = { + id: String(toolCall.id || `call_${index}`), + name: String(toolCall.function?.name || 'tool'), + arguments: '', + added: false, + } + toolCalls.set(index, call) + } + if (toolCall.id) call.id = String(toolCall.id) + if (toolCall.function?.name) call.name = String(toolCall.function.name) + if (!call.added && call.name) { + call.added = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: textStarted ? index + 1 : index, + item: { + type: 'function_call', + id: call.id, + call_id: call.id, + name: call.name, + arguments: '', + }, + }) + } + const argsDelta = toolCall.function?.arguments + if (typeof argsDelta === 'string' && argsDelta) { + call.arguments += argsDelta + yield sseEvent('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + item_id: call.id, + output_index: textStarted ? index + 1 : index, + delta: argsDelta, + }) + } + } + } + } + } + + const output: any[] = [] + if (textStarted) { + const messageItem = { + type: 'message', + id: messageId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + } + output.push(messageItem) + yield sseEvent('response.output_text.done', { + type: 'response.output_text.done', + item_id: messageId, + output_index: 0, + content_index: 0, + text, + }) + yield sseEvent('response.content_part.done', { + type: 'response.content_part.done', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text, annotations: [] }, + }) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: 0, + item: messageItem, + }) + } + + for (const [index, call] of toolCalls.entries()) { + const outputIndex = textStarted ? index + 1 : index + const callItem = { + type: 'function_call', + id: call.id, + call_id: call.id, + name: call.name, + arguments: call.arguments || '{}', + } + output.push(callItem) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: callItem, + }) + } + yield sseEvent('response.completed', { + type: 'response.completed', + response: { + id, + object: 'response', + status: 'completed', + model: target.model, + output, + }, + }) + } + + return Readable.from(generate()) +} + +function extractSseEventName(event: string): string { + return event + .split(/\r?\n/) + .find(line => line.startsWith('event:')) + ?.slice(6) + .trim() || '' +} + +async function anthropicMessagesToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToAnthropicMessages(body, target, true)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + let id = `resp_${Date.now()}` + let messageId = `msg_${id}` + let buffer = '' + let textStarted = false + let text = '' + const toolBlocks = new Map() + + yield sseEvent('response.created', { + type: 'response.created', + response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, + }) + + const ensureText = function* () { + if (!textStarted) { + textStarted = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'message', id: messageId, status: 'in_progress', role: 'assistant', content: [] }, + }) + yield sseEvent('response.content_part.added', { + type: 'response.content_part.added', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '', annotations: [] }, + }) + } + } + + const ensureTool = function* (index: number, idValue?: string, name?: string) { + let block = toolBlocks.get(index) + if (!block) { + block = { id: idValue || `toolu_${index}`, name: name || 'tool', arguments: '', added: false } + toolBlocks.set(index, block) + } + if (idValue) block.id = idValue + if (name) block.name = name + if (!block.added) { + block.added = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: textStarted ? index + 1 : index, + item: { type: 'function_call', id: block.id, call_id: block.id, name: block.name, arguments: '' }, + }) + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + const eventName = extractSseEventName(event) + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + + if (eventName === 'message_start' || data?.type === 'message_start') { + id = String(data?.message?.id || id) + messageId = `msg_${id}` + } + + if (eventName === 'content_block_start' || data?.type === 'content_block_start') { + const contentBlock = data?.content_block || {} + if (contentBlock.type === 'tool_use') { + yield* ensureTool(Number(data.index || 0), String(contentBlock.id || ''), String(contentBlock.name || 'tool')) + } + } + + if (eventName === 'content_block_delta' || data?.type === 'content_block_delta') { + const delta = data?.delta || {} + if (delta.type === 'text_delta' && delta.text) { + yield* ensureText() + text += String(delta.text) + yield sseEvent('response.output_text.delta', { + type: 'response.output_text.delta', + item_id: messageId, + output_index: 0, + content_index: 0, + delta: String(delta.text), + }) + } + if (delta.type === 'input_json_delta' && delta.partial_json) { + const index = Number(data.index || 0) + const block = yield* ensureTool(index) + const argsDelta = String(delta.partial_json) + block.arguments += argsDelta + yield sseEvent('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + item_id: block.id, + output_index: textStarted ? index + 1 : index, + delta: argsDelta, + }) + } + } + } + } + } + + const output: any[] = [] + if (textStarted) { + const messageItem = { + type: 'message', + id: messageId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + } + output.push(messageItem) + yield sseEvent('response.output_text.done', { + type: 'response.output_text.done', + item_id: messageId, + output_index: 0, + content_index: 0, + text, + }) + yield sseEvent('response.content_part.done', { + type: 'response.content_part.done', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text, annotations: [] }, + }) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: 0, + item: messageItem, + }) + } + for (const [index, block] of toolBlocks.entries()) { + const outputIndex = textStarted ? index + 1 : index + const item = { + type: 'function_call', + id: block.id, + call_id: block.id, + name: block.name, + arguments: block.arguments || '{}', + } + output.push(item) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item, + }) + } + yield sseEvent('response.completed', { + type: 'response.completed', + response: { id, object: 'response', status: 'completed', model: target.model, output }, + }) + } + + return Readable.from(generate()) +} + +export async function codexProxyResponses(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + try { + const requestBody = ctx.request.body || {} + if ((requestBody as any).stream === true) { + const stream = target.apiMode === 'anthropic_messages' + ? await anthropicMessagesToResponsesSseStream(target, requestBody) + : await openAiChatToResponsesSseStream(target, requestBody) + ctx.set('Content-Type', 'text/event-stream; charset=utf-8') + ctx.set('Cache-Control', 'no-cache') + ctx.body = stream + } else { + ctx.body = target.apiMode === 'anthropic_messages' + ? anthropicMessageToResponses(await callAnthropicMessages(target, requestBody), target) + : openAiChatToResponses(await callOpenAiChat(target, requestBody), target) + } + } catch (err: any) { + ctx.status = err.status || 502 + ctx.body = { + error: { + type: 'api_error', + message: err?.message || 'Codex proxy request failed', + provider_error: err?.providerError, + }, + } + } +} + +export async function codexProxyModels(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + ctx.body = { + object: 'list', + data: [{ + id: target.model, + object: 'model', + created: 0, + owned_by: target.provider, + }], + } +} diff --git a/packages/server/src/services/coding-agents.ts b/packages/server/src/services/coding-agents.ts new file mode 100644 index 0000000..1f18a31 --- /dev/null +++ b/packages/server/src/services/coding-agents.ts @@ -0,0 +1,1100 @@ +import { execFile } from 'child_process' +import { existsSync, readdirSync, realpathSync } from 'fs' +import { mkdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { delimiter, dirname, extname, join } from 'path' +import { promisify } from 'util' +import { getWebUiHome } from '../config' +import { registerClaudeCodeProxyTarget, type ApiMode } from './claude-code-proxy' +import { registerCodexProxyTarget } from './codex-proxy' +import { PROVIDER_PRESETS } from '../shared/providers' +import { getModelContextLength } from './hermes/model-context' + +const execFileAsync = promisify(execFile) +const LAUNCH_API_MODES = new Set(['chat_completions', 'codex_responses', 'anthropic_messages']) +const CODING_AGENT_HOME_DIR = 'coding-agent' +const CODEX_MODEL_CATALOG_FILE = 'codex-model-catalog.json' +const CODEX_CATALOG_BASE_INSTRUCTIONS = 'You are Codex, a coding agent. Be precise, safe, and helpful.' +const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing' + +export type CodingAgentId = 'claude-code' | 'codex' + +export interface CodingAgentDefinition { + id: CodingAgentId + name: string + provider: string + command: string + packageName: string +} + +export interface CodingAgentToolStatus extends CodingAgentDefinition { + installed: boolean + version: string + rawVersion: string + error?: string +} + +export interface CodingAgentsStatus { + tools: CodingAgentToolStatus[] +} + +export interface CodingAgentMutationResult extends CodingAgentsStatus { + success: boolean + tool: CodingAgentToolStatus + message?: string + code?: string +} + +export interface CodingAgentConfigFileDefinition { + key: string + path: string + absolutePath: string + language: string +} + +export interface CodingAgentConfigScope { + profile?: string + provider?: string +} + +export interface CodingAgentConfigFileContent extends CodingAgentConfigFileDefinition { + content: string + exists: boolean + size: number + profile: string + provider: string + rootDir: string +} + +export interface CodingAgentLaunchInput extends CodingAgentConfigScope { + mode?: 'scoped' | 'global' + model?: string + baseUrl?: string + apiKey?: string + apiMode?: ApiMode +} + +export interface CodingAgentLaunchResult { + agentId: CodingAgentId + mode: 'scoped' | 'global' + profile: string + provider: string + model: string + rootDir: string + workspaceDir: string + command: string + args: string[] + env: Record + shellCommand: string + files: Array<{ key: string; path: string; absolutePath: string }> +} + +export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { + nativeTerminal: true + terminal: string +} + +const TOOL_DEFINITIONS: CodingAgentDefinition[] = [ + { + id: 'claude-code', + name: 'Claude Code', + provider: 'Anthropic', + command: 'claude', + packageName: '@anthropic-ai/claude-code', + }, + { + id: 'codex', + name: 'Codex', + provider: 'OpenAI', + command: 'codex', + packageName: '@openai/codex', + }, +] + +const CONFIG_FILE_DEFINITIONS: Record & { scopedPath: string }>> = { + 'claude-code': [ + { key: 'settings', path: '~/.claude/settings.json', scopedPath: 'settings.json', language: 'json' }, + { key: 'mcp', path: '~/.claude.json', scopedPath: 'mcp.json', language: 'json' }, + { key: 'prompt', path: '~/.claude/CLAUDE.md', scopedPath: 'CLAUDE.md', language: 'markdown' }, + ], + codex: [ + { key: 'auth', path: '~/.codex/auth.json', scopedPath: 'auth.json', language: 'json' }, + { key: 'config', path: '~/.codex/config.toml', scopedPath: 'config.toml', language: 'ini' }, + { key: 'agents', path: '~/.codex/AGENTS.md', scopedPath: 'AGENTS.md', language: 'markdown' }, + ], +} + +const installingTools = new Set() +const deletingTools = new Set() +let cachedGlobalNpmBin: string | null | undefined +const MAX_CONFIG_FILE_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024 + +function getNodeBinDir() { + return dirname(process.execPath) +} + +function getNodePrefix() { + return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir()) +} + +function getHomebrewPrefix() { + const match = process.execPath.match(/^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/) + return match?.[1] || null +} + +function getNpmCliCandidates() { + const prefix = getNodePrefix() + const homebrewPrefix = getHomebrewPrefix() + + return process.platform === 'win32' + ? [ + join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ] + : [ + join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ...(homebrewPrefix ? [join(homebrewPrefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')] : []), + ] +} + +function getNpmCliPath() { + return getNpmCliCandidates().find(existsSync) || null +} + +function getNpmBin() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +function compareNodeVersionDesc(left: string, right: string): number { + const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const diff = (rightParts[index] || 0) - (leftParts[index] || 0) + if (diff !== 0) return diff + } + return right.localeCompare(left) +} + +function getNvmNodeBinPaths(): string { + if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return '' + + const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm') + const versionsDir = join(nvmDir, 'versions', 'node') + if (!existsSync(versionsDir)) return '' + + try { + return readdirSync(versionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(compareNodeVersionDesc) + .map(version => join(versionsDir, version, 'bin')) + .filter(binDir => existsSync(binDir)) + .join(delimiter) + } catch { + return '' + } +} + +function nodeEnvironmentMissingError(): Error { + const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.') + ;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE + return err +} + +function isNodeEnvironmentMissingError(err: any): boolean { + const text = [ + err?.code, + err?.message, + typeof err?.stderr === 'string' ? err.stderr : '', + typeof err?.stdout === 'string' ? err.stdout : '', + ].filter(Boolean).join('\n').toLowerCase() + return text.includes('enoent') || + text.includes('spawn npm') || + text.includes('npm: command not found') || + text.includes('npm not found') || + text.includes('node: command not found') || + text.includes('node not found') +} + +function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null { + const binDir = dirname(npmBin) + if (process.platform === 'win32') { + const node = join(binDir, 'node.exe') + const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null + } + + const node = join(binDir, 'node') + const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') + return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null +} + +function normalizeScopeSegment(value: string | undefined, fallback: string, label: string): string { + // Replace invalid filename characters with underscores + // Windows invalid chars: < > : " / \ | ? * + // Additional problematic chars: control characters + const sanitizedValue = String(value || '').trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + const segment = sanitizedValue || fallback + + if ( + segment === '.' || + segment === '..' || + segment.includes('\0') + ) { + const err = new Error(`Invalid ${label}`) + ;(err as any).status = 400 + throw err + } + if (segment.length > 128) { + const err = new Error(`${label} is too long`) + ;(err as any).status = 400 + throw err + } + return segment +} + +function normalizeConfigScope(scope: CodingAgentConfigScope = {}): Required { + return { + profile: normalizeScopeSegment(scope.profile, 'default', 'profile'), + provider: normalizeScopeSegment(scope.provider, 'default', 'provider'), + } +} + +function normalizeLaunchApiMode(value: unknown, fallback: ApiMode): ApiMode { + if (!value) return fallback + const mode = String(value).trim() as ApiMode + if (!LAUNCH_API_MODES.has(mode)) { + const err = new Error('Invalid API protocol') + ;(err as any).status = 400 + throw err + } + return mode +} + +function getScopedConfigRoot(id: CodingAgentId, scope: Required): string { + return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'model', scope.profile, scope.provider, id) +} + +function getScopedWorkspaceRoot(scope: Required): string { + return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'workspace', scope.profile, scope.provider) +} + +function displayNameForModel(model: string): string { + const trimmed = model.trim() + if (!trimmed) return 'Model' + const leaf = trimmed.split('/').filter(Boolean).pop() || trimmed + return leaf + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, char => char.toUpperCase()) +} + +function codexCatalogEntry(input: { + model: string + displayName: string + contextWindow: number + priority: number +}) { + return { + slug: input.model, + display_name: input.displayName, + description: input.displayName, + default_reasoning_level: 'medium', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses with lighter reasoning' }, + { effort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, + { effort: 'high', description: 'Greater reasoning depth for complex problems' }, + { effort: 'xhigh', description: 'Extra high reasoning depth for complex problems' }, + ], + shell_type: 'shell_command', + visibility: 'list', + supported_in_api: true, + priority: 1000 + input.priority, + additional_speed_tiers: [], + service_tiers: [], + default_service_tier: null, + availability_nux: null, + upgrade: null, + base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, + model_messages: { + instructions_template: '{{ base_instructions }}\n\n{{ personality }}', + instructions_variables: { + base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, + personality: '', + personality_default: '', + personality_friendly: '', + personality_pragmatic: '', + }, + }, + supports_reasoning_summaries: true, + default_reasoning_summary: 'none', + support_verbosity: true, + default_verbosity: 'low', + apply_patch_tool_type: 'freeform', + web_search_tool_type: 'text_and_image', + truncation_policy: { mode: 'tokens', limit: 10_000 }, + supports_parallel_tool_calls: true, + supports_image_detail_original: true, + context_window: input.contextWindow, + max_context_window: input.contextWindow, + effective_context_window_percent: 95, + experimental_supported_tools: [], + input_modalities: ['text'], + supports_search_tool: true, + } +} + +function buildCodexModelCatalog(input: { + profile: string + provider: string + model: string + presetModels: string[] +}) { + const models = [...new Set([input.model, ...input.presetModels].map(item => item.trim()).filter(Boolean))] + return { + models: models.map((model, index) => codexCatalogEntry({ + model, + displayName: displayNameForModel(model), + contextWindow: getModelContextLength({ profile: input.profile, provider: input.provider, model }), + priority: index, + })), + } +} + +function expandHomePath(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/')) return join(homedir(), path.slice(2)) + return path +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function powerShellQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function cmdQuote(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function buildLaunchShellCommand(input: { + workspaceDir: string + env: Record + command: string + args: string[] +}): string { + if (process.platform === 'win32') { + const envAssignments = Object.entries(input.env) + .map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`) + return [ + `Set-Location -LiteralPath ${powerShellQuote(input.workspaceDir)}`, + ...envAssignments, + `& ${powerShellQuote(input.command)} ${input.args.map(powerShellQuote).join(' ')}`.trim(), + ].join('; ') + } + + const envPrefix = Object.entries(input.env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ') + const runCommand = [ + envPrefix, + input.command, + ...input.args.map(shellQuote), + ].filter(Boolean).join(' ') + return `cd ${shellQuote(input.workspaceDir)} && ${runCommand}` +} + +function appleScriptString(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +async function commandExists(command: string): Promise { + try { + await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [command], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + }) + return true + } catch { + return false + } +} + +function isDockerRuntime(): boolean { + return existsSync('/.dockerenv') || process.env.container === 'docker' +} + +async function openNativeTerminal(shellCommand: string): Promise { + if (process.platform === 'win32') { + const escapedCommand = shellCommand.replace(/"/g, '""').replace(/\$/g, '`$') + await execFileAsync('powershell.exe', [ + '-NoProfile', + '-Command', + `Start-Process -FilePath powershell.exe -ArgumentList @('-NoExit', '-Command', "${escapedCommand}")`, + ], { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return 'PowerShell' + } + + if (process.platform === 'darwin') { + await execFileAsync('osascript', [ + '-e', + `tell application "Terminal" to do script ${appleScriptString(shellCommand)}`, + '-e', + 'tell application "Terminal" to activate', + ], { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return 'Terminal.app' + } + + if (process.platform === 'linux') { + if (isDockerRuntime()) { + const err = new Error('Native terminal is not available inside Docker') + ;(err as any).status = 400 + throw err + } + if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + const err = new Error('Native terminal requires a Linux desktop session') + ;(err as any).status = 400 + throw err + } + + const candidates: Array<{ command: string; args: string[] }> = [ + { command: 'xdg-terminal-exec', args: ['bash', '-lc', shellCommand] }, + { command: 'gnome-terminal', args: ['--', 'bash', '-lc', shellCommand] }, + { command: 'konsole', args: ['-e', 'bash', '-lc', shellCommand] }, + { command: 'xfce4-terminal', args: ['--command', `bash -lc ${shellQuote(shellCommand)}`] }, + { command: 'kitty', args: ['bash', '-lc', shellCommand] }, + { command: 'alacritty', args: ['-e', 'bash', '-lc', shellCommand] }, + { command: 'xterm', args: ['-e', 'bash', '-lc', shellCommand] }, + ] + + const errors: string[] = [] + for (const candidate of candidates) { + if (!(await commandExists(candidate.command))) continue + try { + await execFileAsync(candidate.command, candidate.args, { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return candidate.command + } catch (err: any) { + errors.push(`${candidate.command}: ${normalizeError(err)}`) + } + } + + const err = new Error(errors[0] || 'No supported Linux terminal command was found') + ;(err as any).status = 400 + throw err + } + + const err = new Error('Native terminal launch is not supported on this platform') + ;(err as any).status = 400 + throw err +} + +function getLiveConfigFileDefinition(id: string, key: string): CodingAgentConfigFileDefinition | null { + const tool = getCodingAgentDefinition(id) + if (!tool) return null + const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) + if (!definition) return null + return { + key: definition.key, + path: definition.path, + language: definition.language, + absolutePath: expandHomePath(definition.path), + } +} + +function getScopedConfigFileDefinition(id: string, key: string, scopeInput: CodingAgentConfigScope = {}): (CodingAgentConfigFileDefinition & Required & { rootDir: string }) | null { + const tool = getCodingAgentDefinition(id) + if (!tool) return null + const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) + if (!definition) return null + const scope = normalizeConfigScope(scopeInput) + const rootDir = getScopedConfigRoot(tool.id, scope) + return { + key: definition.key, + path: definition.path, + language: definition.language, + ...scope, + rootDir, + absolutePath: join(rootDir, definition.scopedPath), + } +} + +function getCurrentNodeEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: [getNodeBinDir(), getNvmNodeBinPaths(), process.env.PATH].filter(Boolean).join(delimiter), + npm_node_execpath: process.execPath, + } +} + +async function npmExecution(args: string[], env: NodeJS.ProcessEnv): Promise<{ command: string; args: string[] }> { + const bundledNpmCli = getNpmCliPath() + if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] } + + let npmBin: string | null = null + for (const command of [...new Set([getNpmBin(), 'npm'])]) { + const paths = await findCommandPaths(command, env) + if (paths[0]) { + npmBin = paths[0] + break + } + } + if (!npmBin) throw nodeEnvironmentMissingError() + + const npmCli = npmCliFromNpmBin(npmBin) + if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] } + + let nodeBin: string | null = null + for (const command of [...new Set([process.platform === 'win32' ? 'node.exe' : 'node', 'node'])]) { + const paths = await findCommandPaths(command, env) + if (paths[0]) { + nodeBin = paths[0] + break + } + } + if (!nodeBin) throw nodeEnvironmentMissingError() + + return commandExecution(npmBin, args) +} + +async function runNpm(args: string[], options: { timeout?: number; env?: NodeJS.ProcessEnv } = {}) { + const env = { + ...getCurrentNodeEnv(), + ...options.env, + } + const execution = await npmExecution(args, env) + return execFileAsync(execution.command, execution.args, { + encoding: 'utf-8', + timeout: options.timeout, + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + env, + }) +} + +function normalizeError(err: any): string { + if (isNodeEnvironmentMissingError(err)) return nodeEnvironmentMissingError().message + const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' + const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' + const message = stderr || stdout || err?.message || String(err) + return message.split(/\r?\n/).filter(Boolean).slice(0, 4).join('\n') +} + +function normalizeErrorCode(err: any): string | undefined { + return isNodeEnvironmentMissingError(err) ? NODE_ENVIRONMENT_MISSING_CODE : undefined +} + +async function findCommandPaths(command: string, env: NodeJS.ProcessEnv): Promise { + try { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which' + const lookupArgs = process.platform === 'win32' ? [command] : ['-a', command] + const { stdout } = await execFileAsync(lookupCommand, lookupArgs, { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env, + }) + return stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean) + } catch { + return [] + } +} + +function windowsCommandNeedsShell(command: string): boolean { + const extension = extname(command).toLowerCase() + return extension === '.cmd' || extension === '.bat' +} + +async function resolveCommandForExecution(command: string, env: NodeJS.ProcessEnv): Promise { + if (process.platform !== 'win32') return command + const paths = await findCommandPaths(command, env) + // On Windows, prioritize paths with .cmd or .bat extensions since where may return + // both the unix-style script (without extension) and the Windows shim (.cmd) + const windowsPath = paths.find(path => windowsCommandNeedsShell(path)) + return windowsPath || paths[0] || command +} + +function commandExecution(command: string, args: string[]): { command: string; args: string[] } { + if (process.platform === 'win32' && windowsCommandNeedsShell(command)) { + // For CMD /C, the command and args need to be passed as a single string + // The command path should be quoted if it contains spaces, but args are joined directly + const commandArg = / /.test(command) ? `"${command}"` : command + const argsString = args.map(arg => / /.test(arg) ? `"${arg}"` : arg).join(' ') + return { + command: 'cmd.exe', + args: ['/d', '/s', '/c', `${commandArg} ${argsString}`], + } + } + return { command, args } +} + +function packageParts(packageName: string): string[] { + return packageName.split('/').filter(Boolean) +} + +function getPrefixFromPackagePath(path: string, packageName: string): string | null { + const normalized = path.replace(/\\/g, '/') + const parts = normalized.split('/').filter(Boolean) + const nodeModulesIndex = parts.lastIndexOf('node_modules') + const packageNameParts = packageParts(packageName) + + if (nodeModulesIndex <= 0) return null + for (let i = 0; i < packageNameParts.length; i += 1) { + if (parts[nodeModulesIndex + 1 + i] !== packageNameParts[i]) return null + } + + const libIndex = nodeModulesIndex - 1 + if (parts[libIndex] !== 'lib') return null + const prefixParts = parts.slice(0, libIndex) + if (prefixParts.length === 0) return process.platform === 'win32' ? null : '/' + return `${normalized.startsWith('/') ? '/' : ''}${prefixParts.join('/')}` +} + +async function getCommandPackagePrefixes(definition: CodingAgentDefinition, env: NodeJS.ProcessEnv): Promise { + const commandPaths = await findCommandPaths(definition.command, env) + const prefixes = new Set() + + for (const commandPath of commandPaths) { + const candidates = [commandPath] + try { + candidates.push(realpathSync(commandPath)) + } catch { + // Keep the unresolved command path as the fallback candidate. + } + + for (const candidate of candidates) { + const prefix = getPrefixFromPackagePath(candidate, definition.packageName) + if (prefix) prefixes.add(prefix) + } + } + return [...prefixes] +} + +function extractVersion(raw: string): string { + const trimmed = raw.trim() + return trimmed.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] || trimmed.split(/\s+/)[0] || '' +} + +async function getGlobalNpmBin(): Promise { + if (typeof cachedGlobalNpmBin !== 'undefined') return cachedGlobalNpmBin + try { + const { stdout } = await runNpm(['prefix', '-g'], { timeout: 5000 }) + const prefix = stdout.trim() + cachedGlobalNpmBin = prefix ? (process.platform === 'win32' ? prefix : join(prefix, 'bin')) : null + } catch { + cachedGlobalNpmBin = null + } + return cachedGlobalNpmBin +} + +async function commandEnv(): Promise { + const env = getCurrentNodeEnv() + const npmBin = await getGlobalNpmBin() + if (npmBin) { + const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH' + const currentPath = env[pathKey] || '' + if (!currentPath.split(delimiter).includes(npmBin)) { + env[pathKey] = currentPath ? `${npmBin}${delimiter}${currentPath}` : npmBin + } + } + return env +} + +export function getCodingAgentDefinitions(): CodingAgentDefinition[] { + return TOOL_DEFINITIONS.map(tool => ({ ...tool })) +} + +export function getCodingAgentDefinition(id: string): CodingAgentDefinition | null { + return TOOL_DEFINITIONS.find(tool => tool.id === id) || null +} + +export function getCodingAgentConfigFileDefinitions(id: string): CodingAgentConfigFileDefinition[] { + const tool = getCodingAgentDefinition(id) + if (!tool) return [] + return CONFIG_FILE_DEFINITIONS[tool.id].map(file => ({ + key: file.key, + path: file.path, + language: file.language, + absolutePath: expandHomePath(file.path), + })) +} + +export async function getCodingAgentStatus(definition: CodingAgentDefinition): Promise { + try { + const env = await commandEnv() + const resolvedCommand = await resolveCommandForExecution(definition.command, env) + const execution = commandExecution(resolvedCommand, ['--version']) + const { stdout, stderr } = await execFileAsync(execution.command, execution.args, { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + env, + }) + const rawVersion = `${stdout || ''}${stderr || ''}`.trim() + return { + ...definition, + installed: true, + version: extractVersion(rawVersion), + rawVersion, + } + } catch (err: any) { + return { + ...definition, + installed: false, + version: '', + rawVersion: '', + error: normalizeError(err), + } + } +} + +export async function getCodingAgentsStatus(): Promise { + return { + tools: await Promise.all(TOOL_DEFINITIONS.map(tool => getCodingAgentStatus(tool))), + } +} + +export async function installCodingAgent(id: string): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + if (installingTools.has(tool.id)) { + const err = new Error('Install is already running') + ;(err as any).status = 409 + throw err + } + + installingTools.add(tool.id) + try { + const env = await commandEnv() + await runNpm(['install', '-g', tool.packageName], { + timeout: 10 * 60 * 1000, + env, + }) + cachedGlobalNpmBin = undefined + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: status.installed, + tool: status, + tools: allStatus.tools, + message: status.installed ? 'Installed' : status.error || 'Install completed but the command was not found', + } + } catch (err: any) { + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: false, + tool: status, + tools: allStatus.tools, + message: normalizeError(err), + code: normalizeErrorCode(err), + } + } finally { + installingTools.delete(tool.id) + } +} + +export async function deleteCodingAgent(id: string): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + if (deletingTools.has(tool.id)) { + const err = new Error('Delete is already running') + ;(err as any).status = 409 + throw err + } + + deletingTools.add(tool.id) + try { + const env = await commandEnv() + const packagePrefixes = await getCommandPackagePrefixes(tool, env) + const uninstallArgsList = packagePrefixes.length > 0 + ? packagePrefixes.map(prefix => ['uninstall', '-g', '--prefix', prefix, tool.packageName]) + : [['uninstall', '-g', tool.packageName]] + for (const uninstallArgs of uninstallArgsList) { + await runNpm(uninstallArgs, { + timeout: 10 * 60 * 1000, + env, + }) + } + cachedGlobalNpmBin = undefined + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: !status.installed, + tool: status, + tools: allStatus.tools, + message: !status.installed ? 'Deleted' : 'Delete completed but the command is still available', + } + } catch (err: any) { + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: false, + tool: status, + tools: allStatus.tools, + message: normalizeError(err), + code: normalizeErrorCode(err), + } + } finally { + deletingTools.delete(tool.id) + } +} + +export async function readCodingAgentConfigFile(id: string, key: string, scope: CodingAgentConfigScope = {}): Promise { + const definition = getLiveConfigFileDefinition(id, key) + if (!definition) { + const err = new Error('Unknown coding agent config file') + ;(err as any).status = 404 + throw err + } + const normalizedScope = normalizeConfigScope(scope) + + try { + const info = await stat(definition.absolutePath) + if (!info.isFile()) { + const err = new Error('Config path is not a file') + ;(err as any).status = 400 + throw err + } + if (info.size > MAX_CONFIG_FILE_SIZE) { + const err = new Error('Config file is too large to edit') + ;(err as any).status = 413 + throw err + } + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content: await readFile(definition.absolutePath, 'utf-8'), + exists: true, + size: info.size, + } + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content: '', + exists: false, + size: 0, + } + } +} + +export async function writeCodingAgentConfigFile(id: string, key: string, content: string, scope: CodingAgentConfigScope = {}): Promise { + const definition = getLiveConfigFileDefinition(id, key) + if (!definition) { + const err = new Error('Unknown coding agent config file') + ;(err as any).status = 404 + throw err + } + const normalizedScope = normalizeConfigScope(scope) + + const buffer = Buffer.from(content || '', 'utf-8') + if (buffer.length > MAX_CONFIG_FILE_SIZE) { + const err = new Error('Config file content is too large') + ;(err as any).status = 413 + throw err + } + + await mkdir(dirname(definition.absolutePath), { recursive: true }) + await writeFile(definition.absolutePath, buffer) + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content, + exists: true, + size: buffer.length, + } +} + +export async function prepareCodingAgentLaunch(id: string, input: CodingAgentLaunchInput): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + + const mode = input.mode === 'global' ? 'global' : 'scoped' + if (mode === 'global') { + const scope = normalizeConfigScope({ profile: input.profile, provider: 'global' }) + const workspaceDir = getScopedWorkspaceRoot(scope) + await mkdir(workspaceDir, { recursive: true }) + const shellCommand = buildLaunchShellCommand({ + workspaceDir, + env: {}, + command: tool.command, + args: [], + }) + return { + agentId: tool.id, + mode, + profile: scope.profile, + provider: scope.provider, + model: '', + rootDir: workspaceDir, + workspaceDir, + command: tool.command, + args: [], + env: {}, + shellCommand, + files: [], + } + } + + const provider = normalizeScopeSegment(input.provider, 'default', 'provider') + const scope = normalizeConfigScope({ profile: input.profile, provider }) + const model = String(input.model || '').trim() + if (!model) { + const err = new Error('Model is required') + ;(err as any).status = 400 + throw err + } + + const baseUrl = String(input.baseUrl || '').trim() + const apiKey = String(input.apiKey || '').trim() + const preset = PROVIDER_PRESETS.find(item => item.value === provider) + const apiMode = normalizeLaunchApiMode(input.apiMode, preset?.api_mode || 'chat_completions') + const rootDir = getScopedConfigRoot(tool.id, scope) + const workspaceDir = getScopedWorkspaceRoot(scope) + await mkdir(rootDir, { recursive: true }) + await mkdir(workspaceDir, { recursive: true }) + + const files: Array<{ key: string; path: string; absolutePath: string }> = [] + const writeScopedFile = async (key: string, content: string) => { + const definition = getScopedConfigFileDefinition(tool.id, key, scope) + if (!definition) return + await mkdir(dirname(definition.absolutePath), { recursive: true }) + await writeFile(definition.absolutePath, content, 'utf-8') + files.push({ key, path: definition.path, absolutePath: definition.absolutePath }) + } + + let args: string[] = [] + let env: Record = {} + + if (tool.id === 'claude-code') { + const proxyTarget = baseUrl && apiKey + ? registerClaudeCodeProxyTarget({ provider, model, baseUrl, apiKey, apiMode }) + : null + const claudeBaseUrl = proxyTarget?.baseUrl || baseUrl + const claudeApiKey = proxyTarget?.token || apiKey + const modelName = displayNameForModel(model) + const settings = { + model, + env: { + ...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {}), + ...(claudeBaseUrl ? { ANTHROPIC_BASE_URL: claudeBaseUrl } : {}), + ANTHROPIC_MODEL: model, + ANTHROPIC_CUSTOM_MODEL_OPTION: model, + ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: modelName, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: modelName, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: modelName, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: modelName, + }, + } + await writeScopedFile('settings', `${JSON.stringify(settings, null, 2)}\n`) + await writeScopedFile('mcp', `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`) + + const settingsPath = join(rootDir, 'settings.json') + const mcpPath = join(rootDir, 'mcp.json') + args = ['--settings', settingsPath, '--mcp-config', mcpPath] + } else { + if (apiMode !== 'chat_completions' && apiMode !== 'codex_responses' && apiMode !== 'anthropic_messages') { + const err = new Error('Codex launch only supports OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages providers') + ;(err as any).status = 400 + throw err + } + const proxyTarget = apiMode !== 'codex_responses' && baseUrl && apiKey + ? registerCodexProxyTarget({ profile: scope.profile, provider, model, baseUrl, apiKey, apiMode }) + : null + const codexBaseUrl = proxyTarget?.baseUrl || baseUrl + const codexApiKey = proxyTarget?.token || apiKey + const providerId = 'custom' + const catalogPath = join(rootDir, CODEX_MODEL_CATALOG_FILE) + const configToml = [ + `model_catalog_json = ${JSON.stringify(catalogPath)}`, + `model_provider = ${JSON.stringify(providerId)}`, + `model = ${JSON.stringify(model)}`, + 'disable_response_storage = true', + '', + `[model_providers.${providerId}]`, + `name = ${JSON.stringify(provider)}`, + ...(codexBaseUrl ? [`base_url = ${JSON.stringify(codexBaseUrl)}`] : []), + 'wire_api = "responses"', + 'requires_openai_auth = false', + ...(codexApiKey ? [`experimental_bearer_token = ${JSON.stringify(codexApiKey)}`] : []), + '', + ].join('\n') + const catalog = buildCodexModelCatalog({ + profile: scope.profile, + provider, + model, + presetModels: Array.isArray(preset?.models) ? preset.models : [], + }) + await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf-8') + files.push({ key: 'model_catalog', path: CODEX_MODEL_CATALOG_FILE, absolutePath: catalogPath }) + await writeScopedFile('config', configToml) + await writeScopedFile('auth', `${JSON.stringify({}, null, 2)}\n`) + + env = { CODEX_HOME: rootDir } + args = ['--model', model] + } + + const shellCommand = buildLaunchShellCommand({ + workspaceDir, + env, + command: tool.command, + args, + }) + + return { + agentId: tool.id, + mode, + profile: scope.profile, + provider: scope.provider, + model, + rootDir, + workspaceDir, + command: tool.command, + args, + env, + shellCommand, + files, + } +} + +export async function openCodingAgentNativeTerminal(id: string, input: CodingAgentLaunchInput): Promise { + const launch = await prepareCodingAgentLaunch(id, input) + const terminal = await openNativeTerminal(launch.shellCommand) + return { + ...launch, + nativeTerminal: true, + terminal, + } +} diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts new file mode 100644 index 0000000..7f02e43 --- /dev/null +++ b/packages/server/src/services/config-helpers.ts @@ -0,0 +1,295 @@ +import { readFile, chmod } from 'fs/promises' +import { readdir, stat } from 'fs/promises' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getProfileDir } from './hermes/hermes-profile' +import { logger } from './logger' +import { safeFileStore } from './safe-file-store' + +// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- +export const PROVIDER_ENV_MAP: Record = { + 'fun-codex': { api_key_env: '', base_url_env: '' }, + 'fun-claude': { api_key_env: '', base_url_env: '' }, + lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' }, + openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' }, + 'glm-coding-plan': { api_key_env: '', base_url_env: '' }, + zai: { api_key_env: 'GLM_API_KEY', base_url_env: 'GLM_BASE_URL' }, + 'kimi-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: 'KIMI_BASE_URL' }, + 'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' }, + minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' }, + 'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: 'MINIMAX_CN_BASE_URL' }, + deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' }, + alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: 'DASHSCOPE_BASE_URL' }, + 'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' }, + anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: 'ANTHROPIC_BASE_URL' }, + xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' }, + 'xai-oauth': { api_key_env: '', base_url_env: '' }, + xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' }, + 'xiaomi-token-plan': { api_key_env: 'XIAOMI_TOKEN_PLAN_API_KEY', base_url_env: 'XIAOMI_TOKEN_PLAN_BASE_URL' }, + gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: 'GEMINI_BASE_URL' }, + kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' }, + 'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: 'AI_GATEWAY_BASE_URL' }, + cliproxyapi: { api_key_env: '', base_url_env: '' }, + 'opencode-zen': { api_key_env: 'OPENCODE_ZEN_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' }, + 'opencode-go': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' }, + huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' }, + nvidia: { api_key_env: 'NVIDIA_API_KEY', base_url_env: 'NVIDIA_BASE_URL' }, + novita: { api_key_env: 'NOVITA_API_KEY', base_url_env: 'NOVITA_BASE_URL' }, + gmi: { api_key_env: 'GMI_API_KEY', base_url_env: 'GMI_BASE_URL' }, + arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: 'ARCEE_BASE_URL' }, + stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: 'STEPFUN_BASE_URL' }, + 'ollama-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: 'OLLAMA_BASE_URL' }, + nous: { api_key_env: '', base_url_env: '' }, + 'openai-codex': { api_key_env: '', base_url_env: '' }, + 'openai-api': { api_key_env: 'OPENAI_API_KEY', base_url_env: 'OPENAI_BASE_URL' }, + copilot: { api_key_env: 'GITHUB_TOKEN', base_url_env: '' }, + longcat: { api_key_env: 'LONGCAT_API_KEY', base_url_env: 'LONGCAT_BASE_URL' }, + 'tencent-tokenhub': { api_key_env: 'TENCENT_TOKENHUB_API_KEY', base_url_env: 'TOKENHUB_BASE_URL' }, +} + +// --- Types --- + +export type SkillSource = 'builtin' | 'hub' | 'local' | 'external' + +export interface SkillInfo { + name: string + description: string + enabled: boolean + source?: SkillSource +} + +export interface SkillCategory { + name: string + description: string + skills: SkillInfo[] +} + +export interface ModelInfo { + id: string + label: string +} + +export interface ModelGroup { + provider: string + models: ModelInfo[] +} + +// --- Config YAML helpers --- + +const configPath = () => getActiveConfigPath() +const configPathForProfile = (profile: string) => join(getProfileDir(profile), 'config.yaml') +const envPathForProfile = (profile: string) => join(getProfileDir(profile), '.env') + +export async function readConfigYaml(): Promise> { + return safeFileStore.readYaml(configPath()) +} + +export async function readConfigYamlForProfile(profile: string): Promise> { + return safeFileStore.readYaml(configPathForProfile(profile)) +} + +export async function writeConfigYaml(config: Record): Promise { + await safeFileStore.writeYaml(configPath(), config, { backup: true }) +} + +export async function updateConfigYaml( + updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, +): Promise { + return safeFileStore.updateYaml(configPath(), updater, { backup: true }) +} + +export async function updateConfigYamlForProfile( + profile: string, + updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, +): Promise { + return safeFileStore.updateYaml(configPathForProfile(profile), updater, { backup: true }) +} + +export function stripLegacyApiServerGatewayConfig(config: Record): { config: Record; changed: boolean } { + if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) { + return { config, changed: false } + } + + if (config.platforms.api_server !== undefined) { + delete config.platforms.api_server + if (Object.keys(config.platforms).length === 0) delete config.platforms + return { config, changed: true } + } + + return { config, changed: false } +} + +// --- .env helpers --- + +function assertValidEnvKey(key: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid .env key: ${JSON.stringify(key)}`) + } +} + +async function saveEnvValueAtPath(envPath: string, key: string, value: string): Promise { + assertValidEnvKey(key) + await safeFileStore.updateText(envPath, (raw) => { + const remove = !value + const lines = raw.split('\n') + let found = false + const result: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) { + if (!remove) result.push(`${key}=${value}`) + found = true + } else { + const eqIdx = trimmed.indexOf('=') + if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) { + if (!remove) result.push(`${key}=${value}`) + found = true + } else { + result.push(line) + } + } + } + if (!found && !remove) { + result.push(`${key}=${value}`) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n' + }) + try { await chmod(envPath, 0o600) } catch { /* ignore */ } +} + +export async function saveEnvValue(key: string, value: string): Promise { + await saveEnvValueAtPath(getActiveEnvPath(), key, value) +} + +export async function saveEnvValueForProfile(profile: string, key: string, value: string): Promise { + await saveEnvValueAtPath(envPathForProfile(profile), key, value) +} + +// --- File helpers --- + +export async function safeReadFile(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return null + } +} + +export async function safeStat(filePath: string): Promise<{ mtime: number } | null> { + try { + const s = await stat(filePath) + return { mtime: Math.round(s.mtimeMs) } + } catch { + return null + } +} + +// --- Skill helpers --- + +export function extractDescription(content: string): string { + const lines = content.split('\n') + let inFrontmatter = false + let bodyStarted = false + + for (const line of lines) { + if (!bodyStarted && line.trim() === '---') { + if (!inFrontmatter) { + inFrontmatter = true + continue + } else { + inFrontmatter = false + bodyStarted = true + continue + } + } + if (inFrontmatter) continue + if (line.trim() === '') continue + if (line.startsWith('#')) continue + return line.trim().slice(0, 80) + } + return '' +} + +export async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> { + const result: { path: string; name: string }[] = [] + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + result.push(...await listFilesRecursive(join(dir, entry.name), relPath)) + } else { + result.push({ path: relPath, name: entry.name }) + } + } + return result +} + +// --- Provider model helpers --- + +export async function fetchProviderModels(baseUrl: string, apiKey: string, freeOnly = false): Promise { + const base = baseUrl.replace(/\/+$/, '') + const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models` + try { + const res = await fetch(modelsUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) { + logger.warn('available-models %s returned %d', modelsUrl, res.status) + return [] + } + const data = await res.json() as { data?: Array<{ id: string }> } + if (!Array.isArray(data.data)) { + logger.warn('available-models %s returned unexpected format', modelsUrl) + return [] + } + let models = data.data.map(m => m.id) + if (freeOnly) models = models.filter(m => m.endsWith(':free')) + return models.sort() + } catch (err: any) { + logger.error(err, 'available-models %s failed', modelsUrl) + return [] + } +} + +export function buildModelGroups(config: Record): { default: string; groups: ModelGroup[] } { + let defaultModel = '' + const groups: ModelGroup[] = [] + + // 1. Extract current model + const modelSection = config.model + if (typeof modelSection === 'object' && modelSection !== null) { + defaultModel = String(modelSection.default || '').trim() + } else if (typeof modelSection === 'string') { + defaultModel = modelSection.trim() + } + + // 2. Extract custom_providers section + const customProviders = config.custom_providers + if (Array.isArray(customProviders)) { + const customModels: ModelInfo[] = [] + for (const entry of customProviders) { + if (entry && typeof entry === 'object') { + const cName = String(entry.name || '').trim() + const cModel = String(entry.model || '').trim() + if (cName && cModel) { + customModels.push({ id: cModel, label: `${cName}: ${cModel}` }) + } + } + } + if (customModels.length > 0) { + groups.push({ provider: 'Custom', models: customModels }) + } + } + + return { default: defaultModel, groups } +} + +// --- Profile directory helper --- + +export const getHermesDir = () => getActiveProfileDir() diff --git a/packages/server/src/services/credentials.ts b/packages/server/src/services/credentials.ts new file mode 100644 index 0000000..79287f5 --- /dev/null +++ b/packages/server/src/services/credentials.ts @@ -0,0 +1,59 @@ +import { readFile, writeFile, mkdir, unlink } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { scryptSync, randomBytes } from 'node:crypto' +import { config } from '../config' + +const APP_HOME = config.appHome +const CREDENTIALS_FILE = join(APP_HOME, '.credentials') + +export interface Credentials { + username: string + password_hash: string + salt: string + created_at: number +} + +const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } + +function hashPassword(password: string, salt: string): string { + return scryptSync(password, salt, 64, SCRYPT_OPTIONS).toString('hex') +} + +export async function getCredentials(): Promise { + try { + const data = await readFile(CREDENTIALS_FILE, 'utf-8') + return JSON.parse(data) + } catch { + return null + } +} + +export async function setCredentials(username: string, password: string): Promise { + const salt = randomBytes(16).toString('hex') + const password_hash = hashPassword(password, salt) + const cred: Credentials = { username, password_hash, salt, created_at: Date.now() } + await mkdir(APP_HOME, { recursive: true }) + await writeFile(CREDENTIALS_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 }) + return cred +} + +export async function deleteCredentials(): Promise { + try { + await unlink(CREDENTIALS_FILE) + } catch { + // File may not exist + } +} + +export async function verifyCredentials(username: string, password: string): Promise { + const cred = await getCredentials() + if (!cred) return false + if (cred.username !== username) return false + const computed = hashPassword(password, cred.salt) + return computed === cred.password_hash +} + +export function credentialsFileExists(): boolean { + return existsSync(CREDENTIALS_FILE) +} diff --git a/packages/server/src/services/hermes/agent-bridge/README.md b/packages/server/src/services/hermes/agent-bridge/README.md new file mode 100644 index 0000000..69209fb --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/README.md @@ -0,0 +1,99 @@ +# Agent Bridge + +Optional backend-side bridge for talking to Hermes Agent by instantiating +`run_agent.AIAgent` directly in a Python process. + +This is intentionally separate from the current Web UI chat path. + +## Python Service + +```bash +python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +``` + +Default endpoint: + +```text +ipc:///tmp/hermes-agent-bridge.sock +``` + +On Windows, the default endpoint is TCP because Python may not support Unix +domain sockets there: + +```text +tcp://127.0.0.1:18765 +``` + +Override with: + +```bash +HERMES_AGENT_BRIDGE_ENDPOINT=tcp://127.0.0.1:8765 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +``` + +Profile workers use the same platform defaults: TCP on Windows and IPC on +macOS/Linux. Override worker transport with: + +```bash +HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp HERMES_AGENT_BRIDGE_WORKER_PORT_BASE=18780 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +``` + +The service discovers Hermes Agent in this order: + +1. `--agent-root` +2. `HERMES_AGENT_ROOT` +3. the installed `hermes` command path +4. current working directory and parent directories +5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent` +6. the `hermes-agent` package installed in the selected Python environment + +Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`. + +Default agent root: + +```text +~/.hermes/hermes-agent +``` + +You can pass both paths explicitly: + +```bash +python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \ + --agent-root ~/.hermes/hermes-agent \ + --hermes-home ~/.hermes +``` + +If no source checkout containing `run_agent.py` is found, the bridge falls back +to importing `run_agent` from the Python environment. This supports package +installs such as `pip install hermes-agent`. The Node manager prefers the source +checkout's virtualenv when a checkout exists, then the Python interpreter from +the installed `hermes` command, then the system Python. + +The socket transport uses Python and Node standard libraries. No ZMQ dependency +is required. + +## Backend Usage + +```ts +import { AgentBridgeClient } from './services/hermes/agent-bridge' + +const bridge = new AgentBridgeClient() +const run = await bridge.chat(sessionId, message) + +for await (const chunk of bridge.streamOutput(run.run_id)) { + if (chunk.delta) { + // forward chunk.delta to Socket.IO/SSE/etc. + } +} +``` + +The external chat call only sends `session_id` and `message`. Provider, model, +keys, tools, reasoning, and session DB are resolved by hermes-agent from the +normal Hermes config and environment. + +The bridge instantiates `AIAgent` with `platform="cli"` by default so behavior +matches CLI chat. Override it only if a caller intentionally needs a distinct +platform identity: + +```bash +HERMES_AGENT_BRIDGE_PLATFORM=agent-bridge python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +``` diff --git a/packages/server/src/services/hermes/agent-bridge/client.ts b/packages/server/src/services/hermes/agent-bridge/client.ts new file mode 100644 index 0000000..1c231f0 --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/client.ts @@ -0,0 +1,621 @@ +import { setTimeout as delay } from 'timers/promises' +import { createConnection, type Socket } from 'net' +import { tmpdir } from 'os' +import { URL } from 'url' +import { join } from 'path' +import { bridgeLogger } from '../../logger' +import { getActiveProfileName, getProfileDir } from '../hermes-profile' +import type { McpActionResponse } from '../mcp-types' + +function resolveDefaultAgentBridgeEndpoint(): string { + if (process.env.VITEST) { + return process.platform === 'win32' + ? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}` + : `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}` + } + return process.platform === 'win32' + ? 'tcp://127.0.0.1:18765' + : 'ipc:///tmp/hermes-agent-bridge.sock' +} + +export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint() +export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000 + +function envPositiveInt(name: string): number | undefined { + const raw = process.env[name] + if (!raw) return undefined + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +export type AgentBridgeStatus = 'running' | 'complete' | 'interrupted' | 'error' + +export interface AgentBridgeOptions { + endpoint?: string + timeoutMs?: number + connectRetryMs?: number +} + +export interface AgentBridgeRequestOptions { + timeoutMs?: number + serialize?: boolean +} + +export interface AgentBridgeChatOptions { + force_compress?: boolean + storage_message?: AgentBridgeMessage + model?: string + provider?: string + source?: string + wait?: boolean + timeout?: number +} + +export type AgentBridgeMessage = + | string + | Array> + +export interface AgentBridgeResponse { + ok: true + [key: string]: unknown +} + +export interface AgentBridgeChatStarted extends AgentBridgeResponse { + run_id: string + session_id: string + status: AgentBridgeStatus +} + +export interface AgentBridgeOutput extends AgentBridgeResponse { + run_id: string + session_id: string + status: AgentBridgeStatus + delta: string + cursor: number + output: string + done: boolean + result?: unknown + error?: string | null + events: Array> + event_cursor: number +} + +export interface AgentBridgeRunResult extends AgentBridgeResponse { + run_id: string + session_id: string + status: AgentBridgeStatus + output: string + deltas: string[] + events: unknown[] + result?: unknown + error?: string | null +} + +export interface AgentBridgeContextEstimate extends AgentBridgeResponse { + session_id: string + token_count?: number | null + fixed_context_tokens?: number | null + system_prompt_tokens?: number | null + tool_tokens?: number | null + message_count: number + tool_count: number + tool_names?: string[] + system_prompt_chars: number + profile?: string + model?: string + provider?: string +} + +export interface AgentBridgeCommandResult extends AgentBridgeResponse { + session_id: string + command: string + handled: boolean + type?: string + action?: string + message?: string + output?: string + notice?: string + loaded?: string[] + missing?: string[] + new_session_id?: string + history?: unknown[] + retry?: boolean + retry_input?: AgentBridgeMessage + title?: string + kickoff_prompt?: string + clear_goal_continuations?: boolean + max_turns?: number +} + +export interface AgentBridgeGoalEvaluation extends AgentBridgeResponse { + session_id: string + handled: boolean + active?: boolean + status?: string | null + should_continue?: boolean + continuation_prompt?: string | null + verdict?: string + reason?: string + message?: string +} + +export interface AgentBridgeGoalPause extends AgentBridgeResponse { + session_id: string + handled: boolean + active?: boolean + status?: string | null + reason?: string + message?: string +} + +export class AgentBridgeError extends Error { + response?: unknown +} + +export class AgentBridgeClient { + readonly endpoint: string + readonly timeoutMs: number + readonly connectRetryMs: number + private lock: Promise = Promise.resolve() + + constructor(options: AgentBridgeOptions = {}) { + this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT + this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS + this.connectRetryMs = options.connectRetryMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS') ?? 5000 + } + + private summarizePayload(payload: Record): Record { + const action = String(payload.action || '') + const summary: Record = { action } + for (const key of ['session_id', 'run_id', 'request_id', 'approval_id', 'profile', 'worker_key']) { + if (payload[key] != null) summary[key] = payload[key] + } + if (Array.isArray(payload.conversation_history)) summary.conversation_history_count = payload.conversation_history.length + if (Array.isArray(payload.messages)) summary.messages_count = payload.messages.length + if (typeof payload.message === 'string') summary.message_chars = payload.message.length + else if (Array.isArray(payload.message)) summary.message_parts = payload.message.length + if (typeof payload.command === 'string') summary.command = payload.command + if (typeof payload.text === 'string') summary.text_chars = payload.text.length + if (typeof payload.error === 'string') summary.error = payload.error + if (payload.force_compress === true) summary.force_compress = true + return summary + } + + private summarizeResponse(response: Record): Record { + const summary: Record = { ok: response.ok === true } + for (const key of ['session_id', 'run_id', 'request_id', 'status', 'cursor', 'event_cursor']) { + if (response[key] != null) summary[key] = response[key] + } + if (typeof response.delta === 'string') summary.delta_chars = response.delta.length + if (typeof response.output === 'string') summary.output_chars = response.output.length + if (Array.isArray(response.events)) summary.events_count = response.events.length + if (typeof response.error === 'string') summary.error = response.error + if (Array.isArray(response.history)) summary.history_count = response.history.length + return summary + } + + private runtimeContext(payload: Record): Record { + const requestedProfile = typeof payload.profile === 'string' ? payload.profile.trim() : '' + let profile = requestedProfile || 'default' + try { + if (!requestedProfile) profile = getActiveProfileName() + } catch {} + + const context: Record = { + profile, + cwd: process.cwd(), + } + try { + const profileDir = getProfileDir(profile) + context.profile_dir = profileDir + context.config_path = join(profileDir, 'config.yaml') + } catch {} + return context + } + + async connect(): Promise { + return this + } + + async close(): Promise { + return undefined + } + + private connectSocketOnce(): Promise { + return new Promise((resolveConnect, rejectConnect) => { + const endpoint = this.endpoint + let socket: Socket + if (endpoint.startsWith('ipc://')) { + socket = createConnection(endpoint.slice('ipc://'.length)) + } else if (endpoint.startsWith('tcp://')) { + const url = new URL(endpoint) + socket = createConnection({ + host: url.hostname || '127.0.0.1', + port: Number(url.port), + }) + } else { + rejectConnect(new Error(`unsupported agent bridge endpoint: ${endpoint}`)) + return + } + + const cleanup = () => { + socket.off('connect', onConnect) + socket.off('error', onError) + } + const onConnect = () => { + cleanup() + resolveConnect(socket) + } + const onError = (err: Error) => { + cleanup() + socket.destroy() + rejectConnect(err) + } + socket.once('connect', onConnect) + socket.once('error', onError) + }) + } + + private isRetryableConnectError(err: any): boolean { + const code = String(err?.code || '') + return ['ECONNREFUSED', 'ENOENT', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT'].includes(code) + } + + private async connectSocket(): Promise { + const deadline = Date.now() + Math.max(0, this.connectRetryMs) + for (;;) { + try { + return await this.connectSocketOnce() + } catch (err) { + if (!this.isRetryableConnectError(err) || Date.now() >= deadline) { + throw err + } + await delay(100) + } + } + } + + private readResponse(socket: Socket, timeoutMs: number): Promise { + return new Promise((resolveRead, rejectRead) => { + let buffer = '' + const timeout = timeoutMs > 0 + ? setTimeout(() => { + cleanup() + socket.destroy() + rejectRead(new Error(`Agent bridge request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + : null + + const cleanup = () => { + if (timeout) clearTimeout(timeout) + socket.off('data', onData) + socket.off('error', onError) + socket.off('end', onEnd) + socket.off('close', onClose) + } + const finish = (line: string) => { + cleanup() + socket.end() + resolveRead(line) + } + const onData = (chunk: Buffer) => { + buffer += chunk.toString('utf8') + const idx = buffer.indexOf('\n') + if (idx >= 0) finish(buffer.slice(0, idx)) + } + const onError = (err: Error) => { + cleanup() + socket.destroy() + rejectRead(err) + } + const onEnd = () => { + const line = buffer.trim() + if (line) finish(line) + } + const onClose = () => { + if (!buffer.trim()) { + cleanup() + rejectRead(new Error('Agent bridge socket closed without a response')) + } + } + + socket.on('data', onData) + socket.once('error', onError) + socket.once('end', onEnd) + socket.once('close', onClose) + }) + } + + async request( + payload: Record, + options: AgentBridgeRequestOptions = {}, + ): Promise { + const run = async (): Promise => { + const timeoutMs = options.timeoutMs || this.timeoutMs + const startedAt = Date.now() + const action = String(payload.action || '') + const shouldLogRequest = action !== 'get_output' + const runtimeContext = shouldLogRequest ? this.runtimeContext(payload) : undefined + if (shouldLogRequest) { + bridgeLogger.info({ + endpoint: this.endpoint, + timeoutMs, + runtime: runtimeContext, + request: this.summarizePayload(payload), + }, '[agent-bridge-client] request') + } + try { + const socket = await this.connectSocket() + socket.write(`${JSON.stringify(payload)}\n`) + const raw = await this.readResponse(socket, timeoutMs) + const response = JSON.parse(raw) as { ok?: boolean; error?: string } + if (!response.ok) { + const error = new AgentBridgeError(response.error || 'Agent bridge request failed') + error.response = response + bridgeLogger.warn({ + durationMs: Date.now() - startedAt, + runtime: runtimeContext, + response: this.summarizeResponse(response as Record), + }, '[agent-bridge-client] request rejected') + throw error + } + if (shouldLogRequest) { + bridgeLogger.info({ + durationMs: Date.now() - startedAt, + runtime: runtimeContext, + response: this.summarizeResponse(response as Record), + }, '[agent-bridge-client] response') + } + return response as T + } catch (err: any) { + if (!(err instanceof AgentBridgeError)) { + bridgeLogger.error({ + durationMs: Date.now() - startedAt, + err: { message: err?.message, name: err?.name }, + runtime: runtimeContext, + request: this.summarizePayload(payload), + }, '[agent-bridge-client] request failed') + } + throw err + } + } + + if (!options.serialize) { + return run() + } + + const next = this.lock.then(run, run) + this.lock = next.catch(() => undefined) + return next + } + + ping(): Promise { + return this.request({ action: 'ping' }) + } + + chat( + sessionId: string, + message: AgentBridgeMessage, + conversationHistory?: unknown[], + instructions?: string, + profile?: string, + options: AgentBridgeChatOptions = {}, + ): Promise { + return this.request({ + action: 'chat', + session_id: sessionId, + message, + ...(options.storage_message !== undefined ? { storage_message: options.storage_message } : {}), + ...(conversationHistory ? { conversation_history: conversationHistory } : {}), + ...(instructions ? { instructions } : {}), + ...(profile ? { profile } : {}), + ...(options.model ? { model: options.model } : {}), + ...(options.provider ? { provider: options.provider } : {}), + ...(options.source ? { source: options.source } : {}), + ...(options.wait ? { wait: true } : {}), + ...(options.timeout ? { timeout: options.timeout } : {}), + ...(options.force_compress ? { force_compress: true } : {}), + }) + } + + contextEstimate( + sessionId: string, + messages: unknown[], + instructions?: string, + profile?: string, + options: Pick = {}, + ): Promise { + return this.request({ + action: 'context_estimate', + session_id: sessionId, + messages, + ...(instructions ? { instructions } : {}), + ...(profile ? { profile } : {}), + ...(options.model ? { model: options.model } : {}), + ...(options.provider ? { provider: options.provider } : {}), + }) + } + + command(sessionId: string, command: string, profile?: string): Promise { + return this.request({ + action: 'command', + session_id: sessionId, + command, + ...(profile ? { profile } : {}), + }) + } + + goalEvaluate(sessionId: string, finalResponse: string, profile?: string): Promise { + return this.request({ + action: 'goal_evaluate', + session_id: sessionId, + final_response: finalResponse, + ...(profile ? { profile } : {}), + }) + } + + getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise { + return this.request({ + action: 'get_output', + run_id: runId, + cursor, + event_cursor: eventCursor, + }, options) + } + + async *streamOutput( + runId: string, + options: AgentBridgeRequestOptions & { intervalMs?: number } = {}, + ): AsyncGenerator { + const intervalMs = options.intervalMs || 100 + let cursor = 0 + let eventCursor = 0 + for (;;) { + const chunk = await this.getOutput(runId, cursor, eventCursor, options) + cursor = chunk.cursor + eventCursor = chunk.event_cursor + if (chunk.delta || chunk.done || (chunk.events && chunk.events.length > 0)) yield chunk + if (chunk.done) return + await delay(intervalMs) + } + } + + async chatStream( + sessionId: string, + message: AgentBridgeMessage, + onDelta: (delta: string, chunk: AgentBridgeOutput) => void | Promise, + options: AgentBridgeRequestOptions & { intervalMs?: number } = {}, + ): Promise { + const started = await this.chat(sessionId, message) + let last: AgentBridgeOutput | null = null + for await (const chunk of this.streamOutput(started.run_id, options)) { + last = chunk + if (chunk.delta) await onDelta(chunk.delta, chunk) + } + if (!last) throw new Error(`Agent bridge run ${started.run_id} produced no output state`) + return last + } + + getResult(runId: string, options: AgentBridgeRequestOptions = {}): Promise { + return this.request({ action: 'get_result', run_id: runId }, options) + } + + interrupt(sessionId: string, message?: string, profile?: string): Promise { + return this.request({ + action: 'interrupt', + session_id: sessionId, + message, + ...(profile ? { profile } : {}), + }) + } + + goalPause(sessionId: string, reason: string, profile?: string): Promise { + return this.request({ + action: 'goal_pause', + session_id: sessionId, + reason, + ...(profile ? { profile } : {}), + }) + } + + steer(sessionId: string, text: string, profile?: string): Promise { + return this.request({ + action: 'steer', + session_id: sessionId, + text, + ...(profile ? { profile } : {}), + }) + } + + approvalRespond(approvalId: string, choice: string): Promise { + return this.request({ action: 'approval_respond', approval_id: approvalId, choice }) + } + + clarifyRespond(clarifyId: string, response: string): Promise { + return this.request({ action: 'clarify_respond', clarify_id: clarifyId, response }) + } + + compressionRespond( + requestId: string, + payload: { messages?: unknown[]; system_message?: string; error?: string }, + ): Promise { + return this.request({ + action: 'compression_respond', + request_id: requestId, + ...payload, + }, { timeoutMs: this.timeoutMs }) + } + + destroyAll(): Promise { + return this.request({ action: 'destroy_all' }, { serialize: true }) + } + + destroyProfile(profile: string): Promise { + return this.request({ action: 'destroy_profile', profile }, { serialize: true }) + } + + getHistory(sessionId: string, profile?: string): Promise { + return this.request({ + action: 'get_history', + session_id: sessionId, + ...(profile ? { profile } : {}), + }) + } + + status(sessionId: string, profile?: string): Promise { + return this.request({ + action: 'status', + session_id: sessionId, + ...(profile ? { profile } : {}), + }) + } + + destroy(sessionId: string, profile?: string, workerKey?: string): Promise { + return this.request({ + action: 'destroy', + session_id: sessionId, + ...(profile ? { profile } : {}), + ...(workerKey ? { worker_key: workerKey } : {}), + }) + } + + list(): Promise { + return this.request({ action: 'list' }) + } + + shutdown(): Promise { + return this.request({ action: 'shutdown' }, { serialize: true }) + } + + // ───── MCP Management ───── + + mcpList(profile?: string): Promise { + return this.request({ action: 'mcp_list', ...(profile ? { profile } : {}) }) + } + + mcpAdd(name: string, config: Record, profile?: string): Promise { + return this.request({ action: 'mcp_server_add', name, config, ...(profile ? { profile } : {}) }, { serialize: true }) + } + + mcpUpdate(name: string, config: Record, profile?: string): Promise { + return this.request({ action: 'mcp_server_update', name, config, ...(profile ? { profile } : {}) }, { serialize: true }) + } + + mcpRemove(name: string, profile?: string): Promise { + return this.request({ action: 'mcp_server_remove', name, ...(profile ? { profile } : {}) }, { serialize: true }) + } + + mcpTest(name: string, profile?: string): Promise { + return this.request({ action: 'mcp_server_test', name, ...(profile ? { profile } : {}) }, { timeoutMs: 180_000 }) + } + + mcpTools(server?: string, profile?: string, raw?: boolean): Promise { + return this.request({ action: 'mcp_tools_list', ...(server ? { server } : {}), ...(profile ? { profile } : {}), ...(raw ? { raw } : {}) }) + } + + mcpReload(server?: string, profile?: string): Promise { + return this.request({ action: 'mcp_reload', ...(server ? { server } : {}), ...(profile ? { profile } : {}) }, { serialize: true }) + } +} + +export default AgentBridgeClient diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py new file mode 100755 index 0000000..e2422bf --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -0,0 +1,3602 @@ +#!/usr/bin/env python3 +"""Hermes in-process agent bridge. + +This service intentionally lives outside the existing Web UI chat path. It +imports hermes-agent from HERMES_AGENT_ROOT (default: ~/.hermes/hermes-agent), +keeps AIAgent instances in memory by session_id, and exposes a small newline- +delimited JSON request/response protocol over a local socket. +""" + +from __future__ import annotations + +import argparse +import asyncio +import atexit +import copy +import errno +import hashlib +import importlib.util +import json +import locale +import os +import queue +import re +import signal +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +import traceback +import uuid +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse +from typing import Any, Callable + + +DEFAULT_ENDPOINT = "tcp://127.0.0.1:18765" if os.name == "nt" else "ipc:///tmp/hermes-agent-bridge.sock" +DEFAULT_AGENT_ROOT = "~/.hermes/hermes-agent" +DEFAULT_HERMES_HOME = "~/.hermes" +APPROVAL_TIMEOUT_SECONDS = 120 +APPROVAL_TIMEOUT_MS = APPROVAL_TIMEOUT_SECONDS * 1000 +PARENT_WATCHDOG_INTERVAL_SECONDS = 2.0 +OPENROUTER_ATTRIBUTION_ENV = { + "referer": "HERMES_OPENROUTER_APP_REFERER", + "title": "HERMES_OPENROUTER_APP_TITLE", + "categories": "HERMES_OPENROUTER_APP_CATEGORIES", +} +_SURROGATE_RE = re.compile("[\ud800-\udfff]") + + +def _bridge_platform() -> str: + return os.environ.get("HERMES_AGENT_BRIDGE_PLATFORM", "cli").strip() or "cli" + + +def _positive_int(value: str | None) -> int | None: + if not value: + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _hidden_subprocess_kwargs() -> dict[str, Any]: + if os.name != "nt": + return {} + if os.environ.get("HERMES_DESKTOP", "").strip().lower() != "true": + return {} + create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000 + kwargs: dict[str, Any] = {"creationflags": create_no_window} + try: + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 1) + startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0) + kwargs["startupinfo"] = startupinfo + except Exception: + pass + return kwargs + + +def _add_hidden_process_options(kwargs: dict[str, Any], create_no_window: int) -> None: + flags = kwargs.get("creationflags", 0) or 0 + try: + kwargs["creationflags"] = int(flags) | create_no_window + except Exception: + kwargs["creationflags"] = create_no_window + + startupinfo = kwargs.get("startupinfo") + if startupinfo is None: + try: + startupinfo = subprocess.STARTUPINFO() + except Exception: + return + kwargs["startupinfo"] = startupinfo + try: + startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 1) + startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0) + except Exception: + pass + + +def _install_windows_hidden_subprocess_defaults() -> None: + """Hide console windows for subprocesses launched inside desktop bridge runs. + + The desktop bridge itself must keep stdout/stderr pipes for readiness and + worker handshakes, so it runs under python.exe. On Windows that means any + nested console executable, including git.exe from context expansion, can + flash a window unless the child process is created with CREATE_NO_WINDOW. + """ + if os.name != "nt": + return + if os.environ.get("HERMES_DESKTOP", "").strip().lower() != "true": + return + if getattr(subprocess, "_hermes_hidden_defaults_installed", False): + return + + original_popen = subprocess.Popen + original_create_subprocess_exec = asyncio.create_subprocess_exec + original_create_subprocess_shell = asyncio.create_subprocess_shell + create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000 + + class HiddenPopen(original_popen): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + _add_hidden_process_options(kwargs, create_no_window) + super().__init__(*args, **kwargs) + + async def hidden_create_subprocess_exec(*args: Any, **kwargs: Any) -> Any: + _add_hidden_process_options(kwargs, create_no_window) + return await original_create_subprocess_exec(*args, **kwargs) + + async def hidden_create_subprocess_shell(*args: Any, **kwargs: Any) -> Any: + _add_hidden_process_options(kwargs, create_no_window) + return await original_create_subprocess_shell(*args, **kwargs) + + subprocess.Popen = HiddenPopen # type: ignore[assignment] + asyncio.create_subprocess_exec = hidden_create_subprocess_exec # type: ignore[assignment] + asyncio.create_subprocess_shell = hidden_create_subprocess_shell # type: ignore[assignment] + subprocess._hermes_hidden_defaults_installed = True # type: ignore[attr-defined] + + +_install_windows_hidden_subprocess_defaults() + + +def _process_exists(pid: int) -> bool: + if pid <= 0: + return False + if os.name == "nt": + try: + result = subprocess.run( + ["tasklist.exe", "/FI", f"PID eq {pid}", "/NH"], + check=False, + capture_output=True, + text=True, + timeout=5, + **_hidden_subprocess_kwargs(), + ) + return str(pid) in (result.stdout or "") + except Exception: + return True + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError as exc: + return exc.errno != errno.ESRCH + + +def _start_parent_process_watchdog( + parent_pid: int | None, + stop_event: threading.Event, + label: str, + interval: float = PARENT_WATCHDOG_INTERVAL_SECONDS, +) -> None: + if not parent_pid or parent_pid == os.getpid(): + return + + def run() -> None: + while not stop_event.wait(interval): + if _process_exists(parent_pid): + continue + print( + f"[hermes-bridge] parent pid {parent_pid} exited; stopping {label}", + file=sys.stderr, + flush=True, + ) + stop_event.set() + return + + threading.Thread(target=run, daemon=True, name=f"hermes-bridge-parent-watchdog-{label}").start() + + +def _install_stop_signal_handlers(stop_event: threading.Event) -> Callable[[], None]: + if threading.current_thread() is not threading.main_thread(): + return lambda: None + + previous: list[tuple[signal.Signals, Any]] = [] + + def handle_signal(signum: int, _frame: Any) -> None: + stop_event.set() + + for signum in (signal.SIGINT, signal.SIGTERM): + try: + sig = signal.Signals(signum) + previous.append((sig, signal.getsignal(sig))) + signal.signal(sig, handle_signal) + except Exception: + pass + + def restore() -> None: + for sig, handler in previous: + try: + signal.signal(sig, handler) + except Exception: + pass + + return restore + + +def _suppress_bridge_platform_hint() -> None: + raw = os.environ.get("HERMES_BRIDGE_SUPPRESS_PLATFORM_HINT", "cli").strip() + if raw.lower() in {"0", "false", "no", "off"}: + return + targets = {part.strip().lower() for part in raw.split(",") if part.strip()} + if not targets: + return + try: + from agent import prompt_builder + + for target in targets: + prompt_builder.PLATFORM_HINTS.pop(target, None) + except Exception: + pass + + run_agent_module = sys.modules.get("run_agent") + hints = getattr(run_agent_module, "PLATFORM_HINTS", None) + if isinstance(hints, dict): + for target in targets: + hints.pop(target, None) + + +def _candidate_agent_roots(raw: str | None = None) -> list[Path]: + candidates: list[Path] = [] + if raw: + candidates.append(Path(raw).expanduser()) + + env_root = os.environ.get("HERMES_AGENT_ROOT") + if env_root: + candidates.append(Path(env_root).expanduser()) + + hermes_bin = shutil.which(os.environ.get("HERMES_BIN", "hermes")) + if hermes_bin: + bin_path = Path(hermes_bin).resolve() + candidates.extend([ + bin_path.parent.parent, + bin_path.parent.parent.parent, + bin_path.parent.parent / "hermes-agent", + ]) + + script_path = Path(__file__).resolve() + candidates.extend([ + Path.cwd(), + Path.cwd() / ".hermes" / "hermes-agent", + Path.cwd() / "hermes-agent", + script_path.parent, + script_path.parent.parent, + script_path.parent.parent.parent, + script_path.parent.parent.parent / ".hermes" / "hermes-agent", + ]) + for parent in script_path.parents: + candidates.extend([ + parent / ".hermes" / "hermes-agent", + parent / "hermes-agent", + ]) + + candidates.extend([ + Path.home() / ".hermes" / "hermes-agent", + Path.home() / "hermes-agent", + Path("/opt/hermes/hermes-agent"), + Path("/opt/hermes-agent"), + Path("/usr/local/lib/hermes-agent"), + Path("/usr/local/hermes-agent"), + ]) + candidates.append(Path(DEFAULT_AGENT_ROOT).expanduser()) + + unique: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + try: + resolved = candidate.resolve() + except OSError: + resolved = candidate + key = str(resolved) + if key not in seen: + seen.add(key) + unique.append(resolved) + return unique + + +def _find_agent_root(raw: str | None = None) -> Path | None: + for candidate in _candidate_agent_roots(raw): + if (candidate / "run_agent.py").exists(): + return candidate + return None + + +def _discover_agent_root(raw: str | None = None) -> Path: + root = _find_agent_root(raw) + if root is not None: + return root + attempted = ", ".join(str(path) for path in _candidate_agent_roots(raw)) + raise RuntimeError( + "hermes-agent run_agent.py not found. Pass --agent-root or set " + f"HERMES_AGENT_ROOT. Tried: {attempted}" + ) + + +def _discover_hermes_home(raw: str | None = None) -> Path: + if raw: + return Path(raw).expanduser().resolve() + env_home = os.environ.get("HERMES_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + return Path(DEFAULT_HERMES_HOME).expanduser().resolve() + + +def _normalize_base_home(home: Path) -> Path: + if home.parent.name == "profiles": + return home.parent.parent + return home + + +def _jsonable(value: Any) -> Any: + try: + json.dumps(value) + return value + except TypeError: + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + return str(value) + + +def _sanitize_surrogates(value: Any) -> Any: + if isinstance(value, str): + return _SURROGATE_RE.sub("\ufffd", value) + if isinstance(value, dict): + return {_sanitize_surrogates(k): _sanitize_surrogates(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_sanitize_surrogates(v) for v in value] + return value + + +def _json_default(value: Any) -> str: + return _sanitize_surrogates(str(value)) + + +def _json_line_bytes(value: Any) -> bytes: + payload = json.dumps(_sanitize_surrogates(value), ensure_ascii=False, default=_json_default) + "\n" + return payload.encode("utf-8") + + +def _bridge_log(event: str, payload: dict[str, Any]) -> None: + try: + body = {"event": event, **payload} + print( + "[hermes_bridge] " + json.dumps(_sanitize_surrogates(body), ensure_ascii=False, default=_json_default), + file=sys.stderr, + flush=True, + ) + except Exception: + print(f"[hermes_bridge] {event}", file=sys.stderr, flush=True) + + +def _tool_names_from_definitions(tools: Any) -> list[str]: + if not isinstance(tools, list): + return [] + names: list[str] = [] + for tool in tools: + name = "" + if isinstance(tool, dict): + function = tool.get("function") + if isinstance(function, dict): + name = str(function.get("name") or "") + if not name: + name = str(tool.get("name") or "") + else: + name = str(getattr(tool, "name", "") or "") + if name: + names.append(name) + return names + + +def _mcp_tool_names_from_names(tool_names: Any) -> list[str]: + if not isinstance(tool_names, list): + return [] + return sorted(str(name) for name in tool_names if str(name).startswith("mcp_")) + + +def _agent_root() -> Path | None: + return _find_agent_root(os.environ.get("HERMES_AGENT_ROOT")) + + +def _hermes_home() -> Path: + return _discover_hermes_home(os.environ.get("HERMES_HOME")) + + +def _base_hermes_home() -> Path: + return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME)) + + +def _worker_profile() -> str | None: + raw = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE", "").strip() + return raw or None + + +def _profile_home(profile: str | None) -> Path: + base = _base_hermes_home() + if not profile or profile == "default": + return base + profile_home = base / "profiles" / profile + return profile_home if profile_home.exists() else base + + +def _read_dotenv(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + values: dict[str, str] = {} + try: + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + if stripped.startswith("export "): + stripped = stripped[7:].strip() + key, value = stripped.split("=", 1) + key = key.strip() + if not key or not (key[0].isalpha() or key[0] == "_"): + continue + if not all(ch.isalnum() or ch == "_" for ch in key): + continue + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + values[key] = value + return values + except Exception: + return {} + + +def _profile_dotenv_keys() -> set[str]: + base = _base_hermes_home() + keys = set(_read_dotenv(base / ".env").keys()) + profiles_dir = base / "profiles" + try: + for entry in profiles_dir.iterdir(): + if entry.is_dir(): + keys.update(_read_dotenv(entry / ".env").keys()) + except Exception: + pass + return keys + + +def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None: + resolved_root = _discover_agent_root(agent_root) if agent_root else _find_agent_root() + if resolved_root is not None: + os.environ["HERMES_AGENT_ROOT"] = str(resolved_root) + else: + os.environ.pop("HERMES_AGENT_ROOT", None) + resolved_home = _discover_hermes_home(hermes_home) + os.environ["HERMES_HOME"] = str(resolved_home) + os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home)) + + +def _ensure_agent_imports() -> None: + root = _agent_root() + if root is not None: + root_s = str(root) + if root_s not in sys.path: + sys.path.insert(0, root_s) + elif importlib.util.find_spec("run_agent") is None: + raise RuntimeError( + "hermes-agent run_agent.py not found in source locations and the " + "current Python environment cannot import run_agent. Install " + "hermes-agent or pass --agent-root/HERMES_AGENT_ROOT." + ) + os.environ.setdefault("HERMES_HOME", str(_hermes_home())) + os.environ.setdefault("HERMES_AGENT_BRIDGE_BASE_HOME", str(_hermes_home())) + _apply_openrouter_attribution_override() + + +def _apply_openrouter_attribution_override() -> None: + """Override hermes-agent OpenRouter attribution at bridge runtime only.""" + referer = os.environ.get(OPENROUTER_ATTRIBUTION_ENV["referer"], "").strip() + title = os.environ.get(OPENROUTER_ATTRIBUTION_ENV["title"], "").strip() + categories = os.environ.get(OPENROUTER_ATTRIBUTION_ENV["categories"], "").strip() + if not (referer or title or categories): + return + try: + from agent import auxiliary_client + except Exception: + return + headers = dict(getattr(auxiliary_client, "_OR_HEADERS_BASE", {}) or {}) + if referer: + headers["HTTP-Referer"] = referer + if title: + headers.pop("X-Title", None) + headers["X-OpenRouter-Title"] = title + if categories: + headers["X-OpenRouter-Categories"] = categories + try: + auxiliary_client._OR_HEADERS_BASE = headers + except Exception: + pass + + +def _load_cfg(profile: str | None = None) -> dict[str, Any]: + _ensure_agent_imports() + try: + from hermes_cli.config import load_config + + cfg = load_config() + return cfg if isinstance(cfg, dict) else {} + except Exception: + try: + import yaml + + path = _hermes_home() / "config.yaml" + if not path.exists(): + return {} + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return {} + + +def _apply_profile_env(profile: str | None) -> str | None: + """Temporarily set HERMES_HOME to the profile directory. + Returns the original HERMES_HOME value to restore later. + """ + profile_home = _profile_home(profile) + if not (profile_home / "config.yaml").exists(): + return os.environ.get("HERMES_HOME") + original = os.environ.get("HERMES_HOME") + os.environ["HERMES_HOME"] = str(profile_home) + return original + + +def _restore_profile_env(original: str | None) -> None: + """Restore HERMES_HOME after profile-scoped agent creation.""" + if original is not None: + os.environ["HERMES_HOME"] = original + else: + os.environ.pop("HERMES_HOME", None) + + +def _apply_profile_dotenv(profile: str | None) -> dict[str, str | None]: + """Load only the active profile's .env into this bridge process. + + This mirrors Web UI gateway env isolation: + - default keeps inherited env for compatibility, then overlays default .env + - non-default clears keys seen in any profile .env, then overlays its .env + The returned snapshot restores the bridge process after the agent call. + """ + values = _read_dotenv(_profile_home(profile) / ".env") + if profile and profile != "default": + keys = _profile_dotenv_keys() + keys.update(values.keys()) + else: + keys = set(values.keys()) + snapshot = {key: os.environ.get(key) for key in keys} + + if profile and profile != "default": + for key in keys: + os.environ.pop(key, None) + for key, value in values.items(): + os.environ[key] = value + return snapshot + + +def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None: + for key, value in snapshot.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _set_worker_profile_env(profile: str | None) -> None: + profile_home = _profile_home(profile) + os.environ["HERMES_HOME"] = str(profile_home) + os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default" + _refresh_worker_profile_env() + + +def _refresh_worker_profile_env() -> None: + """Overlay the current worker profile .env/config before creating a new agent.""" + profile = _worker_profile() + if not profile: + return + profile_home = _profile_home(profile) + os.environ["HERMES_HOME"] = str(profile_home) + values = _read_dotenv(profile_home / ".env") + for key, value in values.items(): + os.environ[key] = value + _refresh_terminal_env() + + +@contextmanager +def _profile_env(profile: str | None): + if _worker_profile(): + yield + return + original = _apply_profile_env(profile) + env_snapshot = _apply_profile_dotenv(profile) + try: + yield + finally: + _restore_profile_dotenv(env_snapshot) + _restore_profile_env(original) + + +def _refresh_terminal_env() -> None: + """Bridge current worker HERMES_HOME/config.yaml terminal config to TERMINAL_* env vars. + + Worker startup first overlays the profile .env, then this function lets + terminal config.yaml values override the matching terminal environment vars. + """ + hermes_home = os.environ.get("HERMES_HOME", "") + if not hermes_home: + return + config_path = Path(hermes_home) / "config.yaml" + if not config_path.exists(): + return + try: + import yaml + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + terminal_cfg = cfg.get("terminal", {}) + if not isinstance(terminal_cfg, dict): + return + TERMINAL_ENV_MAP = { + "backend": "TERMINAL_ENV", + "cwd": "TERMINAL_CWD", + "timeout": "TERMINAL_TIMEOUT", + "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS", + "ssh_host": "TERMINAL_SSH_HOST", + "ssh_user": "TERMINAL_SSH_USER", + "ssh_port": "TERMINAL_SSH_PORT", + "ssh_key": "TERMINAL_SSH_KEY", + "docker_image": "TERMINAL_DOCKER_IMAGE", + "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", + "singularity_image": "TERMINAL_SINGULARITY_IMAGE", + "modal_image": "TERMINAL_MODAL_IMAGE", + "daytona_image": "TERMINAL_DAYTONA_IMAGE", + "vercel_runtime": "TERMINAL_VERCEL_RUNTIME", + "container_cpu": "TERMINAL_CONTAINER_CPU", + "container_memory": "TERMINAL_CONTAINER_MEMORY", + "container_disk": "TERMINAL_CONTAINER_DISK", + "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", + "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "docker_env": "TERMINAL_DOCKER_ENV", + "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", + "sandbox_dir": "TERMINAL_SANDBOX_DIR", + "persistent_shell": "TERMINAL_PERSISTENT_SHELL", + "modal_mode": "TERMINAL_MODAL_MODE", + } + for cfg_key, env_var in TERMINAL_ENV_MAP.items(): + if cfg_key in terminal_cfg: + val = terminal_cfg[cfg_key] + if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}: + continue + if cfg_key == "cwd" and isinstance(val, str): + val = os.path.expanduser(val) + if isinstance(val, (list, dict)): + os.environ[env_var] = json.dumps(val) + else: + os.environ[env_var] = str(val) + except Exception: + print( + f"[hermes-bridge] Failed to refresh terminal env from {config_path}", + file=sys.stderr, + flush=True, + ) + + +def _resolve_model(cfg: dict[str, Any]) -> str: + env_model = ( + os.environ.get("HERMES_MODEL", "") + or os.environ.get("HERMES_INFERENCE_MODEL", "") + ).strip() + if env_model: + return env_model + model_cfg = cfg.get("model", "") + if isinstance(model_cfg, dict): + return str(model_cfg.get("default") or "").strip() + if isinstance(model_cfg, str): + return model_cfg.strip() + return "" + + +def _resolve_runtime(model: str, provider: str | None = None) -> dict[str, Any]: + _ensure_agent_imports() + from hermes_cli.runtime_provider import resolve_runtime_provider + + requested = provider or os.environ.get("HERMES_BRIDGE_PROVIDER", "").strip() or None + return resolve_runtime_provider(requested=requested, target_model=model or None) + + +def _load_enabled_toolsets() -> list[str] | None: + _ensure_agent_imports() + raw = os.environ.get("HERMES_BRIDGE_TOOLSETS", "").strip() + if raw: + values = [part.strip() for part in raw.split(",") if part.strip()] + if any(value in {"all", "*"} for value in values): + return None + return values or None + + try: + from hermes_cli.config import load_config + from hermes_cli.tools_config import _get_platform_tools + from toolsets import resolve_toolset + + cfg = load_config() + platform = _bridge_platform() + enabled = sorted(_get_platform_tools(cfg, platform, include_default_mcp_servers=True)) + if platform != "cli": + resolved_tools: set[str] = set() + for toolset_name in enabled: + try: + resolved_tools.update(resolve_toolset(toolset_name)) + except Exception: + pass + if not resolved_tools: + enabled = sorted(_get_platform_tools(cfg, "cli", include_default_mcp_servers=True)) + return enabled or None + except Exception: + return None + + +def _discover_bridge_mcp_tools() -> list[str]: + _ensure_agent_imports() + try: + from tools.mcp_tool import discover_mcp_tools + + tools = discover_mcp_tools() + return list(tools) if isinstance(tools, list) else [] + except Exception as exc: + print( + f"[hermes_bridge] MCP tool discovery failed: {exc}", + file=sys.stderr, + flush=True, + ) + return [] + + +def _log_worker_startup_context(profile: str | None) -> None: + profile_name = profile or _worker_profile() or "default" + try: + cfg = _load_cfg() + enabled_toolsets = _load_enabled_toolsets() + discovered_mcp_tools = _discover_bridge_mcp_tools() + tool_names: list[str] = [] + tool_error: str | None = None + try: + from model_tools import get_tool_definitions + + tool_names = _tool_names_from_definitions( + get_tool_definitions( + enabled_toolsets=enabled_toolsets, + quiet_mode=True, + ) + ) + except Exception as exc: + tool_error = str(exc) + + mcp_servers = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {} + enabled_mcp_servers: list[str] = [] + disabled_mcp_servers: list[str] = [] + for name, server_cfg in mcp_servers.items(): + enabled = True + if isinstance(server_cfg, dict): + enabled = str(server_cfg.get("enabled", True)).strip().lower() not in {"0", "false", "no", "off"} + (enabled_mcp_servers if enabled else disabled_mcp_servers).append(str(name)) + + _bridge_log("bridge.worker.initialized", { + "profile": profile_name, + "platform": _bridge_platform(), + "hermes_home": str(_hermes_home()), + "base_hermes_home": str(_base_hermes_home()), + "config_path": str(_hermes_home() / "config.yaml"), + "model": _resolve_model(cfg), + "enabled_toolsets": enabled_toolsets, + "tool_count": len(tool_names), + "tool_names": tool_names, + "tool_error": tool_error, + "mcp_server_count": len(mcp_servers), + "mcp_servers": sorted(str(name) for name in mcp_servers), + "enabled_mcp_servers": sorted(enabled_mcp_servers), + "disabled_mcp_servers": sorted(disabled_mcp_servers), + "mcp_discovered_tool_count": len(discovered_mcp_tools), + "mcp_discovered_tool_names": discovered_mcp_tools, + "mcp_tool_count": len(_mcp_tool_names_from_names(tool_names)), + "mcp_tool_names": _mcp_tool_names_from_names(tool_names), + }) + except Exception as exc: + _bridge_log("bridge.worker.initialized", { + "profile": profile_name, + "error": str(exc), + }) + + +def _load_reasoning_config() -> dict[str, Any] | None: + _ensure_agent_imports() + try: + from hermes_constants import parse_reasoning_effort + + effort = str((_load_cfg().get("agent") or {}).get("reasoning_effort", "") or "").strip() + return parse_reasoning_effort(effort) + except Exception: + return None + + +def _load_service_tier() -> str | None: + raw = str((_load_cfg().get("agent") or {}).get("service_tier", "") or "").strip().lower() + if raw in {"fast", "priority", "on"}: + return "priority" + return None + + +def _cfg_max_turns(cfg: dict[str, Any], default: int = 90) -> int: + try: + env_max = int(os.environ.get("HERMES_BRIDGE_MAX_TURNS", "") or 0) + if env_max > 0: + return env_max + except ValueError: + pass + agent_cfg = cfg.get("agent") or {} + try: + return int(agent_cfg.get("max_turns") or cfg.get("max_turns") or default) + except (TypeError, ValueError): + return default + + +class SessionDbHolder: + def __init__(self) -> None: + self._lock = threading.Lock() + self._db_by_path: dict[str, Any] = {} + self._error: str | None = None + + def get(self, db_path: Path | None = None): + with self._lock: + key = str((db_path or (_base_hermes_home() / "state.db")).resolve()) + if key in self._db_by_path: + return self._db_by_path[key] + _ensure_agent_imports() + try: + from hermes_state import SessionDB + + db = SessionDB(db_path=Path(key)) + self._db_by_path[key] = db + self._error = None + return db + except Exception as exc: + self._error = str(exc) + return None + + @property + def error(self) -> str | None: + return self._error + + def get_for_profile(self, profile: str | None) -> Any: + """Get a SessionDB for the given profile using an explicit DB path.""" + return self.get(_profile_home(profile) / "state.db") + + +@dataclass +class RunRecord: + run_id: str + session_id: str + status: str = "running" + started_at: float = field(default_factory=time.time) + ended_at: float | None = None + result: dict[str, Any] | None = None + error: str | None = None + deltas: list[str] = field(default_factory=list) + events: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class AgentSession: + session_id: str + agent: Any + history: list[dict[str, Any]] = field(default_factory=list) + config: dict[str, Any] = field(default_factory=dict) + running: bool = False + current_run_id: str | None = None + lock: threading.RLock = field(default_factory=threading.RLock) + created_at: float = field(default_factory=time.time) + last_used_at: float = field(default_factory=time.time) + + +class AgentPool: + def __init__(self) -> None: + self._sessions: dict[str, AgentSession] = {} + self._runs: dict[str, RunRecord] = {} + self._lock = threading.RLock() + self._db = SessionDbHolder() + self._approval_requests: dict[str, queue.Queue[str]] = {} + self._gateway_approval_requests: dict[str, str] = {} + self._compression_requests: dict[str, queue.Queue[dict[str, Any]]] = {} + self._clarify_requests: dict[str, queue.Queue[str]] = {} + self._run_context = threading.local() + self._approval_handlers: dict[str, Callable[..., str]] = {} + self._exec_ask_depth = 0 + self._exec_ask_previous: str | None = None + + def get_or_create( + self, + session_id: str, + profile: str | None = None, + model: str | None = None, + provider: str | None = None, + ) -> AgentSession: + requested_model = str(model or "").strip() + requested_provider = str(provider or "").strip() + with self._lock: + existing = self._sessions.get(session_id) + if existing is not None: + # If profile changed, destroy old session and recreate + config_changed = bool( + (profile and existing.config.get("profile") != profile) + or (requested_model and existing.config.get("model") != requested_model) + or (requested_provider and existing.config.get("provider") != requested_provider) + ) + if config_changed: + if not existing.running: + self._destroy_session(session_id) + else: + existing.last_used_at = time.time() + return existing + else: + existing.last_used_at = time.time() + return existing + + _ensure_agent_imports() + _suppress_bridge_platform_hint() + from run_agent import AIAgent + + with _profile_env(profile): + _refresh_worker_profile_env() + discovered_mcp_tools = _discover_bridge_mcp_tools() + cfg = _load_cfg() + resolved_model = requested_model or _resolve_model(cfg) + runtime = _resolve_runtime(resolved_model, requested_provider or None) + agent_cfg = cfg.get("agent") or {} + prompt = str(agent_cfg.get("system_prompt", "") or "").strip() or None + + agent = AIAgent( + model=resolved_model, + max_iterations=_cfg_max_turns(cfg, 90), + provider=runtime.get("provider"), + base_url=runtime.get("base_url"), + api_key=runtime.get("api_key"), + api_mode=runtime.get("api_mode"), + acp_command=runtime.get("command"), + acp_args=runtime.get("args"), + credential_pool=runtime.get("credential_pool"), + quiet_mode=True, + verbose_logging=False, + reasoning_config=_load_reasoning_config(), + service_tier=_load_service_tier(), + enabled_toolsets=_load_enabled_toolsets(), + platform=_bridge_platform(), + session_id=session_id, + session_db=self._db.get_for_profile(profile), + ephemeral_system_prompt=prompt, + status_callback=self._status_callback(session_id), + thinking_callback=self._make_thinking_callback(session_id), + reasoning_callback=self._text_event_callback(session_id, "reasoning.delta"), + tool_progress_callback=self._tool_progress_callback(session_id), + tool_start_callback=self._tool_start_callback(session_id), + tool_complete_callback=self._tool_complete_callback(session_id), + clarify_callback=self._clarify_callback(session_id), + ) + agent.compression_enabled = False + self._install_compression_hook(agent, session_id) + mcp_tool_names = self._mcp_tool_names(self._agent_tool_names(getattr(agent, "tools", None) or [])) + + session = AgentSession( + session_id=session_id, + agent=agent, + history=[], + config={ + "requested_session_id": session_id, + "profile": profile or "default", + "model": resolved_model, + "provider": runtime.get("provider"), + "base_url": runtime.get("base_url"), + "api_mode": runtime.get("api_mode"), + "platform": _bridge_platform(), + "resumed": False, + "resumed_message_count": 0, + "mcp_tool_count": len(discovered_mcp_tools), + "active_mcp_tool_count": len(mcp_tool_names), + "db_error": self._db.error, + }, + ) + self._sessions[session_id] = session + return session + + def _install_compression_hook(self, agent: Any, session_id: str) -> None: + original = getattr(agent, "_compress_context", None) + if not callable(original): + return + + def wrapped_compress_context(messages, system_message, **kwargs): + before_count = len(messages) if isinstance(messages, list) else 0 + approx_tokens = kwargs.get("approx_tokens") + if not isinstance(approx_tokens, int) or approx_tokens <= 0: + approx_tokens = self._estimate_context_tokens(agent, messages, system_message) + print( + "[hermes_bridge] compression requested " + f"session={session_id} messages={before_count} " + f"tokens={approx_tokens if approx_tokens is not None else 'unknown'} " + f"focus={kwargs.get('focus_topic') or ''}", + file=sys.stderr, + flush=True, + ) + request_id = uuid.uuid4().hex + response_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1) + with self._lock: + self._compression_requests[request_id] = response_queue + self._append_event(session_id, { + "event": "bridge.compression.requested", + "request_id": request_id, + "message_count": before_count, + "approx_tokens": approx_tokens, + "focus_topic": kwargs.get("focus_topic"), + "messages": _jsonable(messages), + }) + try: + response = response_queue.get(timeout=180) + if response.get("error"): + raise RuntimeError(str(response.get("error"))) + compressed_messages = response.get("messages") + if not isinstance(compressed_messages, list): + raise RuntimeError("bridge compression response missing messages") + next_system_message = response.get("system_message", system_message) + result_approx_tokens = self._estimate_context_tokens(agent, compressed_messages, next_system_message) + self._append_event(session_id, { + "event": "bridge.compression.completed", + "request_id": request_id, + "message_count": before_count, + "result_messages": len(compressed_messages), + "approx_tokens": approx_tokens, + "result_approx_tokens": result_approx_tokens, + "compressed": True, + }) + return compressed_messages, next_system_message + except queue.Empty: + self._append_event(session_id, { + "event": "bridge.compression.failed", + "request_id": request_id, + "message_count": before_count, + "approx_tokens": approx_tokens, + "error": "bridge compression timed out", + }) + raise RuntimeError("bridge compression timed out") + except Exception as exc: + self._append_event(session_id, { + "event": "bridge.compression.failed", + "request_id": request_id, + "message_count": before_count, + "approx_tokens": approx_tokens, + "error": str(exc), + }) + raise + finally: + with self._lock: + self._compression_requests.pop(request_id, None) + + agent._compress_context = wrapped_compress_context + + def _agent_system_prompt(self, agent: Any, system_message: Any = None) -> str: + prompt = str(getattr(agent, "_cached_system_prompt", "") or "") + if prompt: + return prompt + try: + build_prompt = getattr(agent, "_build_system_prompt", None) + if callable(build_prompt): + return str(build_prompt(system_message) or "") + except Exception: + return str(system_message or "") + return str(system_message or "") + + def _agent_tool_names(self, tools: Any) -> list[str]: + return _tool_names_from_definitions(tools) + + def _mcp_tool_names(self, tool_names: Any) -> list[str]: + return _mcp_tool_names_from_names(tool_names) + + def _estimate_context_info(self, agent: Any, messages: Any, system_message: Any = None) -> dict[str, Any]: + try: + from agent.model_metadata import estimate_request_tokens_rough + except Exception: + return {} + + prompt = self._agent_system_prompt(agent, system_message) + tools = getattr(agent, "tools", None) or [] + message_list = messages if isinstance(messages, list) else [] + try: + tool_names = self._agent_tool_names(tools) + token_count = estimate_request_tokens_rough(message_list, system_prompt=prompt, tools=tools or None) + fixed_context_tokens = estimate_request_tokens_rough([], system_prompt=prompt, tools=tools or None) + system_prompt_tokens = estimate_request_tokens_rough([], system_prompt=prompt, tools=None) + tool_tokens = max(0, int(fixed_context_tokens or 0) - int(system_prompt_tokens or 0)) + return { + "token_count": int(token_count) if isinstance(token_count, (int, float)) and token_count > 0 else None, + "fixed_context_tokens": int(fixed_context_tokens) if isinstance(fixed_context_tokens, (int, float)) and fixed_context_tokens >= 0 else None, + "system_prompt_tokens": int(system_prompt_tokens) if isinstance(system_prompt_tokens, (int, float)) and system_prompt_tokens >= 0 else None, + "tool_tokens": tool_tokens, + "message_count": len(message_list), + "tool_count": len(tools) if isinstance(tools, list) else 0, + "tool_names": tool_names, + "mcp_tool_count": len(self._mcp_tool_names(tool_names)), + "mcp_tool_names": self._mcp_tool_names(tool_names), + "system_prompt_chars": len(prompt), + } + except Exception: + return {} + + def _estimate_context_tokens(self, agent: Any, messages: Any, system_message: Any = None) -> int | None: + token_count = self._estimate_context_info(agent, messages, system_message).get("token_count") + return int(token_count) if isinstance(token_count, (int, float)) and token_count > 0 else None + + def _bridge_context_ready_event(self, session: AgentSession, instructions: str | None, profile: str | None) -> dict[str, Any]: + info = self._estimate_context_info(session.agent, [], instructions) + event = { + "event": "bridge.context.ready", + "session_id": session.session_id, + "profile": profile or session.config.get("profile") or "default", + "model": session.config.get("model"), + "provider": session.config.get("provider"), + **info, + } + session.config["context_info"] = event + return event + + def estimate_context( + self, + session_id: str, + messages: list[dict[str, Any]] | None = None, + instructions: str | None = None, + profile: str | None = None, + model: str | None = None, + provider: str | None = None, + ) -> dict[str, Any]: + session = self.get_or_create(session_id, profile=profile, model=model, provider=provider) + context_info = self._estimate_context_info(session.agent, messages or [], instructions) + print( + "[hermes_bridge] context estimate " + f"session={session_id} profile={profile or 'default'} " + f"messages={len(messages or [])} system_prompt_chars={context_info.get('system_prompt_chars') or 0} " + f"tools={context_info.get('tool_count') or 0} " + f"fixed_tokens={context_info.get('fixed_context_tokens') if context_info.get('fixed_context_tokens') is not None else 'unknown'} " + f"tokens={context_info.get('token_count') if context_info.get('token_count') is not None else 'unknown'}", + file=sys.stderr, + flush=True, + ) + return { + "session_id": session_id, + "profile": profile or session.config.get("profile") or "default", + "model": session.config.get("model"), + "provider": session.config.get("provider"), + **context_info, + } + + def respond_compression( + self, + request_id: str, + messages: list[dict[str, Any]] | None = None, + system_message: str | None = None, + error: str | None = None, + ) -> dict[str, Any]: + with self._lock: + response_queue = self._compression_requests.get(request_id) + if response_queue is None: + raise RuntimeError(f"compression request {request_id} not found") + response_queue.put({ + "messages": messages, + "system_message": system_message, + "error": error, + }) + return {"request_id": request_id, "handled": True} + + def _destroy_session(self, session_id: str) -> None: + session = self._sessions.pop(session_id, None) + if session is None: + return + with self._lock: + for rid in list(self._runs): + if self._runs[rid].session_id == session_id: + del self._runs[rid] + + def _append_event(self, session_id: str, event: dict[str, Any]) -> None: + with self._lock: + session = self._sessions.get(session_id) + run_id = session.current_run_id if session else None + if run_id and run_id in self._runs: + self._runs[run_id].events.append(_jsonable(event)) + + def _status_callback(self, session_id: str): + def callback(kind, text=None): + self._append_event(session_id, {"event": "status", "kind": str(kind), "text": None if text is None else str(text)}) + + return callback + + def _text_event_callback(self, session_id: str, event_name: str): + def callback(text): + self._append_event(session_id, {"event": event_name, "text": str(text)}) + + return callback + + def _make_thinking_callback(self, session_id: str): + """Create a thinking callback that never forwards spinner text as content. + + The hermes-agent CLI uses thinking_callback for its KawaiiSpinner TUI + widget — sending decorative text like "(◕‿◕✿) pondering..." during + API calls. This is pure CLI UX decoration; it has no place in Web UI + conversation history. + + Prior behaviour forwarded this text as thinking.delta events, which the + frontend stored in the message reasoning field. Over long conversations + this contaminated the model's context: the LLM learned to reproduce + kaomoji patterns, creating a self-reinforcing degradation loop. + + This callback sends empty text unconditionally. The model's real + reasoning content arrives through reasoning_callback → reasoning.delta, + which is unaffected. + """ + def callback(text=None): + self._append_event(session_id, {"event": "thinking.delta", "text": ""}) + + return callback + + def _tool_start_callback(self, session_id: str): + def callback(tool_call_id, function_name, function_args): + self._append_event(session_id, { + "event": "tool.started", + "tool_call_id": str(tool_call_id) if tool_call_id else "", + "tool_name": str(function_name) if function_name else "", + "args": _jsonable(function_args) if function_args else {}, + }) + + return callback + + def _tool_complete_callback(self, session_id: str): + def callback(tool_call_id, function_name, function_args, function_result=None): + result_text = "" if function_result is None else str(function_result) + print( + "[hermes_bridge] tool_complete_callback " + f"session={session_id} tool={function_name} " + f"tool_call_id={tool_call_id} result_none={function_result is None} " + f"result_len={len(result_text)}", + file=sys.stderr, + flush=True, + ) + self._append_event(session_id, { + "event": "tool.completed", + "tool_call_id": str(tool_call_id) if tool_call_id else "", + "tool_name": str(function_name) if function_name else "", + "args": _jsonable(function_args) if function_args else {}, + "result": _jsonable(function_result) if function_result is not None else None, + "result_preview": str(function_result)[:500] if function_result else None, + }) + + return callback + + def _tool_progress_callback(self, session_id: str): + def callback(event_type, function_name=None, preview=None, function_args=None, **kwargs): + if event_type in (None, "tool.started", "tool.completed") or str(event_type or "").startswith("subagent."): + print( + "[hermes_bridge] tool_progress_callback " + f"session={session_id} event={event_type} tool={function_name} " + f"kwargs_keys={sorted(kwargs.keys())} " + f"preview_len={len(str(preview)) if preview is not None else 0}", + file=sys.stderr, + flush=True, + ) + if event_type == "reasoning.available": + self._append_event(session_id, { + "event": "reasoning.available", + "text": str(preview) if preview else "", + }) + return + + if str(event_type or "").startswith("subagent."): + payload = { + "event": str(event_type), + "tool_name": str(function_name) if function_name else "", + "text": str(preview) if preview is not None else "", + "args": _jsonable(function_args) if function_args else {}, + } + for key, value in kwargs.items(): + payload[str(key)] = _jsonable(value) + self._append_event(session_id, payload) + return + + if event_type == "_thinking": + text = function_name + if text: + self._append_event(session_id, { + "event": "reasoning.delta", + "text": str(text), + }) + return + + if event_type in (None, "tool.started"): + # AIAgent also calls tool_start_callback with the real tool_call_id. + # Use that event as canonical so resume/replay can match results. + return + + if event_type == "tool.completed": + # AIAgent sends the full function_result to tool_complete_callback. + return + + return callback + + def _step_callback(self, session_id: str): + def callback(step_info=None): + self._append_event(session_id, { + "event": "step", + "step_info": _jsonable(step_info) if step_info else None, + }) + + return callback + + def _stream_delta_callback(self, session_id: str): + def callback(delta=None): + if delta is None: + # Turn boundary signal (tools about to execute) + self._append_event(session_id, { + "event": "turn.boundary", + }) + return + if delta: + self._append_event(session_id, { + "event": "stream.delta", + "delta": str(delta), + }) + + return callback + + def _approval_callback(self, session_id: str): + def callback(command: str, description: str, *, allow_permanent: bool = True) -> str: + approval_id = uuid.uuid4().hex + response_queue: queue.Queue[str] = queue.Queue(maxsize=1) + with self._lock: + self._approval_requests[approval_id] = response_queue + choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + self._append_event(session_id, { + "event": "approval.requested", + "approval_id": approval_id, + "command": str(command or ""), + "description": str(description or ""), + "choices": choices, + "allow_permanent": bool(allow_permanent), + "timeout_ms": APPROVAL_TIMEOUT_MS, + }) + try: + choice = response_queue.get(timeout=APPROVAL_TIMEOUT_SECONDS) + except queue.Empty: + choice = "deny" + finally: + with self._lock: + self._approval_requests.pop(approval_id, None) + self._append_event(session_id, { + "event": "approval.resolved", + "approval_id": approval_id, + "choice": choice, + }) + return choice + + return callback + + def _clarify_callback(self, session_id: str): + def callback(question: str, choices: list[str] | None = None) -> str: + clarify_id = uuid.uuid4().hex + response_queue: queue.Queue[str] = queue.Queue(maxsize=1) + with self._lock: + self._clarify_requests[clarify_id] = response_queue + self._append_event(session_id, { + "event": "clarify.requested", + "clarify_id": clarify_id, + "question": str(question or ""), + "choices": list(choices) if choices else None, + "timeout_ms": 300_000, + }) + try: + user_response = response_queue.get(timeout=300) + except queue.Empty: + user_response = "[user did not respond within 5m]" + finally: + with self._lock: + self._clarify_requests.pop(clarify_id, None) + return user_response + + return callback + + def _approval_dispatcher(self, command: str, description: str, *, allow_permanent: bool = True) -> str: + session_id = str(getattr(self._run_context, "session_id", "") or "") + if not session_id: + return "deny" + with self._lock: + handler = self._approval_handlers.get(session_id) + if handler is None: + return "deny" + return handler(command, description, allow_permanent=allow_permanent) + + def _install_approval_dispatcher_for_current_thread(self) -> None: + from tools.terminal_tool import set_approval_callback + + # terminal_tool stores callbacks in threading.local(), so each run + # thread must bind the shared dispatcher for itself. + set_approval_callback(self._approval_dispatcher) + + def _enter_exec_ask_scope(self) -> None: + with self._lock: + if self._exec_ask_depth == 0: + self._exec_ask_previous = os.environ.get("HERMES_EXEC_ASK") + os.environ["HERMES_EXEC_ASK"] = "1" + self._exec_ask_depth += 1 + + def _exit_exec_ask_scope(self) -> None: + with self._lock: + if self._exec_ask_depth <= 0: + return + self._exec_ask_depth -= 1 + if self._exec_ask_depth > 0: + return + previous = self._exec_ask_previous + self._exec_ask_previous = None + if previous is None: + os.environ.pop("HERMES_EXEC_ASK", None) + else: + os.environ["HERMES_EXEC_ASK"] = previous + + def _gateway_approval_notify(self, session_id: str): + def callback(approval_data: dict[str, Any]) -> None: + approval_id = uuid.uuid4().hex + choices = ["once", "session", "always", "deny"] + with self._lock: + self._gateway_approval_requests[approval_id] = session_id + self._append_event(session_id, { + "event": "approval.requested", + "approval_id": approval_id, + "command": str(approval_data.get("command") or ""), + "description": str(approval_data.get("description") or ""), + "choices": choices, + "allow_permanent": True, + "timeout_ms": 300_000, + }) + + return callback + + def _prepersist_user_message( + self, + session: AgentSession, + message: Any, + storage_message: Any | None, + conversation_history: list[dict[str, Any]] | None, + profile: str | None, + source: str | None = None, + ) -> bool: + persist_message = storage_message if storage_message is not None else message + user_content = str(persist_message) if not isinstance(persist_message, dict) else str(persist_message.get("content", persist_message)) + if not user_content.strip(): + return False + + db = self._db.get_for_profile(profile) + if db is None: + return False + + history_len = len(conversation_history) if conversation_history else 0 + + try: + if hasattr(db, "create_session"): + db.create_session( + session_id=session.session_id, + source=source or _bridge_platform(), + model=session.config.get("model"), + ) + + if hasattr(db, "get_messages"): + messages = db.get_messages(session.session_id) + if messages: + last = messages[-1] + if last.get("role") == "user" and last.get("content") == user_content: + self._align_prepersist_flush_cursor(session, history_len) + return False + + db.append_message( + session_id=session.session_id, + role="user", + content=user_content, + ) + + # AIAgent will build messages as conversation_history + current user. + # Since the current user was pre-persisted above, align the flush + # cursor so the normal end-of-turn flush starts at assistant/tool + # messages generated by this run. + self._align_prepersist_flush_cursor(session, history_len) + return True + except Exception: + return False + + def _align_prepersist_flush_cursor(self, session: AgentSession, history_len: int) -> None: + try: + session.agent._last_flushed_db_idx = history_len + 1 + except Exception: + pass + + def _session_db_message_count(self, session_id: str, profile: str | None) -> int | None: + db = self._db.get_for_profile(profile) + if db is None or not hasattr(db, "get_messages"): + return None + try: + return len(db.get_messages(session_id) or []) + except Exception: + return None + + def _sync_result_tail_to_session_db( + self, + session: AgentSession, + result: dict[str, Any], + conversation_history: list[dict[str, Any]] | None, + profile: str | None, + db_count_after_prepersist: int | None, + ) -> None: + db = self._db.get_for_profile(profile) + if db is None or db_count_after_prepersist is None: + return + + after_count = self._session_db_message_count(session.session_id, profile) + if after_count is None or after_count > db_count_after_prepersist: + return + + messages = result.get("messages") + if not isinstance(messages, list): + return + + history_len = len(conversation_history) if conversation_history else 0 + generated = [ + msg for msg in messages[history_len + 1:] + if isinstance(msg, dict) and msg.get("role") in {"assistant", "tool"} + ] + if not generated: + return + + appended = 0 + for msg in generated: + try: + db.append_message( + session_id=session.session_id, + role=str(msg.get("role") or "assistant"), + content=msg.get("content"), + tool_name=msg.get("tool_name"), + tool_calls=msg.get("tool_calls") if isinstance(msg.get("tool_calls"), list) else None, + tool_call_id=msg.get("tool_call_id"), + finish_reason=msg.get("finish_reason"), + reasoning=msg.get("reasoning") if msg.get("role") == "assistant" else None, + reasoning_content=msg.get("reasoning_content") if msg.get("role") == "assistant" else None, + reasoning_details=msg.get("reasoning_details") if msg.get("role") == "assistant" else None, + codex_reasoning_items=msg.get("codex_reasoning_items") if msg.get("role") == "assistant" else None, + codex_message_items=msg.get("codex_message_items") if msg.get("role") == "assistant" else None, + ) + appended += 1 + except Exception: + break + + if appended: + print( + "[hermes_bridge] synced missing result tail to session db " + f"session={session.session_id} appended={appended}", + file=sys.stderr, + flush=True, + ) + + def start_chat( + self, + session_id: str, + message: Any, + storage_message: Any | None = None, + instructions: str | None = None, + conversation_history: list[dict[str, Any]] | None = None, + profile: str | None = None, + force_compress: bool = False, + model: str | None = None, + provider: str | None = None, + source: str | None = None, + ) -> RunRecord: + session = self.get_or_create(session_id, profile=profile, model=model, provider=provider) + with session.lock: + if session.running: + raise RuntimeError(f"session {session_id} is already running") + run_id = uuid.uuid4().hex + record = RunRecord(run_id=run_id, session_id=session_id) + with self._lock: + self._runs[run_id] = record + session.running = True + session.current_run_id = run_id + session.last_used_at = time.time() + context_event = self._bridge_context_ready_event(session, instructions, profile) + if context_event: + record.events.append(_jsonable(context_event)) + + thread = threading.Thread( + target=self._run_chat, + args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress, source), + daemon=True, + name=f"hermes-bridge-run-{run_id[:8]}", + ) + thread.start() + return record + + def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False, source: str | None = None) -> None: + with _profile_env(profile): + def stream_callback(delta: str) -> None: + with self._lock: + text = str(delta) + # Keep `deltas` for the aggregated `output`/resume snapshot, + # AND record each text chunk as an ordered event in the SAME + # `events` list used by tool.started/tool.completed. Text and + # tool events were previously tracked in two parallel lists + # with no relative ordering, so when the model interleaved + # narration and tool calls ("text → tool → more text") the + # consumer reordered them — processing all events before the + # aggregated delta — which visibly split a word across the + # tool boundary. Recording text as ordered events preserves + # the true interleaving. + record.deltas.append(text) + if text: + record.events.append({"event": "stream.delta", "delta": text}) + + approval_session_token = None + registered_gateway_approval_session = None + exec_ask_scope_entered = False + try: + try: + self._enter_exec_ask_scope() + exec_ask_scope_entered = True + self._install_approval_dispatcher_for_current_thread() + with self._lock: + self._approval_handlers[session.session_id] = self._approval_callback(session.session_id) + self._run_context.session_id = session.session_id + except Exception: + self._run_context.session_id = session.session_id + try: + from tools.approval import register_gateway_notify, set_current_session_key + + approval_session_token = set_current_session_key(session.session_id) + register_gateway_notify(session.session_id, self._gateway_approval_notify(session.session_id)) + registered_gateway_approval_session = session.session_id + except Exception: + pass + self._prepersist_user_message(session, message, storage_message, conversation_history, profile, source) + db_count_after_prepersist = self._session_db_message_count(session.session_id, profile) + if force_compress: + compress = getattr(session.agent, "_compress_context", None) + if callable(compress): + compressed_history, compressed_system = compress( + conversation_history if isinstance(conversation_history, list) else [], + instructions, + approx_tokens=None, + focus_topic="debug_force_compress", + ) + if isinstance(compressed_history, list): + conversation_history = compressed_history + if isinstance(compressed_system, str): + instructions = compressed_system + kwargs: dict[str, Any] = dict( + task_id=session.session_id, + stream_callback=stream_callback, + ) + if instructions: + kwargs["system_message"] = instructions + if conversation_history is not None: + kwargs["conversation_history"] = conversation_history + result = session.agent.run_conversation( + message, + **kwargs, + ) + result = _jsonable(result if isinstance(result, dict) else {"value": result}) + self._sync_result_tail_to_session_db( + session, + result, + conversation_history, + profile, + db_count_after_prepersist, + ) + with session.lock: + if isinstance(result.get("messages"), list): + session.history = result["messages"] + record.status = "interrupted" if result.get("interrupted") else "complete" + record.result = result + record.ended_at = time.time() + session.running = False + session.current_run_id = None + session.last_used_at = time.time() + except Exception as exc: + with session.lock: + record.status = "error" + record.error = str(exc) + record.result = {"error": str(exc), "traceback": traceback.format_exc()} + record.ended_at = time.time() + session.running = False + session.current_run_id = None + session.last_used_at = time.time() + finally: + with self._lock: + self._approval_handlers.pop(session.session_id, None) + try: + del self._run_context.session_id + except AttributeError: + pass + if approval_session_token is not None: + try: + from tools.approval import reset_current_session_key, unregister_gateway_notify + + if registered_gateway_approval_session is not None: + unregister_gateway_notify(registered_gateway_approval_session) + reset_current_session_key(approval_session_token) + except Exception: + pass + if exec_ask_scope_entered: + self._exit_exec_ask_scope() + + def interrupt(self, session_id: str, message: str | None = None) -> dict[str, Any]: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + raise KeyError(f"unknown session: {session_id}") + if not hasattr(session.agent, "interrupt"): + raise RuntimeError("agent does not support interrupt") + session.agent.interrupt(message) + deadline = time.time() + 10.0 + synced = False + while time.time() < deadline: + with session.lock: + if not session.running: + synced = True + break + time.sleep(0.05) + return {"status": "interrupted", "session_id": session_id, "synced": synced} + + def steer(self, session_id: str, text: str) -> dict[str, Any]: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + raise KeyError(f"unknown session: {session_id}") + if not hasattr(session.agent, "steer"): + raise RuntimeError("agent does not support steer") + accepted = bool(session.agent.steer(text)) + return {"status": "queued" if accepted else "rejected", "accepted": accepted, "text": text} + + def respond_approval(self, approval_id: str, choice: str) -> dict[str, Any]: + cleaned = str(choice or "deny").strip().lower() + if cleaned not in {"once", "session", "always", "deny"}: + cleaned = "deny" + with self._lock: + response_queue = self._approval_requests.get(approval_id) + if response_queue is None: + with self._lock: + gateway_session_id = self._gateway_approval_requests.pop(approval_id, None) + if gateway_session_id is None: + return {"approval_id": approval_id, "resolved": False, "choice": cleaned} + try: + from tools.approval import resolve_gateway_approval + + resolved = resolve_gateway_approval(gateway_session_id, cleaned) > 0 + except Exception: + resolved = False + self._append_event(gateway_session_id, { + "event": "approval.resolved", + "approval_id": approval_id, + "choice": cleaned, + }) + return {"approval_id": approval_id, "resolved": resolved, "choice": cleaned} + try: + response_queue.put_nowait(cleaned) + except queue.Full: + pass + return {"approval_id": approval_id, "resolved": True, "choice": cleaned} + + def respond_clarify(self, clarify_id: str, response: str) -> dict[str, Any]: + with self._lock: + response_queue = self._clarify_requests.get(clarify_id) + if response_queue is None: + return {"clarify_id": clarify_id, "resolved": False} + try: + response_queue.put_nowait(response) + except queue.Full: + pass + return {"clarify_id": clarify_id, "resolved": True} + + def get_history(self, session_id: str) -> dict[str, Any]: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + raise KeyError(f"unknown session: {session_id}") + with session.lock: + return {"session_id": session_id, "history": copy.deepcopy(session.history)} + + def dispatch_command(self, session_id: str, command: str, profile: str | None = None) -> dict[str, Any]: + raw = str(command or "").strip() + if raw.startswith("/"): + raw = raw[1:].strip() + if not raw: + raise ValueError("command is required") + + parts = raw.split(maxsplit=1) + name = parts[0].lstrip("/").strip().lower() + arg = parts[1] if len(parts) > 1 else "" + + with _profile_env(profile): + if name == "goal": + return self._dispatch_goal_command(session_id, arg) + if name == "subgoal": + return self._dispatch_subgoal_command(session_id, arg) + + try: + try: + from agent.skill_bundles import ( + build_bundle_invocation_message, + resolve_bundle_command_key, + ) + + bundle_key = resolve_bundle_command_key(name) + if bundle_key: + bundle_result = build_bundle_invocation_message( + bundle_key, + arg, + task_id=session_id, + ) + if bundle_result: + message, loaded_names, missing_names = bundle_result + return { + "session_id": session_id, + "command": name, + "handled": True, + "type": "bundle", + "message": message, + "loaded": loaded_names, + "missing": missing_names, + } + except ImportError: + pass + + from agent.skill_commands import ( + build_skill_invocation_message, + resolve_skill_command_key, + ) + + key = resolve_skill_command_key(name) + if key: + message = build_skill_invocation_message( + key, + arg, + task_id=session_id, + runtime_note=( + "If you need user clarification, call the clarify tool. " + "Do not output raw JSON question/choices payloads as the final response." + ), + ) + if message: + return { + "session_id": session_id, + "command": name, + "handled": True, + "type": "skill", + "message": message, + } + except Exception as exc: + raise RuntimeError(f"skill command dispatch failed: {exc}") from exc + + return { + "session_id": session_id, + "command": name, + "handled": False, + "message": f"not a supported bridge command: /{name}", + } + + def _goal_max_turns_from_config(self) -> int: + try: + from hermes_cli.config import load_config + + goals_cfg = (load_config() or {}).get("goals") or {} + return int(goals_cfg.get("max_turns", 20) or 20) + except Exception: + return 20 + + def _goal_manager(self, session_id: str): + from hermes_cli.goals import GoalManager + + return GoalManager( + session_id=session_id, + default_max_turns=self._goal_max_turns_from_config(), + ) + + def _dispatch_goal_command(self, session_id: str, arg: str) -> dict[str, Any]: + mgr = self._goal_manager(session_id) + clean_arg = str(arg or "").strip() + lower = clean_arg.lower() + + if not clean_arg or lower == "status": + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "goal_status", + "message": mgr.status_line(), + } + + if lower == "pause": + state = mgr.pause(reason="user-paused") + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "pause", + "message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.", + "clear_goal_continuations": True, + } + + if lower == "resume": + state = mgr.resume() + prompt = mgr.next_continuation_prompt() if state else None + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "resume", + "message": f"▶ Goal resumed: {state.goal}" if state else "No goal to resume.", + "kickoff_prompt": prompt, + "max_turns": state.max_turns if state else None, + } + + if lower in {"clear", "stop", "done"}: + had = mgr.has_goal() + mgr.clear() + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "clear", + "message": "✓ Goal cleared." if had else "No active goal.", + "clear_goal_continuations": True, + } + + try: + state = mgr.set(clean_arg) + except ValueError as exc: + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "set", + "message": f"Invalid goal: {exc}", + } + + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "set", + "message": ( + f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" + "After each turn, a judge model will check if the goal is done. " + "Hermes keeps working until it is, you pause/clear it, or the budget is exhausted." + ), + "kickoff_prompt": state.goal, + "max_turns": state.max_turns, + } + + def _dispatch_subgoal_command(self, session_id: str, arg: str) -> dict[str, Any]: + mgr = self._goal_manager(session_id) + clean_arg = str(arg or "").strip() + if not mgr.has_goal(): + return { + "session_id": session_id, + "command": "subgoal", + "handled": True, + "type": "goal", + "action": "subgoal", + "message": "No active goal. Set one with /goal .", + } + + if not clean_arg: + return { + "session_id": session_id, + "command": "subgoal", + "handled": True, + "type": "goal", + "action": "subgoal_status", + "message": f"{mgr.status_line()}\n{mgr.render_subgoals()}", + } + + tokens = clean_arg.split(None, 1) + verb = tokens[0].lower() + rest = tokens[1].strip() if len(tokens) > 1 else "" + + if verb == "remove": + if not rest: + message = "Usage: /subgoal remove " + else: + try: + idx = int(rest.split()[0]) + removed = mgr.remove_subgoal(idx) + message = f"✓ Removed subgoal {idx}: {removed}" + except ValueError: + message = "/subgoal remove: must be an integer (1-based index)." + except (IndexError, RuntimeError) as exc: + message = f"/subgoal remove: {exc}" + return { + "session_id": session_id, + "command": "subgoal", + "handled": True, + "type": "goal", + "action": "subgoal_remove", + "message": message, + } + + if verb == "clear": + try: + prev = mgr.clear_subgoals() + message = f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." if prev else "No subgoals to clear." + except RuntimeError as exc: + message = f"/subgoal clear: {exc}" + return { + "session_id": session_id, + "command": "subgoal", + "handled": True, + "type": "goal", + "action": "subgoal_clear", + "message": message, + } + + try: + text = mgr.add_subgoal(clean_arg) + idx = len(mgr.state.subgoals) if mgr.state else 0 + message = f"✓ Added subgoal {idx}: {text}" + except (ValueError, RuntimeError) as exc: + message = f"/subgoal: {exc}" + + return { + "session_id": session_id, + "command": "subgoal", + "handled": True, + "type": "goal", + "action": "subgoal_add", + "message": message, + } + + def evaluate_goal(self, session_id: str, final_response: str, profile: str | None = None) -> dict[str, Any]: + with _profile_env(profile): + mgr = self._goal_manager(session_id) + if not mgr.is_active(): + return { + "session_id": session_id, + "handled": True, + "active": False, + "should_continue": False, + "continuation_prompt": None, + "message": "", + "verdict": "inactive", + } + decision = mgr.evaluate_after_turn(str(final_response or ""), user_initiated=True) + return { + "session_id": session_id, + "handled": True, + "active": mgr.is_active(), + **decision, + } + + def pause_goal(self, session_id: str, reason: str, profile: str | None = None) -> dict[str, Any]: + with _profile_env(profile): + clean_reason = str(reason or "").strip() or "paused" + mgr = self._goal_manager(session_id) + state = mgr.pause(reason=clean_reason) + return { + "session_id": session_id, + "command": "goal", + "handled": True, + "type": "goal", + "action": "pause", + "active": mgr.is_active(), + "status": state.status if state else None, + "reason": clean_reason, + "message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.", + "clear_goal_continuations": True, + } + + def get_result(self, run_id: str) -> dict[str, Any]: + with self._lock: + record = self._runs.get(run_id) + if record is None: + raise KeyError(f"unknown run: {run_id}") + return { + "run_id": record.run_id, + "session_id": record.session_id, + "status": record.status, + "started_at": record.started_at, + "ended_at": record.ended_at, + "output": "".join(record.deltas), + "deltas": list(record.deltas), + "events": list(record.events), + "result": record.result, + "error": record.error, + } + + def get_output(self, run_id: str, cursor: int = 0, event_cursor: int = 0) -> dict[str, Any]: + with self._lock: + record = self._runs.get(run_id) + if record is None: + raise KeyError(f"unknown run: {run_id}") + cursor = max(0, int(cursor or 0)) + deltas = list(record.deltas) + next_cursor = len(deltas) + event_cursor = max(0, int(event_cursor or 0)) + events = list(record.events) + new_events = _jsonable(events[event_cursor:]) + next_event_cursor = len(events) + return { + "run_id": record.run_id, + "session_id": record.session_id, + "status": record.status, + "delta": "".join(deltas[cursor:]), + "cursor": next_cursor, + "output": "".join(deltas), + "done": record.status != "running", + "result": record.result if record.status != "running" else None, + "error": record.error, + "events": new_events, + "event_cursor": next_event_cursor, + } + + def destroy(self, session_id: str) -> dict[str, Any]: + with self._lock: + session = self._sessions.pop(session_id, None) + if session is None: + return {"session_id": session_id, "destroyed": False} + if session.running and hasattr(session.agent, "interrupt"): + try: + session.agent.interrupt("Session destroyed") + except Exception: + pass + return {"session_id": session_id, "destroyed": True} + + def destroy_all(self) -> dict[str, Any]: + with self._lock: + ids = list(self._sessions.keys()) + destroyed = [] + for sid in ids: + result = self.destroy(sid) + destroyed.append(result) + return {"destroyed": len(destroyed)} + + def status(self, session_id: str) -> dict[str, Any]: + with self._lock: + session = self._sessions.get(session_id) + if session is None: + return { + "session_id": session_id, + "exists": False, + "running": False, + "message_count": 0, + } + with session.lock: + return { + "session_id": session_id, + "exists": True, + "running": session.running, + "current_run_id": session.current_run_id, + "created_at": session.created_at, + "last_used_at": session.last_used_at, + "message_count": len(session.history), + "config": session.config, + } + + def list_sessions(self) -> dict[str, Any]: + with self._lock: + sessions = list(self._sessions.values()) + return { + "sessions": [ + { + "session_id": s.session_id, + "running": s.running, + "current_run_id": s.current_run_id, + "created_at": s.created_at, + "last_used_at": s.last_used_at, + "message_count": len(s.history), + "config": s.config, + } + for s in sessions + ] + } + + +class BridgeServer: + IDLE_TIMEOUT_SECONDS = 30 * 60 # 30 minutes + GC_INTERVAL_SECONDS = 60 # check every minute + + def __init__(self, endpoint: str) -> None: + self.endpoint = endpoint + self.pool = AgentPool() + self._stop = threading.Event() + self._last_gc = time.time() + + def handle(self, req: dict[str, Any]) -> dict[str, Any]: + action = str(req.get("action") or "").strip() + if not action: + raise ValueError("action is required") + + if action == "ping": + with self.pool._lock: + sessions = list(self.pool._sessions.values()) + running_sessions = sum(1 for session in sessions if session.running) + return { + "pong": True, + "time": time.time(), + "pid": os.getpid(), + "agent_root": str(_agent_root()), + "profile": _worker_profile() or "default", + "hermes_home": str(_hermes_home()), + "session_count": len(sessions), + "running_session_count": running_sessions, + } + + if action == "chat": + session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex + message = req.get("message", req.get("input", "")) + storage_message = req.get("storage_message") + instructions = req.get("instructions") or req.get("system_message") + conversation_history = req.get("conversation_history") + profile = req.get("profile") + model = req.get("model") + provider = req.get("provider") + source = req.get("source") + record = self.pool.start_chat( + session_id, + message, + storage_message, + instructions, + conversation_history, + profile, + bool(req.get("force_compress")), + model, + provider, + source, + ) + if req.get("wait"): + timeout = float(req.get("timeout", 0) or 0) + deadline = time.time() + timeout if timeout > 0 else None + while record.status == "running": + if deadline is not None and time.time() >= deadline: + break + time.sleep(0.05) + return self.pool.get_result(record.run_id) + return {"run_id": record.run_id, "session_id": session_id, "status": record.status} + + if action == "context_estimate": + session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex + messages = req.get("messages") or req.get("conversation_history") or [] + if not isinstance(messages, list): + raise ValueError("messages must be a list") + return self.pool.estimate_context( + session_id, + messages=messages, + instructions=req.get("instructions") or req.get("system_message"), + profile=req.get("profile"), + model=req.get("model"), + provider=req.get("provider"), + ) + + if action == "get_result": + return self.pool.get_result(str(req.get("run_id") or "")) + + if action == "get_output": + return self.pool.get_output( + str(req.get("run_id") or ""), + int(req.get("cursor") or 0), + int(req.get("event_cursor") or 0), + ) + + if action == "interrupt": + return self.pool.interrupt(str(req.get("session_id") or ""), req.get("message")) + + if action == "steer": + text = str(req.get("text") or req.get("message") or "").strip() + if not text: + raise ValueError("text is required") + return self.pool.steer(str(req.get("session_id") or ""), text) + + if action == "approval_respond": + approval_id = str(req.get("approval_id") or "").strip() + if not approval_id: + raise ValueError("approval_id is required") + return self.pool.respond_approval(approval_id, str(req.get("choice") or "deny")) + + if action == "clarify_respond": + clarify_id = str(req.get("clarify_id") or "").strip() + if not clarify_id: + raise ValueError("clarify_id is required") + response = str(req.get("response") or "").strip() + return self.pool.respond_clarify(clarify_id, response) + + if action == "compression_respond": + request_id = str(req.get("request_id") or "").strip() + if not request_id: + raise ValueError("request_id is required") + messages = req.get("messages") + if messages is not None and not isinstance(messages, list): + raise ValueError("messages must be a list") + return self.pool.respond_compression( + request_id, + messages=messages, + system_message=req.get("system_message"), + error=req.get("error"), + ) + + if action == "get_history": + return self.pool.get_history(str(req.get("session_id") or "")) + + if action == "command": + session_id = str(req.get("session_id") or "").strip() + if not session_id: + raise ValueError("session_id is required") + return self.pool.dispatch_command( + session_id, + str(req.get("command") or ""), + req.get("profile"), + ) + + if action == "goal_evaluate": + session_id = str(req.get("session_id") or "").strip() + if not session_id: + raise ValueError("session_id is required") + return self.pool.evaluate_goal( + session_id, + str(req.get("final_response") or ""), + req.get("profile"), + ) + + if action == "goal_pause": + session_id = str(req.get("session_id") or "").strip() + if not session_id: + raise ValueError("session_id is required") + return self.pool.pause_goal( + session_id, + str(req.get("reason") or ""), + req.get("profile"), + ) + + if action == "status": + return self.pool.status(str(req.get("session_id") or "")) + + if action == "destroy": + return self.pool.destroy(str(req.get("session_id") or "")) + + if action == "destroy_all": + return self.pool.destroy_all() + + if action == "list": + return self.pool.list_sessions() + + if action == "shutdown": + self._stop.set() + return {"status": "shutting_down"} + + # ───── MCP Management (forwarded from broker) ───── + if action.startswith("mcp_"): + return self._handle_mcp_action(action, req, req.get("profile")) + + raise ValueError(f"unknown action: {action}") + + # ───── MCP Management Methods (for BridgeServer worker process) ───── + + def _read_mcp_config(self, profile=None): + """Read config.yaml for the given profile.""" + import yaml + config_path = _profile_home(profile) / "config.yaml" + try: + with open(config_path, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + + def _save_mcp_config(self, cfg, profile=None): + """Save config.yaml for the given profile using atomic write.""" + import yaml + from utils import atomic_yaml_write + config_path = _profile_home(profile) / "config.yaml" + config_path.parent.mkdir(parents=True, exist_ok=True) + try: + atomic_yaml_write(config_path, cfg, sort_keys=False) + except Exception as e: + raise RuntimeError(f"Failed to save config to {config_path}: {e}") + + @staticmethod + def _run_mcp_discovery_bg(discover_fn, profile: str | None = None): + """Run MCP discovery in a background thread to avoid blocking.""" + def _bg(): + original = _apply_profile_env(profile) + try: + discover_fn() + except Exception as e: + print(f"[mcp-discovery-bg] failed: {e}", file=sys.stderr, flush=True) + finally: + _restore_profile_env(original) + threading.Thread(target=_bg, daemon=True).start() + + def _handle_mcp_action(self, action: str, req: dict[str, Any], profile: str | None = None) -> dict[str, Any]: + """Handle MCP management actions in worker process.""" + try: + from tools.mcp_tool import discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock + except ImportError: + return {"error": "MCP tool module not available", "ok": False} + + if profile is None: + profile = _worker_profile() or "default" + + dispatch = { + "mcp_list": lambda: self._mcp_list(profile, _servers, _lock), + "mcp_server_add": lambda: self._mcp_server_add(req, profile, discover_mcp_tools), + "mcp_server_update": lambda: self._mcp_server_update(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools), + "mcp_server_remove": lambda: self._mcp_server_remove(req, profile, _servers, _lock, _run_on_mcp_loop), + "mcp_server_test": lambda: self._mcp_server_test(req, _servers, _lock), + "mcp_tools_list": lambda: self._mcp_tools_list(req, profile, _servers, _lock), + "mcp_reload": lambda: self._mcp_reload(req, profile, _servers, _lock, _run_on_mcp_loop, discover_mcp_tools, register_mcp_servers), + } + handler = dispatch.get(action) + if handler: + return handler() + return {"error": f"unknown MCP action: {action}", "ok": False} + + # ───── MCP sub-handlers ───── + + def _build_server_entry(self, name: str, cfg: dict, connected: bool = False, + tools_count: int = 0, registered_count: int = 0, + raw_names: list | None = None, registered_names: list | None = None, + tool_details: list | None = None, + error: str | None = None) -> dict[str, Any]: + """Build a normalized server entry dict for API responses.""" + transport = "http" if cfg.get("url") else "stdio" + return { + "name": name, + "transport": transport, + "connected": connected, + "tools": tools_count, + "tools_registered": registered_count, + "tool_names": raw_names or [], + "tool_names_registered": registered_names or [], + "tool_details": tool_details or [], + "error": error, + "raw_config": cfg if isinstance(cfg, dict) else {}, + } + + def _mcp_list(self, profile: str, _servers, _lock) -> dict[str, Any]: + servers = [] + total_tools = 0 + + config = self._read_mcp_config(profile) + mcp_configs = config.get("mcp_servers", {}) or {} if config else {} + profile_server_names = set(mcp_configs.keys()) + + with _lock: + server_snapshot = list(_servers.items()) + for name, task in server_snapshot: + if name not in profile_server_names: + continue + raw_tool_names = [] + try: + for mcp_tool in getattr(task, "_tools", []): + if hasattr(mcp_tool, "name"): + raw_tool_names.append(mcp_tool.name) + except Exception: + pass + registered = list(getattr(task, "_registered_tool_names", None) or []) + if not registered: + registered = list(raw_tool_names) + t = getattr(task, "_task", None) + connected = bool(t and not t.done()) + err = getattr(task, "_error", None) + cfg = getattr(task, "_config", {}) + # Build filtered tool_details (name + description) for card display + srv_cfg = mcp_configs.get(name, {}) if isinstance(mcp_configs.get(name), dict) else {} + tools_filter = srv_cfg.get("tools") if isinstance(srv_cfg.get("tools"), dict) else {} + has_include_filter = "include" in tools_filter + has_exclude_filter = "exclude" in tools_filter + include_set = set(tools_filter.get("include") or []) + exclude_set = set(tools_filter.get("exclude") or []) + tool_details = [] + try: + for mcp_tool in getattr(task, "_tools", []): + tname = getattr(mcp_tool, "name", "?") + if has_include_filter and tname not in include_set: + continue + if has_exclude_filter and tname in exclude_set: + continue + tool_details.append({ + "name": tname, + "description": getattr(mcp_tool, "description", ""), + }) + except Exception: + pass + entry = self._build_server_entry( + name, cfg, connected=connected, + tools_count=len(raw_tool_names), registered_count=len(registered), + raw_names=raw_tool_names, registered_names=registered, + tool_details=tool_details, + error=str(err) if err else None, + ) + servers.append(entry) + total_tools += len(registered) + + # Add servers from config that are not in runtime _servers + if config: + existing = {s["name"] for s in servers} + for name, cfg in mcp_configs.items(): + if name not in existing and isinstance(cfg, dict): + servers.append(self._build_server_entry(name, cfg)) + + return {"servers": servers, "total_tools": total_tools, "ok": True} + + def _mcp_server_add(self, req: dict, profile: str, discover_mcp_tools) -> dict[str, Any]: + name = str(req.get("name") or "").strip() + config = req.get("config", {}) + if not name or not isinstance(config, dict): + return {"error": "name and config are required", "ok": False} + + cfg = self._read_mcp_config(profile) + if not cfg: + return {"error": "config.yaml not found", "ok": False} + + mcp_servers = cfg.setdefault("mcp_servers", {}) + if not isinstance(mcp_servers, dict): + mcp_servers = {} + cfg["mcp_servers"] = mcp_servers + if name in mcp_servers: + return {"error": f"server '{name}' already exists, use update instead", "ok": False} + mcp_servers[name] = config + + self._save_mcp_config(cfg, profile) + self._run_mcp_discovery_bg(discover_mcp_tools, profile) + + return {"ok": True, "name": name} + + @staticmethod + def _shutdown_mcp_server(name: str, _servers, _lock, run_on_mcp_loop) -> bool: + with _lock: + task = _servers.get(name) + if task is None: + return False + + try: + run_on_mcp_loop(lambda: task.shutdown(), timeout=15) + except Exception as e: + print(f"[mcp-server-shutdown] failed for {name}: {e}", file=sys.stderr, flush=True) + finally: + with _lock: + if _servers.get(name) is task: + _servers.pop(name, None) + return True + + def _shutdown_mcp_servers(self, names: list[str], _servers, _lock, run_on_mcp_loop) -> int: + stopped = 0 + for name in names: + if self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop): + stopped += 1 + return stopped + + def _mcp_server_update(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop, discover_mcp_tools) -> dict[str, Any]: + name = str(req.get("name") or "").strip() + config = req.get("config", {}) + if not name or not isinstance(config, dict): + return {"error": "name and config are required", "ok": False} + + cfg = self._read_mcp_config(profile) + if not cfg: + return {"error": "config.yaml not found", "ok": False} + + mcp_servers = cfg.setdefault("mcp_servers", {}) + if not isinstance(mcp_servers, dict): + mcp_servers = {} + cfg["mcp_servers"] = mcp_servers + if name not in mcp_servers: + return {"error": f"server \'{name}\' not found in config", "ok": False} + + mcp_servers[name] = config + + self._save_mcp_config(cfg, profile) + + self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop) + + self._run_mcp_discovery_bg(discover_mcp_tools, profile) + + return {"ok": True} + + def _mcp_server_remove(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop) -> dict[str, Any]: + name = str(req.get("name") or "").strip() + if not name: + return {"error": "name is required", "ok": False} + + # Write config first, then remove from memory + cfg = self._read_mcp_config(profile) + if cfg: + mcp_servers = cfg.get("mcp_servers", {}) + if isinstance(mcp_servers, dict) and name in mcp_servers: + del mcp_servers[name] + self._save_mcp_config(cfg, profile) + + self._shutdown_mcp_server(name, _servers, _lock, run_on_mcp_loop) + + return {"ok": True} + + def _mcp_server_test(self, req: dict, _servers, _lock) -> dict[str, Any]: + name = str(req.get("name") or "").strip() + if not name: + return {"error": "name is required", "ok": False} + + with _lock: + task = _servers.get(name) + if not task: + return {"error": f"server \'{name}\' is not connected", "ok": False} + + tool_names = [] + try: + for mcp_tool in getattr(task, "_tools", []): + if hasattr(mcp_tool, "name"): + tool_names.append(mcp_tool.name) + except Exception as e: + return {"error": f"failed to list tools: {e}", "ok": False} + + return {"ok": True, "tools": tool_names} + + def _mcp_tools_list(self, req: dict, profile: str, _servers, _lock) -> dict[str, Any]: + server_filter = str(req.get("server") or "").strip() or None + raw_mode = bool(req.get("raw")) # Return unfiltered tools for visibility management + results = [] + + config = self._read_mcp_config(profile) + mcp_configs = config.get("mcp_servers", {}) or {} if config else {} + profile_server_names = set(mcp_configs.keys()) + + with _lock: + server_snapshot = list(_servers.items()) + for sname, task in server_snapshot: + if sname not in profile_server_names: + continue + if server_filter and sname != server_filter: + continue + registered = set(getattr(task, "_registered_tool_names", None) or []) + tools = [] + srv_cfg = mcp_configs.get(sname, {}) if isinstance(mcp_configs.get(sname), dict) else {} + tools_filter = srv_cfg.get("tools") if isinstance(srv_cfg.get("tools"), dict) else {} + has_include_filter = "include" in tools_filter + has_exclude_filter = "exclude" in tools_filter + include_set = set(tools_filter.get("include") or []) + exclude_set = set(tools_filter.get("exclude") or []) + def _should_include(tn): + if raw_mode: + return True # Skip filter in raw mode + if has_include_filter: + return tn in include_set + if has_exclude_filter: + return tn not in exclude_set + return True + try: + for mcp_tool in getattr(task, "_tools", []): + tname = getattr(mcp_tool, "name", "?") + if not _should_include(tname): + continue + tools.append({ + "name": tname, + "description": getattr(mcp_tool, "description", ""), + "input_schema": getattr(mcp_tool, "inputSchema", {}), + }) + except Exception as e: + results.append({"server": sname, "tools": [], "error": str(e)}) + continue + results.append({"server": sname, "tools": tools}) + + return {"ok": True, "results": results} + + def _mcp_reload(self, req: dict, profile: str, _servers, _lock, run_on_mcp_loop, + discover_mcp_tools, register_mcp_servers) -> dict[str, Any]: + target = str(req.get("server") or "").strip() or None + + config = self._read_mcp_config(profile) + mcp_configs = config.get("mcp_servers", {}) or {} if config else {} + profile_server_names = set(mcp_configs.keys()) + + if target and target not in mcp_configs: + return {"error": "server \'%s\' not found in config" % target, "ok": False} + + if target: + self._shutdown_mcp_server(target, _servers, _lock, run_on_mcp_loop) + else: + self._shutdown_mcp_servers(list(profile_server_names), _servers, _lock, run_on_mcp_loop) + + # Run discovery in background to avoid blocking the request + if target: + def _reload_single(): + original = _apply_profile_env(profile) + try: + server_config = {target: mcp_configs.get(target, {})} + register_mcp_servers(server_config) + finally: + _restore_profile_env(original) + self._run_mcp_discovery_bg(_reload_single, profile) + else: + self._run_mcp_discovery_bg(discover_mcp_tools, profile) + + return {"ok": True, "message": "MCP servers reloaded"} + + def _make_server_socket(self) -> socket.socket: + return _make_listen_socket(self.endpoint) + + def _read_request(self, conn: socket.socket) -> dict[str, Any]: + return _read_json_request(conn) + + def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None: + _write_json_response(conn, resp) + + def _gc_idle_sessions(self) -> None: + """Destroy sessions idle longer than IDLE_TIMEOUT_SECONDS.""" + now = time.time() + if now - self._last_gc < self.GC_INTERVAL_SECONDS: + return + self._last_gc = now + with self.pool._lock: + idle_ids = [ + sid for sid, s in self.pool._sessions.items() + if not s.running and now - s.last_used_at > self.IDLE_TIMEOUT_SECONDS + ] + for sid in idle_ids: + self.pool.destroy(sid) + + def serve_forever(self) -> None: + server = self._make_server_socket() + restore_signals = _install_stop_signal_handlers(self._stop) + _start_parent_process_watchdog( + _positive_int(os.environ.get("HERMES_AGENT_BRIDGE_BROKER_PID")), + self._stop, + f"worker:{_worker_profile() or 'default'}", + ) + try: + server.listen(16) + server.settimeout(0.2) + print(json.dumps({"event": "ready", "endpoint": self.endpoint}), flush=True) + + while not self._stop.is_set(): + conn: socket.socket | None = None + try: + try: + conn, _addr = server.accept() + except socket.timeout: + self._gc_idle_sessions() + continue + try: + req = self._read_request(conn) + data = self.handle(req) + resp = {"ok": True, **_jsonable(data)} + except Exception as exc: + resp = { + "ok": False, + "error": str(exc), + "error_type": exc.__class__.__name__, + } + self._write_response(conn, resp) + except KeyboardInterrupt: + break + except Exception as exc: + print(f"[hermes-bridge] server loop error: {exc}", file=sys.stderr, flush=True) + finally: + if conn is not None: + try: + conn.close() + except OSError: + pass + finally: + restore_signals() + server.close() + if self.endpoint.startswith("ipc://"): + try: + Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True) + except OSError: + pass + + +class WorkerProcess: + STARTUP_TIMEOUT_SECONDS = 120 + REQUEST_TIMEOUT_SECONDS = 120 + + def __init__(self, key: str, profile: str, endpoint: str, agent_root: str | None, hermes_home: str | None) -> None: + self.key = key or profile or "default" + self.profile = profile or "default" + self.endpoint = endpoint + self.agent_root = agent_root + self.hermes_home = hermes_home + self.process: subprocess.Popen[str] | None = None + self.last_used_at = time.time() + self._lock = threading.RLock() + + @property + def running(self) -> bool: + return self.process is not None and self.process.poll() is None + + @property + def pid(self) -> int | None: + return self.process.pid if self.process is not None else None + + def start(self) -> None: + with self._lock: + if self.running: + return + args = [ + sys.executable, + str(Path(__file__).resolve()), + "--endpoint", + self.endpoint, + "--worker-profile", + self.profile, + ] + if self.agent_root: + args.extend(["--agent-root", self.agent_root]) + if self.hermes_home: + args.extend(["--hermes-home", self.hermes_home]) + + env = { + **os.environ, + "HERMES_AGENT_BRIDGE_ENDPOINT": self.endpoint, + "HERMES_AGENT_BRIDGE_WORKER_PROFILE": self.profile, + "HERMES_AGENT_BRIDGE_BROKER_PID": str(os.getpid()), + } + self.process = subprocess.Popen( + args, + env=env, + cwd=os.getcwd(), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + **_hidden_subprocess_kwargs(), + ) + self._pipe_stderr() + self._wait_ready() + + def _pipe_stderr(self) -> None: + proc = self.process + if proc is None or proc.stderr is None: + return + + def run() -> None: + assert proc.stderr is not None + for line in proc.stderr: + text = line.rstrip() + if text: + print(f"[hermes-bridge-worker:{self.key}] {text}", file=sys.stderr, flush=True) + + threading.Thread(target=run, daemon=True, name=f"hermes-bridge-worker-stderr-{self.key}").start() + + def _wait_ready(self) -> None: + proc = self.process + if proc is None or proc.stdout is None: + raise RuntimeError(f"profile worker {self.key} did not start") + lines: queue.Queue[str | None] = queue.Queue() + ready_event = threading.Event() + + def read_stdout() -> None: + assert proc.stdout is not None + try: + for line in proc.stdout: + if ready_event.is_set(): + text = line.rstrip() + if text: + print(f"[hermes-bridge-worker:{self.key}] {text}", file=sys.stderr, flush=True) + else: + lines.put(line) + finally: + lines.put(None) + + threading.Thread(target=read_stdout, daemon=True, name=f"hermes-bridge-worker-stdout-{self.key}").start() + deadline = time.time() + self.STARTUP_TIMEOUT_SECONDS + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError(f"profile worker {self.key} exited before ready") + try: + line = lines.get(timeout=0.1) + except queue.Empty: + continue + if line is None: + time.sleep(0.05) + continue + text = line.strip() + if text: + print(f"[hermes-bridge-worker:{self.key}] {text}", file=sys.stderr, flush=True) + try: + data = json.loads(text) + if data.get("event") == "ready": + ready_event.set() + return + except Exception: + pass + self.stop() + raise RuntimeError(f"profile worker {self.key} did not become ready within {self.STARTUP_TIMEOUT_SECONDS}s") + + def stop(self) -> None: + with self._lock: + proc = self.process + self.process = None + if proc is None: + return + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=3) + if self.endpoint.startswith("ipc://"): + try: + Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True) + except OSError: + pass + + def request(self, req: dict[str, Any], timeout: float | None = None) -> dict[str, Any]: + self.start() + self.last_used_at = time.time() + request_timeout = timeout if timeout is not None and timeout > 0 else self.REQUEST_TIMEOUT_SECONDS + return _send_bridge_request(self.endpoint, req, request_timeout) + + +def _worker_endpoint(key: str, namespace: str | None = None) -> str: + namespace_key = f"{namespace or ''}\0{key}" + safe = hashlib.sha256(namespace_key.encode("utf-8")).hexdigest()[:16] + transport = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_TRANSPORT", "").strip().lower() + use_tcp = transport == "tcp" or (transport not in {"ipc", "unix"} and os.name == "nt") + if use_tcp: + port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780")) + return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}" + root = Path(tempfile.gettempdir()) / "hermes-agent-bridge-workers" + return f"ipc://{root / f'{safe}.sock'}" + + +def _connect_bridge_socket(endpoint: str, timeout: float) -> socket.socket: + if endpoint.startswith("ipc://"): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(endpoint.removeprefix("ipc://")) + return sock + parsed = urlparse(endpoint) + if parsed.scheme != "tcp": + raise RuntimeError(f"unsupported endpoint scheme: {endpoint}") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((parsed.hostname or "127.0.0.1", int(parsed.port or 0))) + return sock + + +def _send_bridge_request(endpoint: str, req: dict[str, Any], timeout: float) -> dict[str, Any]: + sock = _connect_bridge_socket(endpoint, timeout) + try: + sock.sendall(_json_line_bytes(req)) + chunks: list[bytes] = [] + while True: + chunk = sock.recv(65536) + if not chunk: + break + chunks.append(chunk) + if b"\n" in chunk: + break + line = b"".join(chunks).split(b"\n", 1)[0].strip() + if not line: + raise RuntimeError("worker closed without a response") + resp = json.loads(line.decode("utf-8")) + if not resp.get("ok"): + raise RuntimeError(str(resp.get("error") or "worker request failed")) + return resp + finally: + try: + sock.close() + except OSError: + pass + + +def _tcp_endpoint_port(endpoint: str) -> int | None: + parsed = urlparse(endpoint) + if parsed.scheme != "tcp": + return None + try: + port = int(parsed.port or 0) + return port if port > 0 else None + except (TypeError, ValueError): + return None + + +def _platform_text_encoding() -> str: + getencoding = getattr(locale, "getencoding", None) + if callable(getencoding): + return getencoding() or "utf-8" + return locale.getpreferredencoding(False) or "utf-8" + + +def _windows_listening_pids_on_port(port: int) -> list[int]: + if os.name != "nt": + return [] + try: + result = subprocess.run( + ["netstat.exe", "-ano", "-p", "tcp"], + check=False, + capture_output=True, + text=True, + encoding=_platform_text_encoding(), + errors="ignore", + timeout=5, + **_hidden_subprocess_kwargs(), + ) + except Exception: + return [] + stdout = result.stdout or "" + pids: set[int] = set() + for line in stdout.splitlines(): + parts = line.strip().split() + if len(parts) < 5: + continue + proto, local_address, _remote_address, state, pid_raw = parts[:5] + if proto.upper() != "TCP" or state.upper() != "LISTENING": + continue + if not local_address.endswith(f":{port}"): + continue + try: + pid = int(pid_raw) + except ValueError: + continue + if pid > 0 and pid != os.getpid(): + pids.add(pid) + return sorted(pids) + + +def _kill_windows_endpoint_occupants(endpoint: str) -> None: + if os.name != "nt": + return + port = _tcp_endpoint_port(endpoint) + if not port: + return + for pid in _windows_listening_pids_on_port(port): + try: + print( + f"[hermes-bridge] killing stale process tree pid={pid} port={port}", + file=sys.stderr, + flush=True, + ) + subprocess.run( + ["taskkill.exe", "/PID", str(pid), "/T", "/F"], + check=False, + capture_output=True, + text=True, + timeout=10, + **_hidden_subprocess_kwargs(), + ) + except Exception as exc: + print( + f"[hermes-bridge] failed to kill stale process pid={pid}: {exc}", + file=sys.stderr, + flush=True, + ) + deadline = time.time() + 3 + while time.time() < deadline: + if not _windows_listening_pids_on_port(port): + return + time.sleep(0.1) + + +def _make_listen_socket(endpoint: str) -> socket.socket: + _kill_windows_endpoint_occupants(endpoint) + if endpoint.startswith("ipc://"): + if not hasattr(socket, "AF_UNIX"): + raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform") + sock_path = Path(endpoint.removeprefix("ipc://")) + sock_path.parent.mkdir(parents=True, exist_ok=True) + try: + sock_path.unlink(missing_ok=True) + except OSError: + pass + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(sock_path)) + return server + + parsed = urlparse(endpoint) + if parsed.scheme != "tcp": + raise RuntimeError(f"unsupported endpoint scheme: {endpoint}") + host = parsed.hostname or "127.0.0.1" + port = int(parsed.port or 0) + if port <= 0: + raise RuntimeError(f"tcp endpoint requires a port: {endpoint}") + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((host, port)) + return server + + +def _read_json_request(conn: socket.socket) -> dict[str, Any]: + chunks: list[bytes] = [] + while True: + chunk = conn.recv(65536) + if not chunk: + break + chunks.append(chunk) + if b"\n" in chunk: + break + if not chunks: + raise RuntimeError("empty request") + line = b"".join(chunks).split(b"\n", 1)[0].strip() + if not line: + raise RuntimeError("empty request") + return json.loads(line.decode("utf-8")) + + +def _write_json_response(conn: socket.socket, resp: dict[str, Any]) -> None: + conn.sendall(_json_line_bytes(resp)) + + +class BridgeBroker: + IDLE_TIMEOUT_SECONDS = 30 * 60 + GC_INTERVAL_SECONDS = 60 + + def __init__(self, endpoint: str, agent_root: str | None = None, hermes_home: str | None = None) -> None: + self.endpoint = endpoint + self.agent_root = agent_root + self.hermes_home = hermes_home + self._workers: dict[str, WorkerProcess] = {} + self._run_profile: dict[str, str] = {} + self._run_worker_key: dict[str, str] = {} + self._running_run_profile: dict[str, str] = {} + self._running_run_worker_key: dict[str, str] = {} + self._session_profile: dict[str, str] = {} + self._session_worker_key: dict[str, str] = {} + self._approval_profile: dict[str, str] = {} + self._approval_worker_key: dict[str, str] = {} + self._clarify_profile: dict[str, str] = {} + self._clarify_worker_key: dict[str, str] = {} + self._compression_profile: dict[str, str] = {} + self._compression_worker_key: dict[str, str] = {} + self._lock = threading.RLock() + self._stop = threading.Event() + self._last_gc = time.time() + + def _normalize_profile(self, value: Any) -> str: + profile = str(value or "").strip() + return profile or "default" + + def _normalize_worker_key(self, profile: str, value: Any = None) -> str: + worker_key = str(value or "").strip() + return worker_key or profile + + def _worker_for_profile(self, profile: str, worker_key: str | None = None) -> WorkerProcess: + profile = self._normalize_profile(profile) + key = self._normalize_worker_key(profile, worker_key) + with self._lock: + worker = self._workers.get(key) + if worker is None: + worker = WorkerProcess(key, profile, _worker_endpoint(key, self.endpoint), self.agent_root, self.hermes_home) + self._workers[key] = worker + return worker + + def _route_for_run(self, run_id: str) -> tuple[str, str | None]: + with self._lock: + profile = self._run_profile.get(run_id) + worker_key = self._run_worker_key.get(run_id) + if not profile: + raise KeyError(f"unknown run: {run_id}") + return profile, worker_key + + def _route_for_session(self, session_id: str, fallback_profile: Any = None, worker_key: Any = None) -> tuple[str, str | None]: + with self._lock: + profile = self._session_profile.get(session_id) + stored_worker_key = self._session_worker_key.get(session_id) + if not profile: + fallback = self._normalize_profile(fallback_profile) + if fallback_profile is not None and fallback: + return fallback, self._normalize_worker_key(fallback, worker_key) + raise KeyError(f"unknown session: {session_id}") + return profile, self._normalize_worker_key(profile, worker_key) if worker_key is not None else stored_worker_key + + def _record_response_routes(self, profile: str, worker_key: str, resp: dict[str, Any]) -> None: + run_id = str(resp.get("run_id") or "") + session_id = str(resp.get("session_id") or "") + with self._lock: + if run_id: + self._run_profile[run_id] = profile + self._run_worker_key[run_id] = worker_key + if resp.get("status") == "running": + self._running_run_profile[run_id] = profile + self._running_run_worker_key[run_id] = worker_key + else: + self._running_run_profile.pop(run_id, None) + self._running_run_worker_key.pop(run_id, None) + if session_id: + self._session_profile[session_id] = profile + self._session_worker_key[session_id] = worker_key + for event in resp.get("events") or []: + if not isinstance(event, dict): + continue + approval_id = str(event.get("approval_id") or "") + if approval_id: + self._approval_profile[approval_id] = profile + self._approval_worker_key[approval_id] = worker_key + clarify_id = str(event.get("clarify_id") or "") + if clarify_id: + self._clarify_profile[clarify_id] = profile + self._clarify_worker_key[clarify_id] = worker_key + request_id = str(event.get("request_id") or "") + if event.get("event") == "bridge.compression.requested" and request_id: + self._compression_profile[request_id] = profile + self._compression_worker_key[request_id] = worker_key + if event.get("event") in {"bridge.compression.completed", "bridge.compression.failed"} and request_id: + self._compression_profile.pop(request_id, None) + self._compression_worker_key.pop(request_id, None) + + def stop(self) -> None: + self._stop.set() + with self._lock: + workers = list(self._workers.values()) + self._workers.clear() + self._run_profile.clear() + self._run_worker_key.clear() + self._running_run_profile.clear() + self._running_run_worker_key.clear() + self._session_profile.clear() + self._session_worker_key.clear() + self._approval_profile.clear() + self._approval_worker_key.clear() + self._clarify_profile.clear() + self._clarify_worker_key.clear() + self._compression_profile.clear() + self._compression_worker_key.clear() + for worker in workers: + worker.stop() + + def _forward(self, profile: str, req: dict[str, Any], worker_key: str | None = None) -> dict[str, Any]: + profile = self._normalize_profile(profile) + key = self._normalize_worker_key(profile, worker_key) + worker = self._worker_for_profile(profile, key) + forwarded = dict(req) + forwarded["profile"] = profile + forwarded.pop("worker_key", None) + try: + resp = worker.request(forwarded, self._worker_request_timeout(req)) + self._record_response_routes(profile, key, resp) + return resp + except RuntimeError as e: + # Worker returned ok=false or connection error — return error response + return {"ok": False, "error": str(e)} + + def _worker_request_timeout(self, req: dict[str, Any]) -> float: + try: + timeout = float(req.get("timeout", 0) or 0) + except (TypeError, ValueError): + timeout = 0 + if timeout <= 0: + return WorkerProcess.REQUEST_TIMEOUT_SECONDS + return max(WorkerProcess.REQUEST_TIMEOUT_SECONDS, timeout + 10) + + def handle(self, req: dict[str, Any]) -> dict[str, Any]: + action = str(req.get("action") or "").strip() + if not action: + raise ValueError("action is required") + + if action == "ping": + with self._lock: + worker_details = { + key: { + "running": worker.running, + "pid": worker.pid, + "endpoint": worker.endpoint, + "profile": getattr(worker, "profile", key), + "last_used_at": worker.last_used_at, + } + for key, worker in self._workers.items() + } + workers = {key: details["running"] for key, details in worker_details.items()} + sessions_by_profile: dict[str, int] = {} + for profile in self._session_profile.values(): + sessions_by_profile[profile] = sessions_by_profile.get(profile, 0) + 1 + running_sessions_by_profile: dict[str, int] = {} + for profile in self._running_run_profile.values(): + running_sessions_by_profile[profile] = running_sessions_by_profile.get(profile, 0) + 1 + active_sessions = len(self._session_profile) + running_sessions = len(self._running_run_profile) + return { + "pong": True, + "time": time.time(), + "mode": "broker", + "broker": { + "pid": os.getpid(), + "endpoint": self.endpoint, + }, + "workers": workers, + "worker_details": worker_details, + "active_sessions": active_sessions, + "running_sessions": running_sessions, + "sessions_by_profile": sessions_by_profile, + "running_sessions_by_profile": running_sessions_by_profile, + } + + if action == "worker_ping": + profile = self._normalize_profile(req.get("profile")) + worker_key = self._normalize_worker_key(profile, req.get("worker_key")) + resp = self._forward(profile, {"action": "ping"}, worker_key) + resp["worker_profile"] = profile + resp["worker_key"] = worker_key + return resp + + if action == "chat": + profile = self._normalize_profile(req.get("profile")) + return self._forward(profile, req, self._normalize_worker_key(profile, req.get("worker_key"))) + + if action == "context_estimate": + profile = self._normalize_profile(req.get("profile")) + return self._forward(profile, req, self._normalize_worker_key(profile, req.get("worker_key"))) + + if action in {"get_result", "get_output"}: + profile, worker_key = self._route_for_run(str(req.get("run_id") or "")) + return self._forward(profile, req, worker_key) + + if action in {"interrupt", "steer", "command", "goal_evaluate", "goal_pause", "status", "get_history", "destroy"}: + session_id = str(req.get("session_id") or "") + profile, worker_key = self._route_for_session(session_id, req.get("profile"), req.get("worker_key") if "worker_key" in req else None) + resp = self._forward(profile, req, worker_key) + if action == "destroy": + with self._lock: + self._session_profile.pop(session_id, None) + self._session_worker_key.pop(session_id, None) + return resp + + if action == "approval_respond": + approval_id = str(req.get("approval_id") or "").strip() + if not approval_id: + raise ValueError("approval_id is required") + with self._lock: + profile = self._approval_profile.get(approval_id) + worker_key = self._approval_worker_key.get(approval_id) + if not profile: + raise KeyError(f"unknown approval request: {approval_id}") + return self._forward(profile, req, worker_key) + + if action == "clarify_respond": + clarify_id = str(req.get("clarify_id") or "").strip() + if not clarify_id: + raise ValueError("clarify_id is required") + with self._lock: + profile = self._clarify_profile.get(clarify_id) + worker_key = self._clarify_worker_key.get(clarify_id) + if not profile: + raise KeyError(f"unknown clarify request: {clarify_id}") + return self._forward(profile, req, worker_key) + + if action == "compression_respond": + request_id = str(req.get("request_id") or "").strip() + if not request_id: + raise ValueError("request_id is required") + with self._lock: + profile = self._compression_profile.get(request_id) + worker_key = self._compression_worker_key.get(request_id) + if not profile: + raise KeyError(f"unknown compression request: {request_id}") + return self._forward(profile, req, worker_key) + + if action == "destroy_all": + with self._lock: + workers = list(self._workers.values()) + self._workers.clear() + self._run_profile.clear() + self._run_worker_key.clear() + self._running_run_profile.clear() + self._running_run_worker_key.clear() + self._session_profile.clear() + self._session_worker_key.clear() + self._approval_profile.clear() + self._approval_worker_key.clear() + self._clarify_profile.clear() + self._clarify_worker_key.clear() + self._compression_profile.clear() + self._compression_worker_key.clear() + destroyed = 0 + for worker in workers: + try: + if worker.running: + resp = worker.request({"action": "destroy_all"}) + destroyed += int(resp.get("destroyed") or 0) + except Exception: + pass + finally: + worker.stop() + return {"destroyed": destroyed} + + if action == "destroy_profile": + profile = self._normalize_profile(req.get("profile")) + with self._lock: + workers = [ + worker + for key, worker in list(self._workers.items()) + if getattr(worker, "profile", key) == profile + ] + for worker in workers: + self._workers.pop(worker.key, None) + self._run_profile = {key: value for key, value in self._run_profile.items() if value != profile} + self._run_worker_key = {key: value for key, value in self._run_worker_key.items() if key in self._run_profile} + self._running_run_profile = {key: value for key, value in self._running_run_profile.items() if value != profile} + self._running_run_worker_key = {key: value for key, value in self._running_run_worker_key.items() if key in self._running_run_profile} + self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile} + self._session_worker_key = {key: value for key, value in self._session_worker_key.items() if key in self._session_profile} + self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile} + self._approval_worker_key = {key: value for key, value in self._approval_worker_key.items() if key in self._approval_profile} + self._clarify_profile = {key: value for key, value in self._clarify_profile.items() if value != profile} + self._clarify_worker_key = {key: value for key, value in self._clarify_worker_key.items() if key in self._clarify_profile} + self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile} + self._compression_worker_key = {key: value for key, value in self._compression_worker_key.items() if key in self._compression_profile} + + if not workers: + return {"profile": profile, "destroyed": 0} + + destroyed = 0 + for worker in workers: + if not worker.running: + worker.stop() + continue + try: + resp = worker.request({"action": "destroy_all"}) + destroyed += int(resp.get("destroyed") or 0) + except Exception: + pass + finally: + worker.stop() + return {"profile": profile, "destroyed": destroyed} + + if action == "list": + sessions: list[Any] = [] + with self._lock: + workers = list(self._workers.items()) + for key, worker in workers: + if not worker.running: + continue + try: + resp = worker.request({"action": "list"}) + for session in resp.get("sessions") or []: + if isinstance(session, dict): + session.setdefault("profile", getattr(worker, "profile", key)) + session.setdefault("worker_key", key) + sessions.append(session) + except Exception: + pass + return {"sessions": sessions} + + if action == "shutdown": + self.stop() + return {"status": "shutting_down"} + + # ───── MCP Management ───── + if action.startswith("mcp_"): + profile = self._normalize_profile(req.get("profile")) + return self._forward(profile, req) + + raise ValueError(f"unknown action: {action}") + + def _make_server_socket(self) -> socket.socket: + return _make_listen_socket(self.endpoint) + + def _read_request(self, conn: socket.socket) -> dict[str, Any]: + return _read_json_request(conn) + + def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None: + _write_json_response(conn, resp) + + def _handle_connection(self, conn: socket.socket) -> None: + try: + try: + req = self._read_request(conn) + data = self.handle(req) + resp = {"ok": True, **_jsonable(data)} + except Exception as exc: + resp = { + "ok": False, + "error": str(exc), + "error_type": exc.__class__.__name__, + } + self._write_response(conn, resp) + except Exception as exc: + print(f"[hermes-bridge-broker] connection error: {exc}", file=sys.stderr, flush=True) + finally: + try: + conn.close() + except OSError: + pass + + def _gc_idle_workers(self) -> None: + now = time.time() + if now - self._last_gc < self.GC_INTERVAL_SECONDS: + return + self._last_gc = now + with self._lock: + idle = [ + key for key, worker in self._workers.items() + if worker.running and now - worker.last_used_at > self.IDLE_TIMEOUT_SECONDS + ] + for key in idle: + with self._lock: + worker = self._workers.pop(key, None) + if worker: + worker.stop() + + def serve_forever(self) -> None: + server = self._make_server_socket() + restore_signals = _install_stop_signal_handlers(self._stop) + atexit.register(self.stop) + try: + server.listen(64) + server.settimeout(0.2) + print(json.dumps({"event": "ready", "endpoint": self.endpoint, "mode": "broker"}), flush=True) + + while not self._stop.is_set(): + try: + try: + conn, _addr = server.accept() + except socket.timeout: + self._gc_idle_workers() + continue + threading.Thread( + target=self._handle_connection, + args=(conn,), + daemon=True, + name="hermes-bridge-broker-connection", + ).start() + except KeyboardInterrupt: + break + except Exception as exc: + print(f"[hermes-bridge-broker] server loop error: {exc}", file=sys.stderr, flush=True) + finally: + restore_signals() + try: + atexit.unregister(self.stop) + except Exception: + pass + self.stop() + server.close() + if self.endpoint.startswith("ipc://"): + try: + Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True) + except OSError: + pass + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Hermes AIAgent in-process bridge") + parser.add_argument("--endpoint", default=os.environ.get("HERMES_AGENT_BRIDGE_ENDPOINT", DEFAULT_ENDPOINT)) + parser.add_argument("--agent-root", default=os.environ.get("HERMES_AGENT_ROOT", DEFAULT_AGENT_ROOT)) + parser.add_argument("--hermes-home", default=os.environ.get("HERMES_HOME", DEFAULT_HERMES_HOME)) + parser.add_argument("--worker-profile", default=os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE")) + args = parser.parse_args(argv) + + _set_path_env(args.agent_root, args.hermes_home) + _ensure_agent_imports() + if args.worker_profile: + _set_worker_profile_env(str(args.worker_profile or "default")) + _log_worker_startup_context(str(args.worker_profile or "default")) + BridgeServer(args.endpoint).serve_forever() + else: + BridgeBroker(args.endpoint, args.agent_root, args.hermes_home).serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/server/src/services/hermes/agent-bridge/index.ts b/packages/server/src/services/hermes/agent-bridge/index.ts new file mode 100644 index 0000000..2a1bde2 --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './manager' diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts new file mode 100644 index 0000000..ab7deca --- /dev/null +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -0,0 +1,606 @@ +import { execFileSync, spawn, type ChildProcess } from 'child_process' +import { existsSync, readFileSync } from 'fs' +import { createConnection, createServer } from 'net' +import { dirname, isAbsolute, join, resolve } from 'path' +import { logger } from '../../logger' +import { detectHermesHome, getHermesBin } from '../hermes-path' +import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client' + +const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000 +const DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS = 1000 +const MAX_AGENT_BRIDGE_RESTART_DELAY_MS = 30000 +const OPENROUTER_WEB_UI_ATTRIBUTION_ENV = { + HERMES_OPENROUTER_APP_REFERER: 'https://hermes-studio.ai', + HERMES_OPENROUTER_APP_TITLE: 'Hermes Web UI', + HERMES_OPENROUTER_APP_CATEGORIES: 'cli-agent,personal-agent', +} as const + +export interface AgentBridgeManagerOptions { + endpoint?: string + python?: string + agentRoot?: string + hermesHome?: string + startupTimeoutMs?: number +} + +export interface BridgeCommand { + command: string + argsPrefix: string[] + agentRoot?: string + hermesHome: string +} + +export interface AgentBridgeManagerRuntimeState { + endpoint: string + running: boolean + ready: boolean + pid?: number + starting: boolean + stopping: boolean + restartScheduled: boolean + restartAttempts: number +} + +function envPositiveInt(name: string): number | undefined { + const raw = process.env[name] + if (!raw) return undefined + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +export function buildAgentBridgeProcessEnv(endpoint: string, hermesHome: string | undefined, agentRoot: string | undefined): NodeJS.ProcessEnv { + return { + ...process.env, + HERMES_AGENT_BRIDGE_ENDPOINT: endpoint, + HERMES_HOME: hermesHome, + HERMES_OPENROUTER_APP_REFERER: process.env.HERMES_OPENROUTER_APP_REFERER || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_REFERER, + HERMES_OPENROUTER_APP_TITLE: process.env.HERMES_OPENROUTER_APP_TITLE || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_TITLE, + HERMES_OPENROUTER_APP_CATEGORIES: process.env.HERMES_OPENROUTER_APP_CATEGORIES || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_CATEGORIES, + ...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}), + } +} + +function pathCandidates(agentRoot?: string): string[] { + if (!agentRoot) return [] + return process.platform === 'win32' + ? [ + join(agentRoot, 'venv', 'Scripts', 'python.exe'), + join(agentRoot, 'venv', 'Scripts', 'python3.exe'), + join(agentRoot, '.venv', 'Scripts', 'python.exe'), + join(agentRoot, '.venv', 'Scripts', 'python3.exe'), + ] + : [ + join(agentRoot, 'venv', 'bin', 'python3'), + join(agentRoot, 'venv', 'bin', 'python'), + join(agentRoot, '.venv', 'bin', 'python3'), + join(agentRoot, '.venv', 'bin', 'python'), + ] +} + +function uvCandidates(agentRoot?: string): string[] { + if (!agentRoot) { + return [ + process.env.HERMES_AGENT_BRIDGE_UV, + process.env.UV, + ].filter((value): value is string => !!value && value.trim().length > 0) + } + return [ + process.env.HERMES_AGENT_BRIDGE_UV, + process.env.UV, + ...(process.platform === 'win32' + ? [ + agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '', + agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '', + agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '', + agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '', + ] + : [ + agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '', + agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '', + ]), + 'uv', + ].filter((value): value is string => !!value && value.trim().length > 0) +} + +function resolveExecutable(command: string): string | undefined { + const trimmed = command.trim() + if (!trimmed) return undefined + if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) { + return existsSync(trimmed) ? resolve(trimmed) : undefined + } + try { + const lookup = process.platform === 'win32' + ? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true }) + : execFileSync('which', [trimmed], { encoding: 'utf-8' }) + return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean) + } catch { + return undefined + } +} + +function agentRootFromHermesBin(): string | undefined { + const hermesBin = resolveExecutable(getHermesBin()) + if (!hermesBin) return undefined + + const binDir = dirname(hermesBin) + const rootCandidates = [ + resolve(binDir, '..'), + resolve(binDir, '..', '..'), + resolve(binDir, '..', 'hermes-agent'), + resolve(binDir, '..', 'lib', 'hermes-agent'), + resolve(binDir, '..', '..', 'hermes-agent'), + ] + const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) + if (root) return root + + try { + const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0] + const match = first.match(/^#!\s*(.+)$/) + const python = match?.[1]?.trim().split(/\s+/)[0] + if (python) { + const pyDir = dirname(python) + const shebangRootCandidates = [ + resolve(pyDir, '..', '..'), + resolve(pyDir, '..', '..', 'hermes-agent'), + resolve(pyDir, '..', '..', 'lib', 'hermes-agent'), + ] + return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) + } + } catch {} + return undefined +} + +function hermesBinPython(): string | undefined { + const hermesBin = resolveExecutable(getHermesBin()) + if (!hermesBin) return undefined + try { + const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0] + const match = first.match(/^#!\s*(.+)$/) + const python = match?.[1]?.trim().split(/\s+/)[0] + return python && existsSync(python) ? python : undefined + } catch { + return undefined + } +} + +/** Python from `uv tool install hermes-agent` — avoids pydantic binary mismatches on Windows. */ +function uvToolHermesPython(): string | undefined { + const home = process.env.HOME || process.env.USERPROFILE + const candidates = process.platform === 'win32' + ? [ + join(process.env.APPDATA || '', 'uv', 'tools', 'hermes-agent', 'Scripts', 'python.exe'), + home ? join(home, 'AppData', 'Roaming', 'uv', 'tools', 'hermes-agent', 'Scripts', 'python.exe') : '', + ] + : [ + home ? join(home, '.local', 'share', 'uv', 'tools', 'hermes-agent', 'bin', 'python3') : '', + home ? join(home, '.local', 'share', 'uv', 'tools', 'hermes-agent', 'bin', 'python') : '', + ] + return firstExistingExecutable(candidates.filter((value): value is string => !!value && value.trim().length > 0)) +} + +function firstExistingExecutable(candidates: string[]): string | undefined { + for (const candidate of candidates) { + if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) { + const resolved = resolveExecutable(candidate) + if (resolved) return resolved + continue + } + try { + if (existsSync(candidate)) return candidate + } catch {} + } + return undefined +} + +function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined { + const candidates = [ + explicit, + process.env.HERMES_AGENT_ROOT, + join(hermesHome, 'hermes-agent'), + agentRootFromHermesBin(), + process.cwd(), + join(process.cwd(), 'hermes-agent'), + '/usr/local/lib/hermes-agent', + '/usr/local/hermes-agent', + '/opt/hermes/hermes-agent', + '/opt/hermes-agent', + ].filter((value): value is string => !!value && value.trim().length > 0) + return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) +} + +export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {}): BridgeCommand { + const hermesHome = options.hermesHome || detectHermesHome() + const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome) + const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON + if (explicitPython) { + return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome } + } + + const venvPython = firstExistingExecutable(pathCandidates(agentRoot)) + if (venvPython) { + return { command: venvPython, argsPrefix: [], agentRoot, hermesHome } + } + + const uvToolPython = uvToolHermesPython() + if (uvToolPython) { + return { command: uvToolPython, argsPrefix: [], agentRoot, hermesHome } + } + + const shebangPython = hermesBinPython() + if (shebangPython && existsSync(shebangPython)) { + return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome } + } + + const uv = firstExistingExecutable(uvCandidates(agentRoot)) + if (uv) { + const prefix = ['run'] + if (agentRoot) prefix.push('--project', agentRoot) + prefix.push('python') + return { command: uv, argsPrefix: prefix, agentRoot, hermesHome } + } + + const fallback = firstExistingExecutable([ + process.env.PYTHON || '', + ...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']), + ]) || (process.platform === 'win32' ? 'python' : 'python3') + return { command: fallback, argsPrefix: [], agentRoot, hermesHome } +} + +function bridgeScriptPath(): string { + const candidates = [ + // Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py + resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'), + // ts-node/dev source tree. + resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'), + resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'), + ] + const found = candidates.find(candidate => existsSync(candidate)) + if (!found) { + throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`) + } + return found +} + +function isTcpEndpoint(endpoint: string): boolean { + return endpoint.startsWith('tcp://') +} + +function isDesktopRuntime(): boolean { + return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' +} + +async function canListenTcpEndpoint(endpoint: string): Promise { + const url = new URL(endpoint) + const host = url.hostname || '127.0.0.1' + const port = Number(url.port) + if (!Number.isFinite(port) || port <= 0) return false + + return await new Promise((resolveAvailable) => { + const probe = createServer() + const done = (available: boolean) => { + probe.removeAllListeners() + resolveAvailable(available) + } + probe.once('error', () => done(false)) + probe.listen(port, host, () => { + probe.close(() => done(true)) + }) + }) +} + +async function canConnectTcpEndpoint(endpoint: string): Promise { + const url = new URL(endpoint) + const host = url.hostname || '127.0.0.1' + const port = Number(url.port) + if (!Number.isFinite(port) || port <= 0) return false + + return await new Promise((resolveConnected) => { + const socket = createConnection({ port, host }) + const done = (connected: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolveConnected(connected) + } + socket.setTimeout(250) + socket.once('connect', () => done(true)) + socket.once('timeout', () => done(false)) + socket.once('error', () => done(false)) + }) +} + +function tcpEndpointPort(endpoint: string): number | undefined { + if (!isTcpEndpoint(endpoint)) return undefined + const url = new URL(endpoint) + const port = Number(url.port) + return Number.isFinite(port) && port > 0 ? port : undefined +} + +function windowsListeningPidsOnPort(port: number): number[] { + try { + const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { windowsHide: true }).toString('utf8') + const pids = new Set() + for (const line of output.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/) + if (parts.length < 5) continue + const [proto, localAddress, , state, pidRaw] = parts + if (proto.toUpperCase() !== 'TCP' || state.toUpperCase() !== 'LISTENING') continue + if (!localAddress.endsWith(`:${port}`)) continue + const pid = Number(pidRaw) + if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid) + } + return [...pids] + } catch { + return [] + } +} + +async function waitForTcpEndpoint(endpoint: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await canListenTcpEndpoint(endpoint)) return true + await new Promise(resolve => setTimeout(resolve, 100)) + } + return canListenTcpEndpoint(endpoint) +} + +async function killWindowsEndpointOccupants(endpoint: string): Promise { + const port = tcpEndpointPort(endpoint) + if (!port) return + const pids = windowsListeningPidsOnPort(port) + if (!pids.length) return + for (const pid of pids) { + try { + logger.warn('[agent-bridge] killing stale process tree pid=%d on bridge port %d', pid, port) + execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf-8', windowsHide: true }) + } catch (err) { + logger.warn(err, '[agent-bridge] failed to kill stale bridge process pid=%d', pid) + } + } + await waitForTcpEndpoint(endpoint, 3000) +} + +export class AgentBridgeManager { + endpoint: string + private readonly options: AgentBridgeManagerOptions + private readonly explicitEndpoint: boolean + private child: ChildProcess | null = null + private starting: Promise | null = null + private ready = false + private stopping = false + private restartTimer: NodeJS.Timeout | null = null + private restartAttempts = 0 + + constructor(options: AgentBridgeManagerOptions = {}) { + this.options = options + this.explicitEndpoint = Boolean(options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT) + this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT + } + + get running(): boolean { + return !!this.child && !this.child.killed && this.ready + } + + getRuntimeState(): AgentBridgeManagerRuntimeState { + return { + endpoint: this.endpoint, + running: this.running, + ready: this.ready, + pid: this.child?.pid, + starting: !!this.starting, + stopping: this.stopping, + restartScheduled: !!this.restartTimer, + restartAttempts: this.restartAttempts, + } + } + + async start(): Promise { + if (this.running) return + if (this.starting) return this.starting + this.stopping = false + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + this.starting = this.startProcess() + try { + await this.starting + } finally { + this.starting = null + } + } + + private async startProcess(): Promise { + const script = bridgeScriptPath() + const command = resolveAgentBridgeCommand(this.options) + await this.prepareEndpoint() + const args = [...command.argsPrefix, script, '--endpoint', this.endpoint] + const agentRoot = command.agentRoot + const hermesHome = command.hermesHome + if (agentRoot) args.push('--agent-root', agentRoot) + if (hermesHome) args.push('--hermes-home', hermesHome) + + const env = buildAgentBridgeProcessEnv(this.endpoint, hermesHome, agentRoot) + + logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' ')) + const child = spawn(command.command, args, { + env, + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + this.child = child + this.ready = false + + child.once('exit', (code, signal) => { + const shouldRestart = this.ready && !this.stopping && this.child === child && this.autoRestartEnabled() + logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal) + this.ready = false + if (this.child === child) this.child = null + if (shouldRestart) this.scheduleRestart(code, signal) + }) + + child.stderr?.on('data', chunk => { + const text = String(chunk).trim() + if (text) logger.warn('[agent-bridge] %s', text) + }) + + await new Promise((resolveReady, rejectReady) => { + let buffered = '' + const startupTimeoutMs = this.options.startupTimeoutMs + ?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS') + ?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS + const timeout = setTimeout(() => { + cleanup() + rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`)) + }, startupTimeoutMs) + + const cleanup = () => { + clearTimeout(timeout) + child.off('exit', onExitBeforeReady) + child.off('error', onError) + } + + const markReady = () => { + if (readyResolved) return + this.ready = true + this.restartAttempts = 0 + readyResolved = true + cleanup() + child.stdout?.off('data', onStdout) + resolveReady() + } + + const onError = (err: Error) => { + cleanup() + child.stdout?.off('data', onStdout) + rejectReady(err) + } + + const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup() + child.stdout?.off('data', onStdout) + rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`)) + } + + let readyResolved = false + const onStdout = (chunk: Buffer) => { + const text = chunk.toString('utf8') + buffered += text + for (;;) { + const newline = buffered.indexOf('\n') + if (newline < 0) break + const line = buffered.slice(0, newline).trim() + buffered = buffered.slice(newline + 1) + if (!line) continue + logger.info('[agent-bridge] %s', line) + if (!readyResolved) { + try { + const parsed = JSON.parse(line) + if (parsed?.event === 'ready') { + markReady() + return + } + } catch {} + } + } + } + + child.once('error', onError) + child.once('exit', onExitBeforeReady) + child.stdout?.on('data', onStdout) + + if (isDesktopRuntime() && isTcpEndpoint(this.endpoint)) { + const probe = async () => { + while (!readyResolved && !child.killed) { + if (await canConnectTcpEndpoint(this.endpoint)) { + markReady() + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + probe().catch(onError) + } + }) + + logger.info('[agent-bridge] ready at %s', this.endpoint) + } + + private async prepareEndpoint(): Promise { + if (!this.explicitEndpoint && process.platform === 'win32' && isTcpEndpoint(this.endpoint)) { + if (!(await canListenTcpEndpoint(this.endpoint))) { + await killWindowsEndpointOccupants(this.endpoint) + } + } + process.env.HERMES_AGENT_BRIDGE_ENDPOINT = this.endpoint + } + + private autoRestartEnabled(): boolean { + const raw = String(process.env.HERMES_AGENT_BRIDGE_AUTO_RESTART || '').trim().toLowerCase() + return !['0', 'false', 'no', 'off'].includes(raw) + } + + private scheduleRestart(code: number | null, signal: NodeJS.Signals | null): void { + if (this.restartTimer || this.stopping) return + this.restartAttempts += 1 + const envDelay = envPositiveInt('HERMES_AGENT_BRIDGE_RESTART_DELAY_MS') ?? DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS + const delayMs = Math.min( + MAX_AGENT_BRIDGE_RESTART_DELAY_MS, + envDelay * Math.max(1, this.restartAttempts), + ) + logger.warn( + '[agent-bridge] broker exited unexpectedly code=%s signal=%s; restarting in %dms (attempt %d)', + code, + signal, + delayMs, + this.restartAttempts, + ) + this.restartTimer = setTimeout(() => { + this.restartTimer = null + if (this.stopping) return + this.start().catch((err) => { + logger.warn(err, '[agent-bridge] automatic restart failed') + if (!this.stopping) this.scheduleRestart(null, null) + }) + }, delayMs) + } + + async stop(): Promise { + this.stopping = true + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + const child = this.child + if (!child) return + this.ready = false + this.child = null + + await new Promise((resolveStop) => { + const timeout = setTimeout(() => { + if (!child.killed) child.kill('SIGKILL') + resolveStop() + }, 1500) + child.once('exit', () => { + clearTimeout(timeout) + resolveStop() + }) + if (!child.killed) { + child.kill('SIGTERM') + } + }) + } +} + +let singleton: AgentBridgeManager | null = null + +export function getAgentBridgeManager(): AgentBridgeManager { + if (!singleton) singleton = new AgentBridgeManager() + return singleton +} + +export async function startAgentBridgeManager(): Promise { + const manager = getAgentBridgeManager() + await manager.start() + return manager +} diff --git a/packages/server/src/services/hermes/context-engine/compressor.ts b/packages/server/src/services/hermes/context-engine/compressor.ts new file mode 100644 index 0000000..c31b5d1 --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/compressor.ts @@ -0,0 +1,463 @@ +import type { + StoredMessage, + CompressionConfig, + CompressedContext, + BuildContextInput, + MessageFetcher, + GatewayCaller, + SessionCleaner, +} from './types' +import { DEFAULT_COMPRESSION_CONFIG } from './types' +import { GatewaySummarizer } from './gateway-client' +import { buildAgentInstructions, buildSummarizationSystemPrompt } from './prompt' +import { logger } from '../../../services/logger' + +export class ContextEngine { + private config: CompressionConfig + private messageFetcher: MessageFetcher + private gatewayCaller: GatewayCaller + /** Per-room compression lock to prevent concurrent snapshot overwrites */ + private _compressLocks = new Map>() + private _upstream = '' + private _apiKey: string | null = null + + constructor(opts: { + config?: Partial + messageFetcher: MessageFetcher + gatewayCaller?: GatewayCaller + sessionCleaner?: SessionCleaner + }) { + this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts.config } + this.messageFetcher = opts.messageFetcher + this.gatewayCaller = opts.gatewayCaller || new GatewaySummarizer(this.config.summarizationTimeoutMs) + this.sessionCleaner = opts.sessionCleaner + } + + private sessionCleaner?: SessionCleaner + + setUpstream(upstream: string, apiKey: string | null): void { + this._upstream = upstream + this._apiKey = apiKey + } + + /** + * Build context for an agent reply. + * + * Flow: + * 1. Read persisted snapshot (summary + lastMessageId) from SQLite + * 2. If snapshot exists: + * a. Collect new messages after lastMessageId + * b. Estimate tokens = summary + new messages + * c. Under threshold → return as-is + * d. Over threshold → incremental compress, update snapshot, return + * 3. If no snapshot: + * a. Estimate tokens for all messages + * b. Under threshold → return all verbatim + * c. Over threshold → full compress, save snapshot, return + */ + async buildContext(input: BuildContextInput): Promise { + // Serialize compression per room to prevent concurrent snapshot overwrites + const existing = this._compressLocks.get(input.roomId) + if (existing) { + await existing + } + let resolveLock!: () => void + const lock = new Promise(r => { resolveLock = r }) + this._compressLocks.set(input.roomId, lock) + try { + return await this._buildContextImpl(input) + } finally { + resolveLock() + this._compressLocks.delete(input.roomId) + } + } + + private async _buildContextImpl(input: BuildContextInput): Promise { + const config = { ...this.config, ...input.compression } + const allMessages = this.messageFetcher.getMessages(input.roomId) + // Filter out messages newer than the current one + const messages = allMessages.filter(m => m.timestamp <= input.currentMessage.timestamp) + const total = messages.length + + logger.debug(`[ContextEngine] buildContext START — room=${input.roomId}, agent=${input.agentName}, totalMessagesInDb=${allMessages.length}, afterFilter=${total}`) + + const instructions = buildAgentInstructions({ + agentName: input.agentName, + roomName: input.roomName, + agentDescription: input.agentDescription, + memberNames: input.memberNames, + members: input.members, + }) + + const meta: CompressedContext['meta'] = { + totalMessages: total, + verbatimCount: 0, + hadSnapshot: false, + compressed: false, + summaryTokenEstimate: 0, + } + + const snapshot = this.messageFetcher.getContextSnapshot(input.roomId) + logger.debug(`[ContextEngine] snapshot=${snapshot ? `EXISTS (lastMsgId=${snapshot.lastMessageId}, summaryLen=${snapshot.summary.length})` : 'NONE'}`) + + const estimateFullContextTokens = async ( + history: Array<{ role: 'user' | 'assistant'; content: string }>, + messageTokenEstimate: number, + ): Promise => { + try { + const estimate = await input.contextTokenEstimator?.(history, instructions) + if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) { + return Math.floor(estimate) + } + } catch (err: any) { + logger.warn(`[ContextEngine] full context estimate failed room=${input.roomId}, agent=${input.agentName}: ${err.message}`) + } + return messageTokenEstimate + } + + const logThresholdCheck = (path: string, messageTokens: number, fullTokens: number): void => { + meta.messageTokenEstimate = messageTokens + meta.contextTokenEstimate = fullTokens + logger.info({ + roomId: input.roomId, + agentName: input.agentName, + profile: input.profile || 'default', + path, + messages: total, + messageOnlyTokens: messageTokens, + fullContextTokens: fullTokens, + triggerTokens: config.triggerTokens, + decision: fullTokens > config.triggerTokens ? 'compress' : 'skip', + }, '[ContextEngine] threshold check') + } + + // ── Path A: Snapshot exists — incremental ──────────── + if (snapshot) { + meta.hadSnapshot = true + + // Find the position of lastMessageId in messages + const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId) + // Collect messages after the snapshot position + const newMessages = snapshotIdx >= 0 + ? messages.slice(snapshotIdx + 1) + : messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp) + + const summaryTokens = this.countTokens(snapshot.summary) + const newTokens = this.estimateTokensFromMessages(newMessages) + const messageOnlyTokens = summaryTokens + newTokens + + meta.verbatimCount = newMessages.length + meta.summaryTokenEstimate = summaryTokens + + const snapshotHistory = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName) + const totalTokens = await estimateFullContextTokens(snapshotHistory, messageOnlyTokens) + logThresholdCheck('snapshot', messageOnlyTokens, totalTokens) + + logger.debug(`[ContextEngine] [Path A] snapshotIdx=${snapshotIdx}, newMessages=${newMessages.length}, summaryTokens=~${summaryTokens}, newTokens=~${newTokens}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`) + logger.debug(`[ContextEngine] [Path A] EXISTING SUMMARY (${snapshot.summary.length} chars): ${snapshot.summary.slice(0, 300)}`) + if (newMessages.length > 0) { + logger.debug(`[ContextEngine] [Path A] NEW MESSAGES (${newMessages.length}): ${newMessages.map(m => `[${m.senderName}]: ${m.content.slice(0, 80)}`).join(' | ')}`) + } + + // Under threshold — return summary + new messages directly + if (totalTokens <= config.triggerTokens) { + logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`) + this.logHistory('Path A (no compress)', snapshotHistory) + return { conversationHistory: snapshotHistory, instructions, meta } + } + + // Over threshold — incremental compress + if (totalTokens > messageOnlyTokens && newMessages.length <= config.tailMessageCount) { + throw new Error( + `Context window is too small for group chat agent ${input.agentName}: fixed prompt/tool overhead plus ${newMessages.length} new messages uses ~${totalTokens} tokens, exceeding trigger ${config.triggerTokens}, and there is not enough history to compress.`, + ) + } + logger.debug(`[ContextEngine] [Path A] OVER threshold — starting INCREMENTAL compression of ${newMessages.length} msgs...`) + logger.debug(`[ContextEngine] [Path A] CONTEXT BEFORE COMPRESSION: summary(${snapshot.summary.length} chars) + ${newMessages.length} new msgs`) + meta.compressed = true + input.onProgress?.({ + status: 'compressing', + path: 'snapshot', + messageCount: newMessages.length, + tokenCount: totalTokens, + }) + + const t0 = Date.now() + const result = await this.summarize( + input.roomId, + newMessages, + input.upstream, + input.apiKey, + input.profile || 'default', + snapshot.summary, + ) + const elapsed = Date.now() - t0 + + if (result.summary) { + const lastMsg = newMessages[newMessages.length - 1] + this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastMsg.id, lastMsg.timestamp) + + meta.summaryTokenEstimate = this.countTokens(result.summary) + logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`) + logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`) + const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName) + meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history)) + this.logHistory('Path A (after incremental compress)', history) + if (result.sessionId) this.sessionCleaner?.(result.sessionId) + return { conversationHistory: history, instructions, meta } + } + + // Compression failed — degrade + logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`) + const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName) + this.trimToBudget(history, summaryTokens, config.maxHistoryTokens) + return { conversationHistory: history, instructions, meta } + } + + // ── Path B: No snapshot — full context ─────────────── + const messageOnlyTokens = this.estimateTokensFromMessages(messages) + meta.verbatimCount = total + const fullHistory = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName)) + const totalTokens = await estimateFullContextTokens(fullHistory, messageOnlyTokens) + logThresholdCheck('full', messageOnlyTokens, totalTokens) + + logger.debug(`[ContextEngine] [Path B] no snapshot, totalMessages=${total}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`) + + // Under threshold — pass all messages verbatim + if (totalTokens <= config.triggerTokens) { + logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`) + this.logHistory('Path B (no compress)', fullHistory) + return { conversationHistory: fullHistory, instructions, meta } + } + + // Over threshold — full compress + if (totalTokens > messageOnlyTokens && messages.length <= config.tailMessageCount) { + throw new Error( + `Context window is too small for group chat agent ${input.agentName}: fixed prompt/tool overhead plus ${messages.length} messages uses ~${totalTokens} tokens, exceeding trigger ${config.triggerTokens}, and there is not enough history to compress.`, + ) + } + logger.debug(`[ContextEngine] [Path B] OVER threshold — starting FULL compression of ${total} msgs...`) + logger.debug(`[ContextEngine] [Path B] CONTEXT BEFORE COMPRESSION: ${total} msgs, ~${totalTokens} tokens`) + meta.compressed = true + input.onProgress?.({ + status: 'compressing', + path: 'full', + messageCount: total, + tokenCount: totalTokens, + }) + + const t0 = Date.now() + const result = await this.summarize( + input.roomId, + messages, + input.upstream, + input.apiKey, + input.profile || 'default', + ) + const elapsed = Date.now() - t0 + + if (result.summary) { + // Keep recent tail messages verbatim, compress the rest + const { tailMessageCount } = config + const toCompress = messages.length > tailMessageCount ? messages.slice(0, -tailMessageCount) : messages + const tail = messages.length > tailMessageCount ? messages.slice(-tailMessageCount) : [] + const lastCompressedMsg = toCompress[toCompress.length - 1] + + this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) + + meta.summaryTokenEstimate = this.countTokens(result.summary) + logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`) + logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`) + const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName) + meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history)) + this.logHistory('Path B (after full compress)', history) + if (result.sessionId) this.sessionCleaner?.(result.sessionId) + return { conversationHistory: history, instructions, meta } + } + + // Compression failed — degrade + logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`) + const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName)) + this.trimToBudget(history, 0, config.maxHistoryTokens) + meta.verbatimCount = history.length + return { conversationHistory: history, instructions, meta } + } + + invalidateRoom(roomId: string): void { + this.messageFetcher.deleteContextSnapshot(roomId) + } + + /** + * Force compress all messages in a room (full compression). + * Used when user manually triggers compression. + */ + async forceCompress(roomId: string, profile?: string): Promise { + const allMessages = this.messageFetcher.getMessages(roomId) + if (allMessages.length === 0) return '' + + const config = { ...this.config } + logger.debug(`[ContextEngine] forceCompress room=${roomId}, messages=${allMessages.length}`) + + const t0 = Date.now() + const result = await this.summarize(roomId, allMessages, this._upstream, this._apiKey, profile || 'default') + const elapsed = Date.now() - t0 + + if (result.summary) { + const { tailMessageCount } = config + const toCompress = allMessages.length > tailMessageCount ? allMessages.slice(0, -tailMessageCount) : allMessages + const lastCompressedMsg = toCompress[toCompress.length - 1] + + this.messageFetcher.saveContextSnapshot(roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) + logger.debug(`[ContextEngine] forceCompress DONE in ${elapsed}ms`) + if (result.sessionId) this.sessionCleaner?.(result.sessionId) + return result.summary + } + + throw new Error('Compression failed') + } + + // ─── Private ───────────────────────────────────────────── + + /** + * Build history array: optional summary prefix + verbatim messages. + */ + private buildHistory( + summary: string, + messages: StoredMessage[], + agentSocketId: string, + agentName: string, + ): Array<{ role: 'user' | 'assistant'; content: string }> { + const history: Array<{ role: 'user' | 'assistant'; content: string }> = [] + + if (summary) { + history.push( + { role: 'user', content: '[Previous conversation summary]\n' + summary }, + { role: 'assistant', content: 'I have reviewed the conversation history and understand the context.' }, + ) + } + + history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName))) + return history + } + + /** + * Summarize messages. If previousSummary is provided, do incremental update. + */ + private async summarize( + roomId: string, + messages: StoredMessage[], + upstream: string, + apiKey: string | null, + profile: string, + previousSummary?: string, + ): Promise<{ summary: string | null; sessionId: string | null }> { + if (messages.length === 0 && !previousSummary) return { summary: null, sessionId: null } + + try { + const result = await this.gatewayCaller.summarize( + upstream, + apiKey, + buildSummarizationSystemPrompt(), + messages, + roomId, + profile, + previousSummary, + ) + return { summary: result.summary, sessionId: result.sessionId } + } catch (err: any) { + logger.warn(`[ContextEngine] Summarization failed for room ${roomId}: ${err.message}`) + return { summary: null, sessionId: null } + } finally { + // Session cleanup handled here if sessionCleaner is provided + } + } + + private mapToHistory( + msg: StoredMessage, + agentSocketId: string, + agentName: string, + ): { role: 'user' | 'assistant'; content: string } { + const senderName = msg.senderName || 'unknown' + const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName + + if (msg.role === 'tool') { + const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result' + return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` } + } + + if (msg.role === 'assistant' && msg.tool_calls?.length) { + const toolsInfo = msg.tool_calls.map(tc => { + const name = tc.function?.name || 'unknown' + let args = tc.function?.arguments || '{}' + if (args.length > 4000) args = `${args.slice(0, 4000)}...` + return `[Calling tool: ${name} with arguments: ${args}]` + }).join('\n') + const content = msg.content?.trim() + return { + role: isOwnAgent ? 'assistant' : 'user', + content: content + ? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}` + : `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`, + } + } + + return { + role: isOwnAgent ? 'assistant' : 'user', + content: this.formatAttributedContent(senderName, msg.content || ''), + } + } + + private formatAttributedContent(senderName: string, content: string): string { + return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}` + } + + private formatAttributionPrefix(senderName: string, _content?: string): string { + return `[${senderName}]: ` + } + + private stripMentions(content: string): string { + return String(content || '') + .replace(/@([^\s@]+)/g, '') + .replace(/[ \t]{2,}/g, ' ') + .replace(/^\s+/, '') + } + + private trimToBudget( + history: Array<{ role: 'user' | 'assistant'; content: string }>, + summaryTokens: number, + maxTokens: number, + ): void { + let totalTokens = summaryTokens + this.estimateTokens(history) + while (totalTokens > maxTokens && history.length > 0) { + history.pop() + totalTokens = summaryTokens + this.estimateTokens(history) + } + } + + private estimateTokens(history: Array<{ role: string; content: string }>): number { + const text = history.map(m => m.content).join('') + return this.countTokens(text) + } + + private estimateTokensFromMessages(messages: StoredMessage[]): number { + const text = messages.map(m => m.content).join('') + return this.countTokens(text) + } + + /** Estimate tokens distinguishing CJK (~1.5 tok/char) from Latin (config.charsPerToken per char) */ + private countTokens(text: string): number { + const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length + const other = text.length - cjk + return Math.ceil(cjk * 1.5 + other / this.config.charsPerToken) + } + + /** Log assembled history for debugging */ + private logHistory(label: string, history: Array<{ role: string; content: string }>): void { + const totalTokens = this.estimateTokens(history) + logger.debug(`[ContextEngine] ASSEMBLED HISTORY (${label}): ${history.length} entries, ~${totalTokens} tokens`) + for (const entry of history) { + const preview = entry.content.length > 150 ? entry.content.slice(0, 150) + '...' : entry.content + logger.debug(` [${entry.role}] ${preview}`) + } + } +} diff --git a/packages/server/src/services/hermes/context-engine/gateway-client.ts b/packages/server/src/services/hermes/context-engine/gateway-client.ts new file mode 100644 index 0000000..5f5ea3d --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/gateway-client.ts @@ -0,0 +1,110 @@ +import type { StoredMessage, GatewayCaller } from './types' +import { + buildSummarizationSystemPrompt, + buildFullSummaryPrompt, + buildIncrementalUpdatePrompt, +} from './prompt' +import { updateUsage } from '../../../db/hermes/usage-store' +import { logger } from '../../logger' +import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge' + +/** + * Calls the local bridge to produce LLM-generated summaries. + * The context engine owns history assembly; gateway storage/chaining is not used. + */ +export class GatewaySummarizer implements GatewayCaller { + private timeoutMs: number + + constructor(timeoutMs = 30_000) { + this.timeoutMs = timeoutMs + } + + async summarize( + _upstream: string, + _apiKey: string | null, + systemPrompt: string, + messages: StoredMessage[], + roomId: string, + profile: string, + previousSummary?: string, + ): Promise<{ summary: string; sessionId: string }> { + const history: Array<{ role: string; content: string }> = messages.map(m => ({ + role: 'user', + content: summarizeMessageForPrompt(m), + })) + + if (previousSummary) { + history.unshift( + { role: 'user', content: `[Previous summary]\n${previousSummary}` }, + { role: 'assistant', content: 'Understood, I will update the summary.' }, + ) + } + + const userPrompt = previousSummary + ? buildIncrementalUpdatePrompt() + : buildFullSummaryPrompt() + + const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 }) + const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 160) + + try { + const result = await bridge.request({ + action: 'chat', + session_id: sessionId, + message: userPrompt, + instructions: systemPrompt || buildSummarizationSystemPrompt(), + conversation_history: history, + profile, + source: 'api_server', + wait: true, + timeout: Math.ceil(this.timeoutMs / 1000), + }, { timeoutMs: this.timeoutMs + 15_000 }) + + if (result.status === 'error') { + throw new Error(result.error || 'Summarization bridge run failed') + } + + const payload = result.result as any + const output = String(payload?.final_response || result.output || '').trim() + if (!output) throw new Error('Empty summarization response') + + const usage = payload?.usage || payload?.response?.usage + if (usage) { + updateUsage(roomId, { + inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0, + outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0, + cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0, + cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0, + reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0, + model: payload?.model || payload?.response?.model || '', + profile, + }) + } + logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`) + return { summary: output, sessionId } + } finally { + await bridge.destroy(sessionId, profile).catch(() => undefined) + } + } +} + +function summarizeMessageForPrompt(message: StoredMessage): string { + if (message.role === 'tool') { + const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result' + return `[${label}]\n${message.content || ''}` + } + + if (message.role === 'assistant' && message.tool_calls?.length) { + const toolsInfo = message.tool_calls.map(tc => { + const name = tc.function?.name || 'tool' + const args = tc.function?.arguments || '{}' + return `${name}(${args})` + }).join(', ') + const content = message.content?.trim() + return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]` + } + + return `[${message.senderName}]: ${message.content}` +} diff --git a/packages/server/src/services/hermes/context-engine/index.ts b/packages/server/src/services/hermes/context-engine/index.ts new file mode 100644 index 0000000..9817bd6 --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/index.ts @@ -0,0 +1,13 @@ +export { ContextEngine } from './compressor' +export { GatewaySummarizer } from './gateway-client' +export { buildAgentInstructions, buildSummarizationSystemPrompt, buildFullSummaryPrompt, buildIncrementalUpdatePrompt } from './prompt' +export { DEFAULT_COMPRESSION_CONFIG } from './types' +export type { + StoredMessage, + CompressionConfig, + CompressedContext, + ContextSnapshot, + MessageFetcher, + GatewayCaller, + BuildContextInput, +} from './types' diff --git a/packages/server/src/services/hermes/context-engine/prompt.ts b/packages/server/src/services/hermes/context-engine/prompt.ts new file mode 100644 index 0000000..aff9821 --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/prompt.ts @@ -0,0 +1,115 @@ +// ─── Agent Identity Instructions ──────────────────────────── + +import type { MemberInfo } from './types' +import { getSystemPrompt } from '../../../lib/llm-prompt' + +interface AgentInstructionsParams { + agentName: string + roomName: string + agentDescription: string + memberNames: string[] + members: MemberInfo[] +} + +export function buildAgentInstructions(params: AgentInstructionsParams): string { + // Deduplicate members by name (primary key) to avoid duplicate roles + // If multiple entries have the same name, prefer the one with description + const uniqueMembersMap = new Map() + + for (const m of params.members) { + const existing = uniqueMembersMap.get(m.name) + // Prefer entries with description + if (!existing || (m.description && !existing.description)) { + uniqueMembersMap.set(m.name, m) + } + } + + const uniqueMembers = Array.from(uniqueMembersMap.values()) + + let memberSection: string + if (uniqueMembers.length > 0) { + memberSection = uniqueMembers + .map(m => m.description ? `- ${m.name}: ${m.description}` : `- ${m.name}`) + .join('\n') + } else if (params.memberNames.length > 0) { + // Deduplicate member names as well + const uniqueNames = Array.from(new Set(params.memberNames)) + memberSection = uniqueNames.map(n => `- ${n}`).join('\n') + } else { + memberSection = '- 未知' + } + + // Handle empty agent description + const roleDescription = params.agentDescription?.trim() + ? params.agentDescription + : '专业的 AI 助手,随时准备协助解决问题。' + + const basePrompt = `你是"${params.agentName}",群聊房间"${params.roomName}"中的 AI 助手。 + +你的角色:${roleDescription} + +当前房间成员: +${memberSection} + +规则: +- 当你收到群聊任务时,说明系统已经判断你需要回复;请直接回应当前消息,不要因为消息里同时提及其他成员而拒绝回复或输出空回复。 +- 重点回应提及你的人。 +- 回答简洁、对群聊有帮助。 + - 不要假装是人类,需要时明确表明自己是 AI。 + - 对话历史中包含多个人的消息,每条消息前标有发送者名字。 + - 历史消息里的"[发送者]: ..."只是系统添加的归属标记,用来帮助你理解谁说了这句话;不要在你的回复中复述或模仿这种方括号前缀。 + - 回复时使用自然语言即可;如果需要点名某人,只使用 @名字,不要输出"[${params.agentName}]:"这类格式。 + - 对话开头可能包含之前的对话摘要,用于提供更早的上下文。 + - 回复最新一条提及你的消息。 + - 群聊系统支持 agent 之间通过 @名字 接力:当你在回复中写出 @某个成员,系统会把消息路由给对应成员。 + - 如果用户明确要求你叫、让、请某个 agent 执行任务,不要自己代办,不要说你无法指挥其他 agent;请直接用 @名字 转交任务,并简短说明你已转交。 + - 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方,并把需要对方执行的任务写清楚。 + - 不要主动 @ 任何人,除非最新消息明确要求你转交、邀请、询问某个具体成员。 + - 如果只是回答提问,直接回答,不要在结尾 @ 其他成员继续接力。 + - 不要为了活跃气氛、征求补充、让别人也看看而 @ 其他 agent 或用户。 + - 只有在确实需要对方执行动作、提供信息、确认决策时,才可以 @名字。 + - 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。` + + return getSystemPrompt(basePrompt) +} + +// ─── Summarization Prompts ───────────────────────────────── + +export function buildSummarizationSystemPrompt(): string { + return `你是一个群聊对话的摘要助手。请创建一份结构化摘要,帮助 AI 助手快速理解完整的对话上下文并智能回复。 + +使用以下格式: + +当前话题: +- 现在在聊什么,目标是什么 + +已知结论: +- 已达成哪些共识,哪些问题已经回答过 + +待回复消息: +- 还剩谁的问题没回,下一步要做什么 + +关键人物: +- 人名、角色、引用关系 + +重要上下文: +- 不要丢时间线和立场变化 +- 少写废话,多保留"可行动信息" +- 重点保留:谁说了什么、结论是什么、下一步是什么 +- 关键的 URL、代码片段、错误信息、约束条件 + +规则: +- 基于事实,不要编造信息。 +- 保持简洁(500 字以内)。 +- 聚焦于帮助 AI 回复下一条消息的可行动信息。 +- 使用与对话相同的语言。 +- 不要回复对话内容,只输出摘要。` +} + +export function buildFullSummaryPrompt(): string { + return '请对上方对话创建一份简洁的摘要。只输出摘要内容。' +} + +export function buildIncrementalUpdatePrompt(): string { + return '对话自上次摘要后有了新的内容。请更新摘要,整合新消息。保持相同格式,更新所有部分。只输出更新后的摘要。' +} diff --git a/packages/server/src/services/hermes/context-engine/summary-cache.ts b/packages/server/src/services/hermes/context-engine/summary-cache.ts new file mode 100644 index 0000000..5adebdd --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/summary-cache.ts @@ -0,0 +1,49 @@ +import type { SummaryCacheEntry } from './types' + +const MAX_ENTRIES = 200 + +export class SummaryCache { + private cache = new Map() + private ttlMs: number + + constructor(ttlMs = 120_000) { + this.ttlMs = ttlMs + } + + get(roomId: string): SummaryCacheEntry | undefined { + const entry = this.cache.get(roomId) + if (!entry) return undefined + if (Date.now() - entry.createdAt >= this.ttlMs) { + this.cache.delete(roomId) + return undefined + } + return entry + } + + set(roomId: string, entry: SummaryCacheEntry): void { + if (this.cache.size >= MAX_ENTRIES) { + let oldestKey = '' + let oldestTime = Infinity + for (const [k, v] of this.cache) { + if (v.createdAt < oldestTime) { + oldestTime = v.createdAt + oldestKey = k + } + } + if (oldestKey) this.cache.delete(oldestKey) + } + this.cache.set(roomId, entry) + } + + invalidate(roomId: string): void { + this.cache.delete(roomId) + } + + clear(): void { + this.cache.clear() + } + + get size(): number { + return this.cache.size + } +} diff --git a/packages/server/src/services/hermes/context-engine/types.ts b/packages/server/src/services/hermes/context-engine/types.ts new file mode 100644 index 0000000..5376427 --- /dev/null +++ b/packages/server/src/services/hermes/context-engine/types.ts @@ -0,0 +1,133 @@ +// ─── Message Types ────────────────────────────────────────── + +/** Raw message from SQLite messages table */ +export interface StoredMessage { + id: string + roomId: string + senderId: string + senderName: string + content: string + timestamp: number + role?: string + tool_call_id?: string | null + tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null + tool_name?: string | null + finish_reason?: string | null +} + +// ─── Compression Config ──────────────────────────────────── + +export interface CompressionConfig { + /** Token threshold to trigger compression (estimate all messages) */ + triggerTokens: number + /** Max tokens for the final compressed context sent to LLM */ + maxHistoryTokens: number + /** Number of recent messages to keep verbatim after compression */ + tailMessageCount: number + /** Characters per token for estimation */ + charsPerToken: number + /** Timeout for summarization LLM call in ms */ + summarizationTimeoutMs: number +} + +export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { + triggerTokens: 100_000, + maxHistoryTokens: 32_000, + tailMessageCount: 10, + charsPerToken: 6, + summarizationTimeoutMs: 30_000, +} + +// ─── Compression Output ──────────────────────────────────── + +export interface CompressedContext { + conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }> + instructions: string + meta: { + totalMessages: number + verbatimCount: number + hadSnapshot: boolean + compressed: boolean + summaryTokenEstimate: number + contextTokenEstimate?: number + messageTokenEstimate?: number + } +} + +// ─── Context Snapshot (persisted in SQLite) ──────────────── + +export interface ContextSnapshot { + roomId: string + summary: string + lastMessageId: string + lastMessageTimestamp: number + updatedAt: number +} + +// ─── Summary Cache ────────────────────────────────────────── + +export interface SummaryCacheEntry { + summary: string + lastMessageId: string + lastMessageTimestamp: number + createdAt: number +} + +// ─── Dependency Injection ────────────────────────────────── + +export interface MessageFetcher { + getMessages(roomId: string, limit?: number): StoredMessage[] + getContextSnapshot(roomId: string): ContextSnapshot | null + saveContextSnapshot(roomId: string, summary: string, lastMessageId: string, lastMessageTimestamp: number): void + deleteContextSnapshot(roomId: string): void +} + +export interface GatewayCaller { + summarize( + upstream: string, + apiKey: string | null, + systemPrompt: string, + messages: StoredMessage[], + roomId: string, + profile: string, + previousSummary?: string, + ): Promise<{ summary: string; sessionId: string }> +} + +export type SessionCleaner = (sessionId: string) => void + +export type ContextProgress = (event: { + status: 'compressing' + path: 'snapshot' | 'full' + messageCount: number + tokenCount: number +}) => void + +// ─── Build Context Input ─────────────────────────────────── + +export interface MemberInfo { + userId: string + name: string + description: string +} + +export interface BuildContextInput { + roomId: string + agentId: string + agentName: string + agentDescription: string + agentSocketId: string + roomName: string + memberNames: string[] + members: MemberInfo[] + upstream: string + apiKey: string | null + currentMessage: StoredMessage + compression?: Partial + profile?: string + contextTokenEstimator?: ( + history: Array<{ role: 'user' | 'assistant'; content: string }>, + instructions: string, + ) => Promise + onProgress?: ContextProgress +} diff --git a/packages/server/src/services/hermes/conversations.ts b/packages/server/src/services/hermes/conversations.ts new file mode 100644 index 0000000..9d6b474 --- /dev/null +++ b/packages/server/src/services/hermes/conversations.ts @@ -0,0 +1,436 @@ +import { exportSessionsRaw, type HermesSessionFull } from './hermes-cli' + +const LINEAGE_TOLERANCE_SECONDS = 3 +const LIVE_WINDOW_SECONDS = 300 +const EXPORT_CACHE_TTL_MS = 30000 +const DEFAULT_CONVERSATION_LIMIT = 200 +const SYNTHETIC_USER_PREFIXES = [ + '[system:', + "you've reached the maximum number of tool-calling iterations allowed.", + 'you have reached the maximum number of tool-calling iterations allowed.', +] + +type HermesMessageLike = { + id?: number | string + session_id?: string + role?: string + content?: unknown + timestamp?: number +} + +type ConversationSession = HermesSessionFull & { + parent_session_id?: string | null + preview: string + last_active: number + is_active: boolean +} + +type CachedExport = { + expires_at_ms: number + sessions: HermesSessionFull[] +} + +const exportCache = new Map() + +export interface ConversationSummary { + id: string + profile?: string | null + source: string + model: string + title: string | null + started_at: number + ended_at: number | null + last_active: number + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + preview: string + is_active: boolean + thread_session_count: number +} + +export interface ConversationMessage { + id: number | string + session_id: string + role: 'user' | 'assistant' + content: string + timestamp: number +} + +export interface ConversationDetail { + session_id: string + messages: ConversationMessage[] + visible_count: number + thread_session_count: number +} + +export interface ConversationListOptions { + source?: string + humanOnly?: boolean + limit?: number +} + +function cacheKey(source?: string): string { + return source || '__all__' +} + +function safeText(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return '' +} + +function textFromContent(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return value + .map(item => textFromContent(item).trim()) + .filter(Boolean) + .join('\n') + } + if (!value || typeof value !== 'object') return '' + + const record = value as Record + const directKeys = ['text', 'content', 'value'] as const + for (const key of directKeys) { + const direct = record[key] + if (typeof direct === 'string') return direct + if (Array.isArray(direct)) { + const nested = textFromContent(direct) + if (nested) return nested + } + } + + const nestedKeys = ['parts', 'children', 'items'] as const + for (const key of nestedKeys) { + if (Array.isArray(record[key])) { + const nested = textFromContent(record[key]) + if (nested) return nested + } + } + + const flattened = Object.values(record) + .map(entry => textFromContent(entry).trim()) + .filter(Boolean) + .join('\n') + if (flattened) return flattened + + try { + return JSON.stringify(record) + } catch { + return '' + } +} + +function normalizeText(value: unknown): string { + return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase() +} + +function excerpt(value: unknown, width = 80): string { + const text = textFromContent(value).replace(/\s+/g, ' ').trim() + if (!text) return '' + return text.length > width ? `${text.slice(0, width)}…` : text +} + +function isSyntheticUserText(content: unknown): boolean { + const text = normalizeText(content) + return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix)) +} + +function visibleHumanMessage(message: HermesMessageLike): boolean { + const role = safeText(message.role) + const content = textFromContent(message.content).trim() + if (!content) return false + if (role !== 'user' && role !== 'assistant') return false + if (role === 'user' && isSyntheticUserText(content)) return false + return true +} + +function firstVisibleHumanText(messages: HermesMessageLike[]): string { + const firstVisible = messages.find(visibleHumanMessage) + return firstVisible ? textFromContent(firstVisible.content).trim() : '' +} + +function maxMessageTimestamp(messages: HermesMessageLike[]): number { + return messages.reduce((max, message) => { + const timestamp = Number(message.timestamp || 0) + return Number.isFinite(timestamp) && timestamp > max ? timestamp : max + }, 0) +} + +function enrichSession(session: HermesSessionFull, nowSeconds: number): ConversationSession { + const messages = Array.isArray(session.messages) ? session.messages : [] + const preview = excerpt(firstVisibleHumanText(messages)) + const lastActive = maxMessageTimestamp(messages) || Number(session.ended_at || session.started_at || 0) + const endedAt = session.ended_at ?? null + return { + ...session, + parent_session_id: (session.parent_session_id as string | null | undefined) ?? null, + preview, + last_active: lastActive, + is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS, + } +} + +function sortByRecency(items: T[]): T[] { + return [...items].sort((a, b) => { + if (b.last_active !== a.last_active) return b.last_active - a.last_active + if (b.started_at !== a.started_at) return b.started_at - a.started_at + return a.id.localeCompare(b.id) + }) +} + +function timingMatchesParent(parent: ConversationSession | undefined, child: ConversationSession | undefined): boolean { + if (!parent || !child || parent.ended_at == null) return false + return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS +} + +function isBranchRoot(session: ConversationSession | undefined, byId: Map): boolean { + if (!session?.parent_session_id) return false + const parent = byId.get(session.parent_session_id) + return !!parent && parent.end_reason === 'branched' && timingMatchesParent(parent, session) +} + +function isVisibleRoot(session: ConversationSession | undefined, byId: Map): boolean { + if (!session || session.source === 'tool') return false + return session.parent_session_id == null || isBranchRoot(session, byId) +} + +function continuationCandidates(parent: ConversationSession, byId: Map, childrenByParent: Map): ConversationSession[] { + const childIds = childrenByParent.get(parent.id) || [] + return childIds + .map(childId => byId.get(childId)) + .filter((child): child is ConversationSession => !!child) + .filter(child => child.source !== 'tool') + .filter(child => child.source === parent.source) + .filter(child => timingMatchesParent(parent, child)) + .sort((a, b) => { + const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0)) + const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0)) + if (aDelta !== bDelta) return aDelta - bDelta + return a.id.localeCompare(b.id) + }) +} + +function nextContinuationChild(parent: ConversationSession, byId: Map, childrenByParent: Map): ConversationSession | null { + if (parent.end_reason !== 'compression') return null + const candidates = continuationCandidates(parent, byId, childrenByParent) + if (candidates.length === 1) return candidates[0] + + const exactPreviewMatches = candidates.filter(child => { + const childPreview = normalizeText(child.preview) + const parentPreview = normalizeText(parent.preview) + return !!childPreview && childPreview === parentPreview + }) + + if (exactPreviewMatches.length === 1) return exactPreviewMatches[0] + return null +} + +function collectConversationChain(rootId: string, byId: Map, childrenByParent: Map): ConversationSession[] { + const chain: ConversationSession[] = [] + const seen = new Set() + let current = byId.get(rootId) || null + while (current && !seen.has(current.id)) { + chain.push(current) + seen.add(current.id) + current = nextContinuationChild(current, byId, childrenByParent) + } + return chain +} + +function sessionMessages(session: HermesSessionFull): HermesMessageLike[] { + return Array.isArray(session.messages) ? session.messages as HermesMessageLike[] : [] +} + +function normalizeVisibleMessage(message: HermesMessageLike, session: HermesSessionFull, index: number): ConversationMessage | null { + if (!visibleHumanMessage(message)) return null + const role = safeText(message.role) + const content = textFromContent(message.content).trim() + if (role !== 'user' && role !== 'assistant') return null + if (!content) return null + + const rawTimestamp = Number(message.timestamp) + const timestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0 + ? rawTimestamp + : Number(session.ended_at || session.started_at || 0) + const id = message.id ?? `${session.id}:${index}:${timestamp}` + + return { + id, + session_id: safeText(message.session_id || session.id), + role, + content, + timestamp, + } +} + +function visibleMessagesForSessions(sessions: HermesSessionFull[]): ConversationMessage[] { + return sessions + .flatMap(session => sessionMessages(session).map((message, index) => normalizeVisibleMessage({ ...message, session_id: safeText(message.session_id || session.id) }, session, index))) + .filter((message): message is ConversationMessage => !!message) + .sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp + return String(a.id).localeCompare(String(b.id)) + }) +} + +function hasVisibleHumanMessages(sessions: HermesSessionFull[]): boolean { + return visibleMessagesForSessions(sessions).length > 0 +} + +function toSummary(session: ConversationSession): ConversationSummary { + return { + id: session.id, + source: safeText(session.source), + model: safeText(session.model), + title: session.title ?? null, + started_at: Number(session.started_at || 0), + ended_at: session.ended_at ?? null, + last_active: session.last_active, + message_count: Number(session.message_count || 0), + tool_call_count: Number(session.tool_call_count || 0), + input_tokens: Number(session.input_tokens || 0), + output_tokens: Number(session.output_tokens || 0), + cache_read_tokens: Number(session.cache_read_tokens || 0), + cache_write_tokens: Number(session.cache_write_tokens || 0), + reasoning_tokens: Number(session.reasoning_tokens || 0), + billing_provider: session.billing_provider ?? null, + estimated_cost_usd: Number(session.estimated_cost_usd || 0), + actual_cost_usd: session.actual_cost_usd ?? null, + cost_status: safeText(session.cost_status), + preview: session.preview, + is_active: session.is_active, + thread_session_count: 1, + } +} + +function aggregateSummary(rootId: string, byId: Map, childrenByParent: Map): ConversationSummary | null { + const chain = collectConversationChain(rootId, byId, childrenByParent) + if (!chain.length || !hasVisibleHumanMessages(chain)) return null + const root = chain[0] + const last = chain[chain.length - 1] + const title = root.title || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages)), 72) || null + const preview = root.preview || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages))) + const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean))) + + return { + ...toSummary(root), + title, + preview, + model: safeText(last?.model || root.model), + ended_at: last?.ended_at ?? null, + last_active: Math.max(...chain.map(session => session.last_active)), + is_active: chain.some(session => session.is_active), + billing_provider: last?.billing_provider ?? root.billing_provider ?? null, + cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed', + thread_session_count: chain.length, + message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0), + tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0), + input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0), + output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0), + cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0), + cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0), + reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0), + estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0), + actual_cost_usd: chain.reduce((sum, session) => { + const actual = session.actual_cost_usd + if (actual == null) return sum + return (sum || 0) + Number(actual) + }, null), + } +} + +async function loadSessions(source?: string): Promise { + const key = cacheKey(source) + const nowMs = Date.now() + const cached = exportCache.get(key) + const raws = cached && cached.expires_at_ms > nowMs + ? cached.sessions + : await exportSessionsRaw(source) + + if (!cached || cached.expires_at_ms <= nowMs) { + exportCache.set(key, { + expires_at_ms: nowMs + EXPORT_CACHE_TTL_MS, + sessions: raws, + }) + } + + const nowSeconds = nowMs / 1000 + return raws.map(raw => enrichSession(raw, nowSeconds)) +} + +export async function listConversationSummaries(options: ConversationListOptions = {}): Promise { + const humanOnly = options.humanOnly !== false + const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT + const sessions = await loadSessions(options.source) + const byId = new Map(sessions.map(session => [session.id, session])) + const childrenByParent = new Map() + for (const session of sessions) { + const key = session.parent_session_id ?? null + const siblings = childrenByParent.get(key) || [] + siblings.push(session.id) + childrenByParent.set(key, siblings) + } + + if (!humanOnly) { + return sortByRecency( + sessions + .filter(session => session.source !== 'tool') + .map(toSummary), + ).slice(0, limit) + } + + const summaries = sessions + .filter(session => isVisibleRoot(session, byId)) + .map(session => aggregateSummary(session.id, byId, childrenByParent)) + .filter((summary): summary is ConversationSummary => !!summary) + + return sortByRecency(summaries).slice(0, limit) +} + +export async function getConversationDetail(sessionId: string, options: ConversationListOptions = {}): Promise { + const humanOnly = options.humanOnly !== false + const sessions = await loadSessions(options.source) + const byId = new Map(sessions.map(session => [session.id, session])) + const childrenByParent = new Map() + for (const session of sessions) { + const key = session.parent_session_id ?? null + const siblings = childrenByParent.get(key) || [] + siblings.push(session.id) + childrenByParent.set(key, siblings) + } + + if (!humanOnly) { + const session = byId.get(sessionId) + if (!session || session.source === 'tool') return null + const messages = visibleMessagesForSessions([session]) + return { + session_id: sessionId, + messages, + visible_count: messages.length, + thread_session_count: 1, + } + } + + const root = byId.get(sessionId) + if (!isVisibleRoot(root, byId)) return null + const chain = collectConversationChain(sessionId, byId, childrenByParent) + const messages = visibleMessagesForSessions(chain) + if (!messages.length) return null + return { + session_id: sessionId, + messages, + visible_count: messages.length, + thread_session_count: chain.length, + } +} diff --git a/packages/server/src/services/hermes/copilot-device-flow.ts b/packages/server/src/services/hermes/copilot-device-flow.ts new file mode 100644 index 0000000..9645502 --- /dev/null +++ b/packages/server/src/services/hermes/copilot-device-flow.ts @@ -0,0 +1,158 @@ +/** + * GitHub OAuth Device Flow for Copilot login. + * + * Mirrors the upstream hermes-agent implementation + * (`hermes_cli/copilot_auth.py:155-275`): + * - POST https://github.com/login/device/code → device_code, user_code, verification_uri + * - POST https://github.com/login/oauth/access_token → access_token (after user approves) + * - Polling rules per RFC 8628: authorization_pending, slow_down, expired_token, access_denied + * + * Client ID `Ov23li8tweQw6odWQebz` is reused from upstream hermes-agent for now; + * a dedicated web-ui OAuth App can be registered later without changing the protocol. + */ + +const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code' +const GITHUB_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token' +export const COPILOT_OAUTH_CLIENT_ID = 'Ov23li8tweQw6odWQebz' +export const COPILOT_OAUTH_SCOPE = 'read:user' +const FETCH_TIMEOUT_MS = 15_000 + +export interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +export interface AccessTokenSuccess { + kind: 'success' + access_token: string + token_type: string + scope: string +} + +export interface AccessTokenPending { + kind: 'pending' +} + +export interface AccessTokenSlowDown { + kind: 'slow_down' +} + +export interface AccessTokenDenied { + kind: 'denied' +} + +export interface AccessTokenExpired { + kind: 'expired' +} + +export interface AccessTokenError { + kind: 'error' + error: string + description?: string +} + +export type AccessTokenResult = + | AccessTokenSuccess + | AccessTokenPending + | AccessTokenSlowDown + | AccessTokenDenied + | AccessTokenExpired + | AccessTokenError + +/** + * Request a fresh device code from GitHub. Throws on network failure or non-2xx. + */ +export async function startDeviceFlow( + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await fetchImpl(GITHUB_DEVICE_CODE_URL, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: COPILOT_OAUTH_CLIENT_ID, + scope: COPILOT_OAUTH_SCOPE, + }).toString(), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`GitHub device code request failed: ${res.status} ${text}`) + } + + const data = await res.json() as Partial + if (!data.device_code || !data.user_code || !data.verification_uri) { + throw new Error('GitHub device code response missing required fields') + } + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri, + expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900, + interval: typeof data.interval === 'number' && data.interval > 0 ? data.interval : 5, + } +} + +/** + * Poll the access-token endpoint once. Caller is responsible for sleeping the + * server-suggested `interval` between calls and handling slow_down/expired. + */ +export async function pollDeviceFlow( + deviceCode: string, + fetchImpl: typeof fetch = fetch, +): Promise { + let res: Response + try { + res = await fetchImpl(GITHUB_ACCESS_TOKEN_URL, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: COPILOT_OAUTH_CLIENT_ID, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + } catch (err: any) { + return { kind: 'error', error: 'network', description: err?.message ?? String(err) } + } + + let body: any + try { + body = await res.json() + } catch { + return { kind: 'error', error: 'parse', description: `HTTP ${res.status}` } + } + + if (body && typeof body.access_token === 'string' && body.access_token) { + return { + kind: 'success', + access_token: body.access_token, + token_type: body.token_type ?? 'bearer', + scope: body.scope ?? COPILOT_OAUTH_SCOPE, + } + } + + const code = typeof body?.error === 'string' ? body.error : 'unknown_error' + switch (code) { + case 'authorization_pending': + return { kind: 'pending' } + case 'slow_down': + return { kind: 'slow_down' } + case 'access_denied': + return { kind: 'denied' } + case 'expired_token': + return { kind: 'expired' } + default: + return { kind: 'error', error: code, description: body?.error_description } + } +} diff --git a/packages/server/src/services/hermes/copilot-models.ts b/packages/server/src/services/hermes/copilot-models.ts new file mode 100644 index 0000000..c2dc197 --- /dev/null +++ b/packages/server/src/services/hermes/copilot-models.ts @@ -0,0 +1,272 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { readFile } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' + +const execFileAsync = promisify(execFile) + +const COPILOT_API_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token' +const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models' +const EDITOR_VERSION = 'vscode/1.104.1' +const PLUGIN_VERSION = 'copilot-chat/0.20.0' +const USER_AGENT = 'GithubCopilot/1.155.0' +const FETCH_TIMEOUT_MS = 8000 +const POSITIVE_TTL_MS = 60 * 60 * 1000 +const NEGATIVE_TTL_MS = 60 * 1000 + +export interface CopilotModelMeta { + id: string + preview: boolean + disabled: boolean +} + +const FALLBACK_MODELS: CopilotModelMeta[] = [ + { id: 'gpt-5.5', preview: false, disabled: false }, + { id: 'gpt-5.4', preview: false, disabled: false }, + { id: 'gpt-5.4-mini', preview: false, disabled: false }, + { id: 'gpt-5.4-nano', preview: false, disabled: false }, + { id: 'gpt-5-mini', preview: false, disabled: false }, + { id: 'gpt-5.3-codex', preview: false, disabled: false }, + { id: 'claude-opus-4.8', preview: false, disabled: false }, + { id: 'claude-opus-4.7', preview: false, disabled: false }, + { id: 'claude-opus-4.6', preview: false, disabled: false }, + { id: 'claude-opus-4.6-fast', preview: true, disabled: false }, + { id: 'claude-opus-4.5', preview: false, disabled: false }, + { id: 'claude-haiku-4.5', preview: false, disabled: false }, + { id: 'claude-sonnet-4.6', preview: false, disabled: false }, + { id: 'claude-sonnet-4.5', preview: false, disabled: false }, + { id: 'gemini-2.5-pro', preview: false, disabled: false }, + { id: 'gemini-3-flash', preview: true, disabled: false }, + { id: 'gemini-3.1-pro', preview: true, disabled: false }, + { id: 'gemini-3.5-flash', preview: false, disabled: false }, + { id: 'raptor-mini', preview: true, disabled: false }, +] + +interface CacheEntry { + value: CopilotModelMeta[] + expiresAt: number + isFallback: boolean +} + +// 缓存按 oauth token 隔离:避免切换 hermes profile(不同 .env / 不同 Copilot 账号) +// 时仍命中上一个账号的模型列表 + preview/disabled 状态。key 为 token 的非密码学哈希 +// (不直接用明文 token 作 key,减少日志/调试时泄漏风险)。无 token 场景使用 "__none__"。 +const cacheByToken: Map = new Map() +const inflightByToken: Map> = new Map() + +function tokenCacheKey(oauthToken: string): string { + if (!oauthToken) return '__none__' + // FNV-1a 32-bit;够用作 cache key + let h = 0x811c9dc5 + for (let i = 0; i < oauthToken.length; i++) { + h ^= oauthToken.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0).toString(16) +} + +function unquote(raw: string): string { + const v = raw.trim() + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1) + } + return v +} + +function readEnvVar(envContent: string, key: string): string { + if (process.env[key]) return unquote(process.env[key]!) + const m = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm')) + if (m && m[1].trim() && !m[1].trim().startsWith('#')) return unquote(m[1]) + return '' +} + +// classic PATs (ghp_) cannot be used as Copilot OAuth tokens — mirror upstream +// hermes-agent copilot_auth.py and skip them so callers fall through. +function isUsableOAuthToken(token: string): boolean { + if (!token) return false + if (token.startsWith('ghp_')) return false + return true +} + +async function readGhAppsToken(): Promise { + const candidates = [ + join(homedir(), '.config', 'github-copilot', 'apps.json'), + join(homedir(), '.config', 'github-copilot', 'hosts.json'), + ] + for (const path of candidates) { + try { + const text = await readFile(path, 'utf-8') + const data = JSON.parse(text) + for (const v of Object.values(data) as any[]) { + const tok = v?.oauth_token + if (typeof tok === 'string' && isUsableOAuthToken(tok.trim())) return tok.trim() + } + } catch { /* skip */ } + } + return '' +} + +/** + * 解析 Copilot OAuth token,按 web-ui 的优先级顺序: + * 1. COPILOT_GITHUB_TOKEN 2. GH_TOKEN 3. GITHUB_TOKEN + * 4. ~/.config/github-copilot/apps.json (VS Code Copilot 插件存储) + * 5. `gh auth token` CLI fallback + * 跳过 classic PAT (ghp_),与上游 hermes-agent copilot_auth.py 行为对齐。 + * 这是单一事实来源 —— 授权检测和模型拉取都应使用此函数。 + */ +export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null + +export async function resolveCopilotOAuthTokenWithSource( + envContent: string, +): Promise<{ token: string; source: CopilotTokenSource }> { + for (const key of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN']) { + const v = readEnvVar(envContent, key) + if (isUsableOAuthToken(v)) return { token: v, source: 'env' } + } + const appsToken = await readGhAppsToken() + if (appsToken) return { token: appsToken, source: 'apps-json' } + try { + const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 3000, windowsHide: true }) + const v = stdout.trim() + if (isUsableOAuthToken(v)) return { token: v, source: 'gh-cli' } + } catch { /* ignore */ } + return { token: '', source: null } +} + +export async function resolveCopilotOAuthToken(envContent: string): Promise { + const { token } = await resolveCopilotOAuthTokenWithSource(envContent) + return token +} + +async function exchangeForCopilotToken(oauthToken: string): Promise { + const res = await fetch(COPILOT_API_TOKEN_URL, { + headers: { + 'Authorization': `token ${oauthToken}`, + 'Editor-Version': EDITOR_VERSION, + 'Editor-Plugin-Version': PLUGIN_VERSION, + 'User-Agent': USER_AGENT, + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`token exchange ${res.status}`) + const data = await res.json() as { token?: string } + if (!data.token) throw new Error('no token in response') + return data.token +} + +// ID 噪音过滤: +// - text-embedding-* / *-embedding-* —— 嵌入模型(chat type 已过滤掉,但保留显式清单防御) +// - accounts/msft/routers/* —— Copilot 内部路由模型,UI 模型 ID(带斜杠)会破坏 selectbox,且不可读 +// - rerank* —— rerank 模型 +// 与 opencode/models.dev 的 curated 思路一致:剔除明显非聊天用途的噪音 ID。 +const NOISE_ID_PREFIXES = ['accounts/', 'text-embedding', 'rerank'] + +function isNoiseModelId(id: string): boolean { + const lower = id.toLowerCase() + return NOISE_ID_PREFIXES.some((p) => lower.startsWith(p)) +} + +async function fetchModelsList(copilotToken: string): Promise { + const res = await fetch(COPILOT_MODELS_URL, { + headers: { + 'Authorization': `Bearer ${copilotToken}`, + 'Editor-Version': EDITOR_VERSION, + 'Copilot-Integration-Id': 'vscode-chat', + 'User-Agent': USER_AGENT, + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`models fetch ${res.status}`) + const data = await res.json() as { data?: any[] } + if (!Array.isArray(data.data)) return [] + // 与上游 hermes-agent hermes_cli/models.py 对齐:只过滤 chat type 且 supports + // /chat/completions endpoint。不强制 model_picker_enabled —— 用户可能想用未在 IDE + // picker 里的模型(用户决定全量展示,由用户自行判断订阅是否覆盖)。 + // 额外去掉噪音 ID(embedding/rerank/router)。 + const seen = new Set() + const out: CopilotModelMeta[] = [] + for (const m of data.data) { + if (m?.capabilities?.type !== 'chat') continue + const endpoints = m?.supported_endpoints + if (Array.isArray(endpoints) && endpoints.length > 0) { + if (!endpoints.includes('/chat/completions')) continue + } + const id = String(m?.id ?? '').trim() + if (!id || seen.has(id)) continue + if (isNoiseModelId(id)) continue + seen.add(id) + out.push({ + id, + preview: m?.preview === true, + disabled: m?.policy?.state === 'disabled', + }) + } + return out +} + +async function loadModelsWithToken(oauth: string): Promise { + if (!oauth) throw new Error('no oauth token') + const copilotToken = await exchangeForCopilotToken(oauth) + const models = await fetchModelsList(copilotToken) + if (models.length === 0) throw new Error('empty model list') + return models +} + +/** + * 获取 GitHub Copilot 当前账号可用的 chat 模型列表(含 preview/disabled meta)。 + * - 缓存按 oauth token 隔离(profile 切换不会串 + * - 正缓存 1 小时(成功结果) + * - 负缓存 60 秒(失败时缓存 fallback,避免抖动重复打慢路径) + * - 并发请求合并:同一 token 的同时多次调用复用 inflight Promise + */ +export async function getCopilotModelsDetailed(envContent: string): Promise { + // 先解析 oauth token —— 这一步本身有 fs 读取,但不会发网络请求;用作 cache key。 + const oauth = await resolveCopilotOAuthToken(envContent) + const key = tokenCacheKey(oauth) + const now = Date.now() + const hit = cacheByToken.get(key) + if (hit && hit.expiresAt > now) return hit.value + const existing = inflightByToken.get(key) + if (existing) return existing + const promise = (async () => { + try { + const models = await loadModelsWithToken(oauth) + cacheByToken.set(key, { value: models, expiresAt: Date.now() + POSITIVE_TTL_MS, isFallback: false }) + return models + } catch { + cacheByToken.set(key, { value: FALLBACK_MODELS, expiresAt: Date.now() + NEGATIVE_TTL_MS, isFallback: true }) + return FALLBACK_MODELS + } finally { + inflightByToken.delete(key) + } + })() + inflightByToken.set(key, promise) + return promise +} + +/** 兼容旧调用:只返回 ID 列表。 */ +export async function getCopilotModels(envContent: string): Promise { + const detailed = await getCopilotModelsDetailed(envContent) + return detailed.map((m) => m.id) +} + +/** 仅供测试使用:清空所有缓存与 inflight 状态。 */ +export function __resetCopilotModelsCacheForTest(): void { + cacheByToken.clear() + inflightByToken.clear() +} + +/** + * 注销 / 切换账号后必须调用:清空所有 token 桶下的模型列表缓存与 inflight。 + * 否则下一次查询仍会命中旧账号的 cache(key 是 token 哈希;删除 token 后 + * key 变为 "__none__" 不会撞,但旧 key 的旧数据仍残留并继续返回过期模型)。 + */ +export function invalidateAllCaches(): void { + cacheByToken.clear() + inflightByToken.clear() +} + +export const COPILOT_FALLBACK_MODELS = FALLBACK_MODELS diff --git a/packages/server/src/services/hermes/file-provider.ts b/packages/server/src/services/hermes/file-provider.ts new file mode 100644 index 0000000..eb80ec1 --- /dev/null +++ b/packages/server/src/services/hermes/file-provider.ts @@ -0,0 +1,863 @@ +import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises' +import { resolve, normalize, isAbsolute, basename, join } from 'path' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { existsSync, readFileSync } from 'fs' +import YAML from 'js-yaml' +import { config } from '../../config' +import { getActiveProfileDir, getActiveEnvPath, getProfileDir } from './hermes-profile' +import { isPathWithin, relativePathFromBase } from './hermes-path' + +const execFileAsync = promisify(execFile) +const execOpts = { windowsHide: true } + +// Max download file size (default 200MB) +const MAX_DOWNLOAD_SIZE = parseInt(process.env.MAX_DOWNLOAD_SIZE || '', 10) || 200 * 1024 * 1024 +// Backend command timeout (default 30s) +const BACKEND_TIMEOUT = 30_000 + +// Max edit/upload file size (default 10MB) +export const MAX_EDIT_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024 + +// Sensitive files that should not be written/deleted/renamed +const SENSITIVE_FILES = new Set(['.env', 'auth.json']) + +export interface FileEntry { + name: string + path: string // relative to hermes home + isDir: boolean + size: number + modTime: string // ISO 8601 +} + +export interface FileStat { + name: string + path: string // relative to hermes home + isDir: boolean + size: number + modTime: string // ISO 8601 + permissions?: string +} + +export type BackendType = 'local' | 'docker' | 'ssh' | 'singularity' | 'modal' | 'daytona' + +export interface FileProvider { + type: BackendType + readFile(filePath: string): Promise + exists(filePath: string): Promise + listDir(dirPath: string): Promise + stat(filePath: string): Promise + writeFile(filePath: string, content: Buffer): Promise + deleteFile(filePath: string): Promise + deleteDir(dirPath: string): Promise + renameFile(oldPath: string, newPath: string): Promise + mkDir(dirPath: string): Promise + copyFile(srcPath: string, destPath: string): Promise +} + +export interface TerminalConfig { + backend: BackendType + docker_image?: string + docker_container_name?: string + cwd?: string + singularity_image?: string + ssh_port?: number +} + +/** + * Validate a file path: must be absolute and not contain '..' traversal. + */ +export function normalizePlatformPath(filePath: string, platform = process.platform): string { + if (platform !== 'win32') return filePath + const msysDrivePath = filePath.match(/^\/([a-zA-Z])(?:\/(.*))?$/) + if (!msysDrivePath) return filePath + const [, drive, rest = ''] = msysDrivePath + return `${drive.toUpperCase()}:\\${rest.replace(/\//g, '\\')}` +} + +export function validatePath(filePath: string): string { + if (!filePath) throw Object.assign(new Error('Missing file path'), { code: 'missing_path' }) + const resolved = resolve(normalizePlatformPath(filePath)) + const normalized = normalize(resolved) + if (normalized.includes('..')) { + throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' }) + } + if (!isAbsolute(normalized)) { + throw Object.assign(new Error('Path must be absolute'), { code: 'invalid_path' }) + } + return normalized +} + +/** + * Check if a path is inside the upload directory. + */ +export function isInUploadDir(filePath: string): boolean { + return isPathWithin(filePath, config.uploadDir) +} + +function homeDirForProfile(profile?: string): string { + return profile ? getProfileDir(profile) : getActiveProfileDir() +} + +function envPathForProfile(profile?: string): string { + return profile ? join(getProfileDir(profile), '.env') : getActiveEnvPath() +} + +/** + * Check if a relative path refers to a sensitive file. + */ +export function isSensitivePath(relativePath: string): boolean { + const parts = relativePath.replace(/\\/g, '/').split('/') + const fileName = parts[parts.length - 1] + return SENSITIVE_FILES.has(fileName) +} + +/** + * Resolve a relative path to an absolute path under the hermes home directory. + * Validates path safety (no traversal). + */ +export function resolveHermesPath(relativePath: string, profile?: string): string { + const homeDir = homeDirForProfile(profile) + if (!relativePath || relativePath === '.' || relativePath === '/') { + return homeDir + } + const normalized = normalize(relativePath).replace(/\\/g, '/') + if (normalized.startsWith('..') || normalized.includes('/../') || normalized.startsWith('/')) { + throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' }) + } + const resolved = resolve(homeDir, normalized) + if (!isPathWithin(resolved, homeDir)) { + throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' }) + } + return resolved +} + +// --- Local --- + +export class LocalFileProvider implements FileProvider { + type: BackendType = 'local' + constructor(private homeDir = getActiveProfileDir()) {} + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + const s = await fsStat(p) + if (!s.isFile()) throw Object.assign(new Error('Not a file'), { code: 'not_found' }) + if (s.size > MAX_DOWNLOAD_SIZE) { + throw Object.assign(new Error(`File too large: ${s.size} bytes`), { code: 'file_too_large' }) + } + return readFile(p) + } + + async exists(filePath: string): Promise { + try { + const p = validatePath(filePath) + const s = await fsStat(p) + return s.isFile() + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + const entries = await readdir(p, { withFileTypes: true }) + const results: FileEntry[] = [] + for (const entry of entries) { + try { + const fullPath = resolve(p, entry.name) + const s = await fsStat(fullPath) + const relPath = relativePathFromBase(fullPath, this.homeDir) ?? entry.name + results.push({ + name: entry.name, + path: relPath, + isDir: s.isDirectory(), + size: s.size, + modTime: s.mtime.toISOString(), + }) + } catch { + // skip entries that fail to stat + } + } + return results + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + const s = await fsStat(p) + const relPath = relativePathFromBase(p, this.homeDir) ?? basename(p) + return { + name: basename(p), + path: relPath || basename(p), + isDir: s.isDirectory(), + size: s.size, + modTime: s.mtime.toISOString(), + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + await fsWriteFile(p, content) + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + const s = await fsStat(p) + if (!s.isFile()) throw Object.assign(new Error('Not a file'), { code: 'not_found' }) + await rm(p) + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + const s = await fsStat(p) + if (!s.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'not_found' }) + await rm(p, { recursive: true }) + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + await rename(op, np) + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + await mkdir(p, { recursive: true }) + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + await fsCopyFile(sp, dp) + } +} + +/** + * Parse `ls -la --time-style=+%Y-%m-%dT%H:%M:%S` output into FileEntry[]. + * Example line: `drwxr-xr-x 2 user group 4096 2025-07-20T10:30:00 dirname` + * Skips the "total N" line and entries "." and "..". + */ +function parseLsOutput(output: string, parentRelPath: string): FileEntry[] { + const entries: FileEntry[] = [] + for (const line of output.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('total ')) continue + const parts = trimmed.split(/\s+/) + if (parts.length < 7) continue + const permissions = parts[0] + const size = parseInt(parts[4], 10) || 0 + const modTime = parts[5] + const name = parts.slice(6).join(' ') + if (name === '.' || name === '..') continue + const isDir = permissions.startsWith('d') + const relPath = parentRelPath ? `${parentRelPath}/${name}` : name + entries.push({ name, path: relPath, isDir, size, modTime: modTime.includes('T') ? modTime : new Date(modTime).toISOString() }) + } + return entries +} + +/** + * Parse `stat -c '%n|%F|%s|%Y'` output. + * Output: `/path/to/file|regular file|1234|1721500000` + */ +function parseStatOutput(output: string, relativePath: string): FileStat { + const parts = output.trim().split('|') + if (parts.length < 4) throw Object.assign(new Error('Failed to parse stat output'), { code: 'backend_error' }) + const name = basename(parts[0]) + const fileType = parts[1].toLowerCase() + const size = parseInt(parts[2], 10) || 0 + const modEpoch = parseInt(parts[3], 10) || 0 + const isDir = fileType.includes('directory') + return { + name, + path: relativePath, + isDir, + size, + modTime: new Date(modEpoch * 1000).toISOString(), + } +} + +// --- Docker --- + +export class DockerFileProvider implements FileProvider { + type: BackendType = 'docker' + private containerName: string + private homeDir: string + + constructor(containerName: string, homeDir = getActiveProfileDir()) { + this.containerName = containerName + this.homeDir = homeDir + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'cat', p, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found in container'), { code: 'not_found' }) + } + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', [ + 'exec', this.containerName, 'test', '-f', p, + ], { timeout: 5000, ...execOpts }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts }) + const relParent = relativePathFromBase(p, this.homeDir) ?? '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('docker', [ + 'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p, + ], { timeout: BACKEND_TIMEOUT, ...execOpts }) + const relPath = relativePathFromBase(p, this.homeDir) ?? basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', [ + 'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`, + ], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- SSH --- + +export class SSHFileProvider implements FileProvider { + type: BackendType = 'ssh' + private host: string + private user: string + private keyPath?: string + private port?: number + private homeDir: string + + constructor(host: string, user: string, keyPath?: string, homeDir = getActiveProfileDir(), port?: number) { + this.host = host + this.user = user + this.keyPath = keyPath + this.port = port + this.homeDir = homeDir + } + + private sshArgs(): string[] { + const args = ['-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'] + if (this.port) args.push('-p', String(this.port)) + if (this.keyPath) args.push('-i', this.keyPath) + args.push(`${this.user}@${this.host}`) + return args + } + + /** + * Shell-escape a string for safe use in a remote SSH command. + * Wraps in single quotes and escapes embedded single quotes. + */ + private shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'" + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + // Pass a single quoted command string to prevent shell injection on remote + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `cat ${this.shellEscape(p)}`, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found on remote'), { code: 'not_found' }) + } + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [ + ...this.sshArgs(), `test -f ${this.shellEscape(p)}`, + ], { timeout: 5000, ...execOpts }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts }) + const relParent = relativePathFromBase(p, this.homeDir) ?? '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('ssh', [ + ...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`, + ], { timeout: BACKEND_TIMEOUT, ...execOpts }) + const relPath = relativePathFromBase(p, this.homeDir) ?? basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [ + ...this.sshArgs(), `cat > ${this.shellEscape(p)}`, + ], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- Singularity --- + +export class SingularityFileProvider implements FileProvider { + type: BackendType = 'singularity' + private imagePath: string + private homeDir: string + + constructor(imagePath: string, homeDir = getActiveProfileDir()) { + this.imagePath = imagePath + this.homeDir = homeDir + } + + async readFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + // Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'cat', p, + ], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts }) + return stdout as unknown as Buffer + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) { + throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + } + if (err.stderr && /no such file/i.test(String(err.stderr))) { + throw Object.assign(new Error('File not found in container'), { code: 'not_found' }) + } + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async exists(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', [ + 'exec', this.imagePath, 'test', '-f', p, + ], { timeout: 5000, ...execOpts }) + return true + } catch { + return false + } + } + + async listDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p, + ], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts }) + const relParent = relativePathFromBase(p, this.homeDir) ?? '' + return parseLsOutput(stdout, relParent) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file|not a directory/i.test(String(err.stderr))) + throw Object.assign(new Error('Directory not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async stat(filePath: string): Promise { + const p = validatePath(filePath) + try { + const { stdout } = await execFileAsync('singularity', [ + 'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p, + ], { timeout: BACKEND_TIMEOUT, ...execOpts }) + const relPath = relativePathFromBase(p, this.homeDir) ?? basename(p) + return parseStatOutput(stdout, relPath) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + if (err.stderr && /no such file/i.test(String(err.stderr))) throw Object.assign(new Error('Not found'), { code: 'not_found' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async writeFile(filePath: string, content: Buffer): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', [ + 'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`, + ], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteFile(filePath: string): Promise { + const p = validatePath(filePath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async deleteDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async renameFile(oldPath: string, newPath: string): Promise { + const op = validatePath(oldPath) + const np = validatePath(newPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async mkDir(dirPath: string): Promise { + const p = validatePath(dirPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } + + async copyFile(srcPath: string, destPath: string): Promise { + const sp = validatePath(srcPath) + const dp = validatePath(destPath) + try { + await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts }) + } catch (err: any) { + if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' }) + throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' }) + } + } +} + +// --- Config helpers --- + +/** + * Read terminal config from hermes config.yaml. + */ +export function getTerminalConfig(profile?: string): TerminalConfig { + try { + const configPath = join(homeDirForProfile(profile), 'config.yaml') + if (!existsSync(configPath)) return { backend: 'local' } + const raw = readFileSync(configPath, 'utf-8') + const doc = YAML.load(raw, { json: true }) as any + const t = doc?.terminal || {} + return { + backend: (t.backend as BackendType) || 'local', + docker_image: t.docker_image, + docker_container_name: t.docker_container_name, + cwd: t.cwd, + singularity_image: t.singularity_image, + } + } catch { + return { backend: 'local' } + } +} + +/** + * Read SSH env vars from hermes .env file. + */ +function getSSHEnvVars(profile?: string): { host?: string; user?: string; key?: string; port?: number } { + try { + const envPath = envPathForProfile(profile) + if (!existsSync(envPath)) return {} + const raw = readFileSync(envPath, 'utf-8') + const vars: Record = {} + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + let value = trimmed.slice(eqIdx + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + vars[trimmed.slice(0, eqIdx).trim()] = value + } + return { + host: vars.TERMINAL_SSH_HOST, + user: vars.TERMINAL_SSH_USER, + key: vars.TERMINAL_SSH_KEY, + port: vars.TERMINAL_SSH_PORT ? parseInt(vars.TERMINAL_SSH_PORT, 10) : undefined, + } + } catch { + return {} + } +} + +/** + * Resolve Docker container name. If not configured, try to find a running + * container based on the configured image. + */ +async function resolveDockerContainer(cfg: TerminalConfig): Promise { + if (cfg.docker_container_name) return cfg.docker_container_name + if (cfg.docker_image) { + try { + const { stdout } = await execFileAsync('docker', [ + 'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest', + ], { timeout: 5000, ...execOpts }) + const id = stdout.trim() + if (id) return id + } catch { } + } + throw Object.assign( + new Error('Cannot determine Docker container. Set terminal.docker_container_name in hermes config.'), + { code: 'backend_error' }, + ) +} + +// --- Factory --- + +// Cache providers for a short time to avoid re-reading config on every request +const providerCache = new Map() +const CACHE_TTL = 10_000 + +/** @internal — for testing only */ +export function _resetFileProviderCache() { + providerCache.clear() +} + +/** + * Create a FileProvider based on the active hermes terminal config. + * Defaults to LocalFileProvider if config cannot be read or backend is unknown. + */ +export async function createFileProvider(profile?: string): Promise { + const now = Date.now() + const homeDir = homeDirForProfile(profile) + const cacheKey = profile || homeDir + const cached = providerCache.get(cacheKey) + if (cached && now - cached.cachedAt < CACHE_TTL) return cached.provider + + const cfg = getTerminalConfig(profile) + let provider: FileProvider + + switch (cfg.backend) { + case 'docker': { + const container = await resolveDockerContainer(cfg) + provider = new DockerFileProvider(container, homeDir) + break + } + case 'ssh': { + const ssh = getSSHEnvVars(profile) + if (!ssh.host || !ssh.user) { + throw Object.assign( + new Error('SSH backend requires TERMINAL_SSH_HOST and TERMINAL_SSH_USER in .env'), + { code: 'backend_error' }, + ) + } + provider = new SSHFileProvider(ssh.host, ssh.user, ssh.key, homeDir, ssh.port) + break + } + case 'singularity': { + if (!cfg.singularity_image) { + throw Object.assign( + new Error('Singularity backend requires terminal.singularity_image in config'), + { code: 'backend_error' }, + ) + } + provider = new SingularityFileProvider(cfg.singularity_image, homeDir) + break + } + case 'modal': + case 'daytona': + throw Object.assign( + new Error(`File download not yet supported for '${cfg.backend}' backend`), + { code: 'unsupported_backend' }, + ) + default: + provider = new LocalFileProvider(homeDir) + } + + providerCache.set(cacheKey, { provider, cachedAt: now }) + return provider +} + +// Always-available local provider for upload directory files +const localProvider = new LocalFileProvider() +export { localProvider, MAX_DOWNLOAD_SIZE } diff --git a/packages/server/src/services/hermes/gateway-autostart.ts b/packages/server/src/services/hermes/gateway-autostart.ts new file mode 100644 index 0000000..00c79b8 --- /dev/null +++ b/packages/server/src/services/hermes/gateway-autostart.ts @@ -0,0 +1,294 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { stripLegacyApiServerGatewayConfig } from '../config-helpers' +import { logger } from '../logger' +import { safeFileStore } from '../safe-file-store' +import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile' +import { startGatewayRunManaged } from './gateway-runner' +import { parseGatewayStatusesFromProfileList } from './profile-list-parser' +import { execHermesWithBin } from './hermes-process' + +const RESERVED_PROFILE_NAMES = new Set([ + 'hermes', 'test', 'tmp', 'root', 'sudo', +]) + +const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([ + 'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout', + 'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools', + 'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall', + 'profile', 'plugins', 'honcho', 'acp', +]) + +function resolveHermesBin(): string { + return process.env.HERMES_BIN?.trim() || 'hermes' +} + +function isReservedProfileName(profile: string): boolean { + const normalized = String(profile || '').trim().toLowerCase() + if (!normalized || normalized === 'default') return false + return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized) +} + +function isDockerRuntime(): boolean { + return existsSync('/.dockerenv') +} + +function isTermuxRuntime(): boolean { + const prefix = process.env.PREFIX || '' + return !!process.env.TERMUX_VERSION || + prefix.includes('/com.termux/') || + existsSync('/data/data/com.termux/files/usr') +} + +function envFlagEnabled(name: string): boolean { + const value = String(process.env[name] || '').trim().toLowerCase() + return ['1', 'true', 'yes', 'on'].includes(value) +} + +export function shouldUseManagedGatewayRun(): boolean { + return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') || + isDockerRuntime() || + isTermuxRuntime() || + process.platform === 'win32' +} + +export function shouldUseManagedGatewayRunForAutostart(platform: NodeJS.Platform = process.platform): boolean { + return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') || + isDockerRuntime() || + isTermuxRuntime() || + platform === 'win32' +} + +export function gatewayStatusLooksRunning(output: string): boolean { + const text = output.toLowerCase() + if (text.includes('gateway is not running') || text.includes('not running')) return false + return text.includes('gateway is running') || text.includes('running') +} + +export function gatewayStatusLooksRuntimeLocked(output: string): boolean { + const text = output.toLowerCase() + return text.includes('runtime lock is already held') + || text.includes('gateway runtime lock is already held') + || text.includes('already held by another instance') +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (err: any) { + return err?.code === 'EPERM' + } +} + +function readJsonPid(path: string): number | null { + if (!existsSync(path)) return null + try { + const data = JSON.parse(readFileSync(path, 'utf-8')) + const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10) + return Number.isFinite(pid) && pid > 0 ? pid : null + } catch { + return null + } +} + +export function gatewayStateLooksRunningForProfile(profileDir: string): boolean { + const statePath = join(profileDir, 'gateway_state.json') + if (existsSync(statePath)) { + try { + const data = JSON.parse(readFileSync(statePath, 'utf-8')) + const state = String(data?.gateway_state || '').toLowerCase() + const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10) + if ((state === 'running' || state === 'starting') && isProcessAlive(pid)) return true + } catch {} + } + + const pid = readJsonPid(join(profileDir, 'gateway.pid')) + return pid !== null && isProcessAlive(pid) +} + +export function parseGatewayStatusesFromProfileListOutput(stdout: string, profileNames = listProfileNamesFromDisk()): Map { + return parseGatewayStatusesFromProfileList(stdout, profileNames) +} + +async function listGatewayStatusesFromProfileList(hermesBin: string): Promise> { + const { stdout } = await execHermesWithBin(hermesBin, ['profile', 'list'], { + timeout: 10000, + windowsHide: true, + }) + return parseGatewayStatusesFromProfileListOutput(stdout) +} + +async function isGatewayRunningInProfileList(hermesBin: string, profile: string): Promise { + const statuses = await listGatewayStatusesFromProfileList(hermesBin) + const status = statuses.get(profile) + return status !== undefined && gatewayStatusLooksRunning(status) +} + +export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise { + if (gatewayStateLooksRunningForProfile(profileDir)) return true + + try { + const { stdout, stderr } = await execHermesWithBin(hermesBin, ['gateway', 'status'], { + timeout: 10000, + windowsHide: true, + env: { + ...process.env, + HERMES_HOME: profileDir, + }, + }) + return gatewayStatusLooksRunning(`${stdout}\n${stderr}`) + } catch (err: any) { + const output = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}` + if (gatewayStatusLooksRuntimeLocked(output)) { + logger.info({ profileDir }, 'Hermes gateway status reported runtime lock held; treating gateway as already running') + return true + } + if (output.trim()) { + logger.warn({ err, profileDir }, 'Hermes gateway status failed; treating as not running') + } + return false + } +} + +async function waitForGatewayRunning(hermesBin: string, profile: string, profileDir: string, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + if (await isGatewayRunningInProfileList(hermesBin, profile)) return true + } catch (err) { + logger.warn(err, '[gateway-autostart] Hermes profile list check failed while waiting for gateway profile=%s', profile) + } + if (await isGatewayRunningForProfile(hermesBin, profileDir)) return true + await new Promise(resolve => setTimeout(resolve, 500)) + } + return false +} + +async function stopGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise { + try { + await execHermesWithBin(hermesBin, ['gateway', 'stop'], { + timeout: 30000, + windowsHide: true, + env: { + ...process.env, + HERMES_HOME: profileDir, + }, + }) + logger.info('[gateway-autostart] gateway stopped profile=%s home=%s', profile, profileDir) + } catch (err) { + logger.warn(err, '[gateway-autostart] Hermes CLI gateway stop failed before restart profile=%s home=%s', profile, profileDir) + } +} + +export async function startGatewayForProfile( + hermesBin: string, + profile: string, + profileDir: string, + opts: { managedRun?: boolean } = {}, +): Promise { + if (opts.managedRun ?? shouldUseManagedGatewayRun()) { + const result = startGatewayRunManaged(hermesBin, { profileDir }) + logger.info( + '[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s', + profile, + profileDir, + result.pid || 'unknown', + ) + return + } + + try { + await execHermesWithBin(hermesBin, ['gateway', 'start'], { + timeout: 30000, + windowsHide: true, + env: { + ...process.env, + HERMES_HOME: profileDir, + }, + }) + logger.info('[gateway-autostart] gateway started via Hermes CLI service profile=%s home=%s', profile, profileDir) + } catch (err) { + logger.warn(err, '[gateway-autostart] Hermes CLI gateway start failed; falling back to background run profile=%s home=%s', profile, profileDir) + const result = startGatewayRunManaged(hermesBin, { profileDir }) + logger.info( + '[gateway-autostart] gateway started via fallback background run profile=%s home=%s pid=%s', + profile, + profileDir, + result.pid || 'unknown', + ) + } +} + +export async function getGatewayRuntimeStatusForProfile(profile: string): Promise<{ running: boolean; profile: string }> { + const hermesBin = resolveHermesBin() + const profileDir = getProfileDir(profile) + const running = await isGatewayRunningForProfile(hermesBin, profileDir) + return { running, profile } +} + +export async function restartGatewayForProfile(profile: string): Promise<{ running: boolean; profile: string }> { + const hermesBin = resolveHermesBin() + const profileDir = getProfileDir(profile) + await clearApiServerForProfile(profileDir) + await stopGatewayForProfile(hermesBin, profile, profileDir) + + try { + await startGatewayForProfile(hermesBin, profile, profileDir, { managedRun: shouldUseManagedGatewayRun() }) + } catch (err) { + logger.error(err, '[gateway-autostart] Hermes gateway restart failed profile=%s home=%s', profile, profileDir) + throw err + } + + const running = await waitForGatewayRunning(hermesBin, profile, profileDir) + if (!running) throw new Error('Hermes gateway start completed but gateway did not report running within timeout') + return { running, profile } +} + +export async function clearApiServerForProfile(profileDir: string): Promise { + const configPath = join(profileDir, 'config.yaml') + try { + await safeFileStore.updateYaml(configPath, (config) => { + const result = stripLegacyApiServerGatewayConfig(config) + return { data: result.config, result: undefined, write: result.changed } + }, { backup: true }) + } catch (err) { + logger.warn(err, 'Failed to clear legacy api_server gateway config before gateway startup: %s', profileDir) + } +} + +export async function ensureProfileGatewaysRunning(): Promise { + const hermesBin = resolveHermesBin() + const profiles = listProfileNamesFromDisk() + let gatewayStatuses: Map | undefined + try { + gatewayStatuses = await listGatewayStatusesFromProfileList(hermesBin) + } catch (err) { + logger.warn(err, '[gateway-autostart] Hermes profile list failed; falling back to per-profile gateway status checks') + } + + for (const profile of profiles) { + if (isReservedProfileName(profile)) { + logger.warn('[gateway-autostart] skipping reserved profile name during gateway autostart profile=%s', profile) + continue + } + + const profileDir = getProfileDir(profile) + const status = gatewayStatuses?.get(profile) + const running = status !== undefined && gatewayStatusLooksRunning(status) + ? true + : await isGatewayRunningForProfile(hermesBin, profileDir) + if (running) { + logger.info('[gateway-autostart] gateway already running profile=%s home=%s status=%s', profile, profileDir, status || 'status-check') + continue + } + + await clearApiServerForProfile(profileDir) + await startGatewayForProfile(hermesBin, profile, profileDir, { managedRun: shouldUseManagedGatewayRunForAutostart() }) + const ready = await waitForGatewayRunning(hermesBin, profile, profileDir) + if (!ready) { + logger.warn('[gateway-autostart] gateway start completed but did not report running within timeout profile=%s home=%s', profile, profileDir) + } + } +} diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts new file mode 100644 index 0000000..0dc720c --- /dev/null +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -0,0 +1,929 @@ +/** + * GatewayManager — 多 Profile 网关生命周期管理 + * + * 核心职责: + * 1. 启动时检测所有 profile 的网关运行状态(PID、端口、健康检查) + * 2. 自动发现端口冲突并重新分配 + * 3. 启动/停止网关进程 + * + * 启动检测流程(detectStatus): + * ① 读取 gateway.pid,缺失时回退读取 gateway_state.json → 获取 PID + * ② 读取 config.yaml (platforms.api_server.extra.port/host) → 获取配置端口 + * ③ PID 存活且配置端口 health check 通过? + * - 是 → 配置与运行状态匹配,注册网关 + * - 否 → 标记为 stopped + * + * detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写 + * config.yaml。 + * + * 端口分配流程(resolvePort,启动前调用): + * ① 读取配置端口 + * ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口 + * ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口 + * ④ 从 8642 起递增查找空闲端口,仅返回本次运行使用的端口,不再回写 config.yaml + * + * 启动模式: + * - 所有平台统一使用 `hermes gateway run --replace` + * - 停止时先尝试 `hermes gateway stop`,再根据 PID / 监听端口清理进程 + */ + +import type { ChildProcess } from 'child_process' +import { join } from 'path' +import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { createServer } from 'net' +import yaml from 'js-yaml' +import { logger } from '../logger' +import { detectHermesHome, getHermesBin } from './hermes-path' +import { execHermesWithBin, spawnHermesWithBin } from './hermes-process' + +const execFileAsync = promisify(execFile) + +// ============================ +// 常量 & 环境检测 +// ============================ + +const HERMES_BASE = detectHermesHome() +const HERMES_BIN = getHermesBin() +const DEFAULT_WEB_UI_PORT = 8648 + +const GATEWAY_RUNTIME_ENV_KEYS = new Set([ + 'PATH', + 'HOME', + 'USER', + 'USERNAME', + 'SHELL', + 'LANG', + 'TZ', + 'TMP', + 'TEMP', + 'TMPDIR', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'HERMES_BIN', + 'HERMES_ALLOW_ROOT_GATEWAY', + 'SYSTEMROOT', + 'COMSPEC', + 'APPDATA', + 'LOCALAPPDATA', +]) + +function parseEnvKeys(raw: string): Set { + const keys = new Set() + for (const line of raw.split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (match) keys.add(match[1]) + } + return keys +} + +function readProfileEnvKeys(): Set { + const keys = new Set() + const envPaths = [join(HERMES_BASE, '.env')] + const profilesDir = join(HERMES_BASE, 'profiles') + + if (existsSync(profilesDir)) { + for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (entry.isDirectory()) envPaths.push(join(profilesDir, entry.name, '.env')) + } + } + + for (const envPath of envPaths) { + try { + for (const key of parseEnvKeys(readFileSync(envPath, 'utf-8'))) keys.add(key) + } catch {} + } + + return keys +} + +export function buildGatewayProcessEnv(profileName: string, hermesHome: string): NodeJS.ProcessEnv { + const base = { ...process.env } + + if (profileName !== 'default') { + for (const key of readProfileEnvKeys()) { + if (!GATEWAY_RUNTIME_ENV_KEYS.has(key.toUpperCase())) delete base[key] + } + } + + return { + ...base, + HERMES_HOME: hermesHome, + } +} + +function getWebUiPort(): number | null { + const port = parseInt(process.env.PORT || String(DEFAULT_WEB_UI_PORT), 10) + return port > 0 && port <= 65535 ? port : null +} + +// ============================ +// 类型定义 +// ============================ + +export interface GatewayStatus { + profile: string + port: number + host: string + url: string + running: boolean + pid?: number + diagnostics?: GatewayDiagnostics +} + +export interface GatewayDiagnostics { + pid_path: string + config_path: string + pid_file_exists: boolean + config_exists: boolean + health_url: string + health_checked_at: string + health_ok?: boolean + reason: string +} + +interface ManagedGateway { + pid: number + port: number + host: string + url: string + owned: boolean + process?: ChildProcess +} + +interface ResolvedGatewayEndpoint { + port: number + host: string +} + +function formatHostForUrl(host: string): string { + if (host.startsWith('[') && host.endsWith(']')) return host + return host.includes(':') ? `[${host}]` : host +} + +function buildHttpUrl(host: string, port: number): string { + return `http://${formatHostForUrl(host)}:${port}` +} + +function isLocalHost(host: string): boolean { + return ['127.0.0.1', 'localhost', '::1', '[::1]', '0.0.0.0'].includes(host) +} + +function shouldDetachGatewayProcess(): boolean { + // In dev mode (nodemon), always detach gateway processes so they survive restarts + // Production mode: attach gateways so they can be managed together with the server + const override = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN?.trim().toLowerCase() + const shouldDetach = override === '0' || override === 'false' + + if (shouldDetach) { + console.log('[gateway] Detaching gateway process (dev mode: HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN=' + override + ')') + } else { + console.log('[gateway] Attaching gateway process (prod mode: HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN=' + (override || 'not set') + ')') + } + + return shouldDetach +} + +// ============================ +// GatewayManager +// ============================ + +export class GatewayManager { + /** 已注册的网关:profile name → { pid, port, host, url } */ + private gateways = new Map() + + /** 本次启动过程中已分配的端口集合(防止并发分配到相同端口) */ + private allocatedPorts = new Set() + + /** 当前活跃的 profile(用于代理路由的默认上游) */ + private activeProfile: string + + constructor(activeProfile: string) { + this.activeProfile = activeProfile + } + + // ============================ + // Profile 目录 & 配置读取 + // ============================ + + /** 获取 profile 的 home 目录路径 */ + private profileDir(name: string): string { + if (name === 'default') return HERMES_BASE + return join(HERMES_BASE, 'profiles', name) + } + + /** + * 从 profile 的 config.yaml 读取 api_server 端口和主机 + * 读取路径:platforms.api_server.extra.port / extra.host + */ + private readProfilePort(name: string): { port: number; host: string } { + const configPath = join(this.profileDir(name), 'config.yaml') + const defaultHost = process.env.GATEWAY_HOST || '127.0.0.1' + + if (!existsSync(configPath)) return { port: 8642, host: defaultHost } + + try { + const content = readFileSync(configPath, 'utf-8') + const cfg = yaml.load(content, { json: true }) as any || {} + + const extra = cfg?.platforms?.api_server?.extra + const rawPort = extra?.port || 8642 + const port = typeof rawPort === 'number' ? rawPort : parseInt(rawPort, 10) || 8642 + const host = extra?.host || defaultHost + // 端口超出合法范围时回退到默认值 + return { port: port > 0 && port <= 65535 ? port : 8642, host } + } catch { + return { port: 8642, host: defaultHost } + } + } + + /** Read a profile gateway PID, falling back to runtime state when gateway.pid is missing. */ + private readPidFile(name: string): number | null { + const profilePath = this.profileDir(name) + const pidPath = join(profilePath, 'gateway.pid') + + try { + if (existsSync(pidPath)) { + const content = readFileSync(pidPath, 'utf-8').trim() + const data = JSON.parse(content) + return typeof data.pid === 'number' ? data.pid : parseInt(data.pid, 10) || null + } + } catch {} + + const statePath = join(profilePath, 'gateway_state.json') + if (!existsSync(statePath)) return null + + try { + const content = readFileSync(statePath, 'utf-8').trim() + const data = JSON.parse(content) + const pid = typeof data.pid === 'number' ? data.pid : parseInt(data.pid, 10) || null + const state = data?.gateway_state + return pid && Number.isFinite(pid) && pid > 0 && (state === 'running' || state === 'starting') ? pid : null + } catch { + return null + } + } + + // ============================ + // 进程 & 端口检测工具 + // ============================ + + /** Check process liveness without sending a terminating signal. */ + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err: any) { + return err?.code === 'EPERM' + } + } + + /** 请求 /health 端点,判断网关是否真正就绪 */ + private async checkHealth(url: string, timeoutMs = 3000): Promise { + try { + const res = await fetch(`${url.replace(/\/$/, '')}/health`, { + signal: AbortSignal.timeout(timeoutMs), + }) + return res.ok + } catch { + return false + } + } + + /** 尝试绑定端口,检测端口是否被系统级进程占用 */ + /** 清理过期的 PID 文件 */ + private clearPidFile(name: string): void { + try { + const pidPath = join(this.profileDir(name), 'gateway.pid') + if (existsSync(pidPath)) { + unlinkSync(pidPath) + logger.debug('Cleared stale PID file for profile "%s"', name) + } + } catch (err) { + logger.debug('Failed to clear PID file: %s', err) + } + } + + /** 从 base 端口开始递增查找空闲端口(上限 65535) */ + private findFreePort(base: number, host = '127.0.0.1', reservedPorts = new Set()): Promise { + return new Promise((resolve, reject) => { + const tryPort = (port: number) => { + if (port > 65535) { + reject(new Error(`No free port found in range ${base}-65535`)) + return + } + if (reservedPorts.has(port)) { + tryPort(port + 1) + return + } + const server = createServer() + server.once('error', () => { + server.close() + tryPort(port + 1) + }) + server.once('listening', () => { + server.close() + resolve(port) + }) + server.listen(port, host) + } + tryPort(base) + }) + } + + // ============================ + // 端口分配 + // ============================ + + /** + * 为 profile 分配可用端口(启动前调用) + * + * 检测顺序: + * 1. 当前 profile 已经健康运行 → 直接使用运行端口 + * 2. 未运行 → 从 8642 开始找空闲端口 + * 3. 检查已管理 profile / 本轮已分配端口 / 系统 TCP 占用 + */ + private async resolvePort(name: string): Promise<{ port: number; host: string }> { + const { port: configuredPort, host } = this.readProfilePort(name) + const configuredUrl = buildHttpUrl(host, configuredPort) + + // 检查是否是当前 profile 自己的端口(内存中的记录) + const existing = this.gateways.get(name) + if (existing && existing.host === host && this.isProcessAlive(existing.pid) && await this.checkHealth(existing.url, 1000)) { + // 如果内存中有记录且进程存活,直接使用内存中的端口 + logger.info('Profile "%s" already running on port %d (in-memory record)', name, existing.port) + this.allocatedPorts.add(existing.port) + return { port: existing.port, host } + } + + // 检查 PID 文件指向的当前 profile 是否仍健康运行 + const pid = this.readPidFile(name) + if (pid && this.isProcessAlive(pid) && await this.checkHealth(configuredUrl, 1000)) { + logger.info('Profile "%s" already running on configured port %d (PID: %d)', name, configuredPort, pid) + this.gateways.set(name, { pid, port: configuredPort, host, url: configuredUrl, owned: false }) + this.allocatedPorts.add(configuredPort) + return { port: configuredPort, host } + } + + // 如果没有 PID 文件也没有内存记录,不认领端口上的未知网关 + // 如果端口被占用,findFreePort 会分配新端口 + + // 收集已占用端口:本次启动已分配的端口 + 其他 profile 的网关端口 + const usedPorts = new Set(this.allocatedPorts) + for (const [profileName, gw] of Array.from(this.gateways.entries())) { + // 跳过当前 profile 自己的端口 + if (profileName === name) continue + if (gw.host === host && this.isProcessAlive(gw.pid)) { + usedPorts.add(gw.port) + } + } + const webUiPort = getWebUiPort() + if (webUiPort !== null) usedPorts.add(webUiPort) + + const port = await this.findFreePort(8642, host, usedPorts) + if (configuredPort !== port) { + logger.info('Assigning port for profile "%s": %d → %d', name, configuredPort, port) + } else { + logger.debug('Assigning port %d for profile "%s"', port, name) + } + this.allocatedPorts.add(port) + return { port, host } + } + + // ============================ + // 公开方法:状态查询 + // ============================ + + /** 获取指定 profile 的网关 URL(代理路由使用) */ + getUpstream(profileName?: string): string { + const name = profileName || this.activeProfile + const gw = this.gateways.get(name) + if (gw?.url) return gw.url + const { port, host } = this.readProfilePort(name) + return buildHttpUrl(host, port) + } + + /** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */ + getApiKey(profileName?: string): string | null { + const name = profileName || this.activeProfile + try { + const envPath = join(this.profileDir(name), '.env') + if (!existsSync(envPath)) return null + const content = readFileSync(envPath, 'utf-8') + const match = content.match(/^API_SERVER_KEY\s*=\s*"?([^"\n]+)"?/m) + return match?.[1]?.trim() || null + } catch { + return null + } + } + + getActiveProfile(): string { + return this.activeProfile + } + + setActiveProfile(name: string) { + this.activeProfile = name + } + + /** 列出所有已知 profile 名称(通过 hermes CLI 或文件系统扫描) */ + async listProfiles(): Promise { + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'list'], { + timeout: 10000, + windowsHide: true, + }) + const profiles: string[] = [] + for (const line of stdout.trim().split('\n')) { + if (line.startsWith(' Profile') || line.match(/^ ─/)) continue + const match = line.match(/^\s+(?:◆)?(.+?)\s+/) + if (match) profiles.push(match[1]) + } + return profiles + } catch { + // CLI 不可用时回退到文件系统扫描 + const profiles = ['default'] + const profilesDir = join(HERMES_BASE, 'profiles') + if (existsSync(profilesDir)) { + for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) { + profiles.push(entry.name) + } + } + } + return profiles + } + } + + /** + * 检测单个 profile 的网关状态(只读,不修改任何进程或配置) + * + * 流程: + * ① 读 PID 文件 → 检查进程是否存活 + * ② 读配置端口 → health check + * ③ 两者都通过 → 匹配,注册 + * ④ 否则 → 标记为未运行(不杀进程,由 startAll 处理) + */ + async detectStatus(name: string): Promise { + const pid = this.readPidFile(name) + const { port, host } = this.readProfilePort(name) + const url = buildHttpUrl(host, port) + const pidPath = join(this.profileDir(name), 'gateway.pid') + const configPath = join(this.profileDir(name), 'config.yaml') + const diagnostics: GatewayDiagnostics = { + pid_path: pidPath, + config_path: configPath, + pid_file_exists: existsSync(pidPath), + config_exists: existsSync(configPath), + health_url: `${url.replace(/\/$/, '')}/health`, + health_checked_at: new Date().toISOString(), + reason: 'stopped', + } + + // 首先检查 PID 文件:如果存在且进程存活且健康,则标记为运行 + if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) { + diagnostics.health_ok = true + diagnostics.reason = 'pid alive and health check passed' + this.gateways.set(name, { pid, port, host, url, owned: false }) + return { profile: name, port, host, url, running: true, pid, diagnostics } + } + + if (pid) { + diagnostics.health_ok = false + diagnostics.reason = this.isProcessAlive(pid) ? 'pid alive but health check failed' : 'stale pid file' + } else if (!diagnostics.pid_file_exists) { + diagnostics.reason = 'missing pid file' + } + + // 没有 PID 文件时不认领端口上的未知网关,避免误判其他 profile 的网关 + this.gateways.delete(name) + return { profile: name, port, host, url, running: false, diagnostics } + } + + /** 检测所有 profile 的网关状态 */ + async listAll(): Promise { + const profiles = await this.listProfiles() + const statuses = await Promise.all(profiles.map(name => this.detectStatus(name))) + return statuses + } + + // ============================ + // 公开方法:启动 & 停止 + // ============================ + + /** + * 启动单个 profile 的网关 + * 启动前自动调用 resolvePort() 确保端口可用且配置完整 + */ + async start(name: string): Promise { + // 检查是否已在运行 + const existing = this.gateways.get(name) + if (existing && this.isProcessAlive(existing.pid)) { + if (await this.checkHealth(existing.url, 1000)) { + logger.info('Gateway for profile "%s" already running (PID: %d, port: %d)', name, existing.pid, existing.port) + return { profile: name, port: existing.port, host: existing.host, url: existing.url, running: true, pid: existing.pid } + } + + logger.info('Gateway for profile "%s" is alive but unhealthy (PID: %d, port: %d), restarting', + name, existing.pid, existing.port) + try { + await this.stop(name) + } catch (err) { + logger.debug('Failed to stop unhealthy gateway before restart: %s', err) + } + } + + const endpoint = await this.resolvePort(name) + return this.startResolved(name, endpoint) + } + + /** 使用已经解析好的端口启动网关,避免 startAll() 中重复分配端口 */ + private async startResolved(name: string, endpoint: ResolvedGatewayEndpoint): Promise { + const { port, host } = endpoint + const hermesHome = this.profileDir(name) + const url = buildHttpUrl(host, port) + + // Windows 特定:清理僵尸锁定文件 + if (process.platform === 'win32') { + const lockPath = join(hermesHome, 'gateway.lock') + if (existsSync(lockPath)) { + try { + const content = readFileSync(lockPath, 'utf-8').trim() + const lockData = JSON.parse(content) + const pid = lockData.pid + + if (pid && !this.isProcessAlive(pid)) { + logger.warn('Found stale gateway lock file (PID: %d), attempting cleanup', pid) + try { + // 使用 Node.js 内置方法删除文件,避免 PowerShell 弹窗 + unlinkSync(lockPath) + logger.info('Successfully removed stale lock file') + } catch (err) { + logger.debug('Failed to remove lock file: %s', err) + } + } + } catch (err) { + logger.debug('Failed to check lock file: %s', err) + } + } + } + + // 所有平台统一使用 run 模式;dev/nodemon 可通过 env 保留 gateway 进程。 + return new Promise((resolve, reject) => { + const env = buildGatewayProcessEnv(name, hermesHome) + const detachGateway = shouldDetachGatewayProcess() + const child = spawnHermesWithBin(HERMES_BIN, ['gateway', 'run', '--replace'], { + stdio: 'ignore', + detached: detachGateway, + windowsHide: true, + env, + }) + if (detachGateway) { + child.unref() + } + + const pid = child.pid ?? 0 + logger.info('Starting gateway for profile "%s" (run mode, PID: %d, port: %d, detached: %s)', name, pid, port, detachGateway) + + // 保存子进程引用,用于后续管理 + this.gateways.set(name, { pid, port, host, url, owned: true, process: child }) + + this.waitForReady(name, pid, port, host, url) + .then(resolve) + .catch(reject) + }) + } + + /** 等待网关健康检查通过,最多 15 秒 */ + private async waitForReady(name: string, pid: number, port: number, host: string, url: string): Promise { + const deadline = Date.now() + 15000 + while (Date.now() < deadline) { + if (pid && !this.isProcessAlive(pid)) { + throw new Error(`Gateway process exited unexpectedly (PID: ${pid})`) + } + if (await this.checkHealth(url, 2000)) { + // "gateway start" 自行管理进程,重新从 pid 文件读取实际 PID + const actualPid = this.readPidFile(name) ?? pid + const previous = this.gateways.get(name) + this.gateways.set(name, { + pid: actualPid, + port, + host, + url, + owned: previous?.owned ?? true, + process: previous?.process, + }) + return { profile: name, port, host, url, running: true, pid: actualPid || undefined } + } + await new Promise(r => setTimeout(r, 500)) + } + throw new Error(`Gateway health check timed out after 15000ms`) + } + + private async getListeningPids(port: number): Promise { + try { + if (process.platform === 'win32') { + const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp'], { + timeout: 5000, + windowsHide: true, + }) + const pids = new Set() + for (const line of stdout.split(/\r?\n/)) { + const parts = line.trim().split(/\s+/) + if (parts.length < 5 || parts[0].toUpperCase() !== 'TCP') continue + const localAddress = parts[1] + const state = parts[3]?.toUpperCase() + const pid = parseInt(parts[4], 10) + if (state === 'LISTENING' && localAddress.endsWith(`:${port}`) && Number.isFinite(pid)) { + pids.add(pid) + } + } + return Array.from(pids) + } + + const { stdout } = await execFileAsync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { + timeout: 5000, + }) + return stdout + .split(/\r?\n/) + .map(line => parseInt(line.trim(), 10)) + .filter(pid => Number.isFinite(pid)) + } catch { + return [] + } + } + + private async killPid(pid: number, force = false): Promise { + if (!pid) return + + if (process.platform === 'win32') { + try { + await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + timeout: 5000, + windowsHide: true, + }) + } catch { + try { process.kill(pid) } catch { } + } + return + } + + const signal = force ? 'SIGKILL' : 'SIGTERM' + try { + process.kill(-pid, signal) + } catch { + try { process.kill(pid, signal) } catch { } + } + } + + private async stopViaHermesCli(name: string): Promise { + const hermesHome = this.profileDir(name) + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], { + timeout: 15000, + windowsHide: true, + env: buildGatewayProcessEnv(name, hermesHome), + }) + const output = `${stdout}${stderr}`.trim() + if (output) logger.debug('%s: hermes gateway stop: %s', name, output) + } catch (err) { + logger.debug('Failed to stop gateway via Hermes CLI for profile "%s": %s', name, err) + } + } + + /** + * 停止单个 profile 的网关 + * 所有平台使用 run 模式,直接 kill 进程 + * 返回前等待 health check 确认网关已真正停止 + */ + async stop(name: string, timeoutMs = 10000): Promise { + // 记录当前 URL,用于确认停止 + const gw = this.gateways.get(name) + const configured = this.readProfilePort(name) + const port = gw?.port ?? configured.port + const host = gw?.host ?? configured.host + const url = gw?.url || buildHttpUrl(host, port) + + // 所有平台使用 run 模式,直接杀进程 + const pids = new Set() + if (gw?.process?.pid) pids.add(gw.process.pid) + if (gw?.pid) pids.add(gw.pid) + const pidFilePid = this.readPidFile(name) + if (pidFilePid) pids.add(pidFilePid) + if (isLocalHost(host)) { + for (const pid of await this.getListeningPids(port)) { + pids.add(pid) + } + } + + if (pids.size === 0) { + if (!(await this.checkHealth(url, 1000))) { + this.gateways.delete(name) + this.allocatedPorts.delete(port) + this.clearPidFile(name) + logger.info('Stopped gateway for profile "%s" (already stopped)', name) + return + } + await this.stopViaHermesCli(name) + if (!(await this.checkHealth(url, 1000))) { + this.gateways.delete(name) + this.allocatedPorts.delete(port) + this.clearPidFile(name) + logger.info('Stopped gateway for profile "%s"', name) + return + } + throw new Error(`Cannot stop gateway for profile "${name}": no PID available`) + } + + await this.stopViaHermesCli(name) + + if (!(await this.checkHealth(url, 1000))) { + this.gateways.delete(name) + this.allocatedPorts.delete(port) + this.clearPidFile(name) + logger.info('Stopped gateway for profile "%s"', name) + return + } + + if (gw?.process && !gw.process.killed) { + try { gw.process.kill(process.platform === 'win32' ? undefined : 'SIGTERM') } catch { } + } + + for (const pid of pids) { + await this.killPid(pid) + } + + // 等待 health check 失败,确认网关已真正停止 + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (!(await this.checkHealth(url, 1000))) { + this.gateways.delete(name) + this.allocatedPorts.delete(port) + this.clearPidFile(name) + logger.info('Stopped gateway for profile "%s"', name) + return + } + await new Promise(r => setTimeout(r, 300)) + } + + if (isLocalHost(host)) { + const listeningPids = await this.getListeningPids(port) + if (listeningPids.length) { + logger.warn( + 'Gateway for profile "%s" still listening on port %d, force killing PIDs: %s', + name, + port, + listeningPids.join(', '), + ) + for (const pid of listeningPids) { + await this.killPid(pid, true) + } + + const forceDeadline = Date.now() + 3000 + while (Date.now() < forceDeadline) { + if (!(await this.checkHealth(url, 500))) { + this.gateways.delete(name) + this.allocatedPorts.delete(port) + this.clearPidFile(name) + logger.info('Stopped gateway for profile "%s" (force killed)', name) + return + } + await new Promise(r => setTimeout(r, 200)) + } + } + } + + logger.warn('Failed to stop gateway for profile "%s" within %dms', name, timeoutMs) + throw new Error(`Gateway stop timed out after ${timeoutMs}ms`) + } + + /** 停止所有已管理的网关(并行执行) */ + async stopAll(): Promise { + const entries = Array.from(this.gateways.entries()) + .filter(([, gw]) => gw.owned) + .map(([name]) => name) + await Promise.allSettled(entries.map(name => this.stop(name))) + } + + // ============================ + // 批量操作(启动时调用) + // ============================ + + /** 扫描所有 profile,检测网关运行状态并注册 */ + async detectAllOnStartup(): Promise { + logger.info('Scanning profiles for running gateways...') + const profiles = await this.listProfiles() + + for (const name of profiles) { + const status = await this.detectStatus(name) + if (status.running) { + logger.info('%s: running (PID: %s, port: %d)', name, status.pid, status.port) + } else { + logger.debug('%s: stopped', name) + } + } + } + + /** + * 启动所有未运行的网关 + * + * 两阶段执行: + * Phase 1 — 顺序处理:检查状态、清理旧进程、分配端口 + * Phase 2 — 并行启动网关进程 + */ + async startAll(): Promise { + // 确保使用 default profile 启动网关 + const currentProfile = this.getActiveProfile() + if (currentProfile !== 'default') { + logger.info('Current profile is "%s", switching to "default" for gateway startup', currentProfile) + try { + await execHermesWithBin(HERMES_BIN, ['profile', 'use', 'default'], { + timeout: 10000, + windowsHide: true, + }) + this.setActiveProfile('default') + logger.info('Waiting for profile switch to take effect...') + // 等待一下让 profile 切换完全生效,确保配置文件更新完成 + await new Promise(resolve => setTimeout(resolve, 2000)) + logger.info('Successfully switched to default profile') + } catch (err) { + logger.error(err, 'Failed to switch to default profile, continuing with current profile') + } + } + + // 清空已分配端口集合,确保每次启动都从干净状态开始 + this.allocatedPorts.clear() + + const profiles = await this.listProfiles() + // Phase 1: 顺序处理 + const toStart: Array<{ name: string; endpoint: ResolvedGatewayEndpoint }> = [] + for (const name of profiles) { + const existing = this.gateways.get(name) + if (existing && this.isProcessAlive(existing.pid)) { + if (await this.checkHealth(existing.url, 1000)) { + logger.info('%s: already running (PID: %d, port: %d)', name, existing.pid, existing.port) + continue + } + + logger.info('%s: process alive but unhealthy (PID: %d, port: %d), restarting', + name, existing.pid, existing.port) + try { + await this.stop(name) + } catch (err) { + logger.debug('Failed to stop unhealthy gateway: %s', err) + } + } + + // Skip remote profiles — local hermes command cannot start remote gateways + const { host } = this.readProfilePort(name) + if (host && host !== '127.0.0.1' && host !== 'localhost') { + logger.info('%s: remote profile (host=%s), skipping auto-start', name, host) + continue + } + + // 有 PID 文件但进程未在正确端口运行 → 通过 health check 检查网关状态 + const pid = this.readPidFile(name) + if (pid && this.isProcessAlive(pid)) { + const { port: configuredPort, host } = this.readProfilePort(name) + const configuredUrl = buildHttpUrl(host, configuredPort) + + // 检查配置文件中的端口是否有正常的网关在运行 + if (await this.checkHealth(configuredUrl, 2000)) { + // Health check 通过,说明网关正常工作 + logger.info('%s: gateway already running on configured port %d (PID: %d, health check passed)', + name, configuredPort, pid) + // 注册到内存中 + this.gateways.set(name, { pid, port: configuredPort, host, url: configuredUrl, owned: false }) + continue + } else { + // Health check 失败,说明网关有问题(僵尸进程或端口冲突) + logger.info('%s: stale process (PID: %d) health check failed on port %d, stopping and restarting', + name, pid, configuredPort) + try { + await this.stop(name) + } catch (err) { + logger.debug('Failed to stop stale gateway: %s', err) + } + // 清理过期的 PID 文件 + this.clearPidFile(name) + } + } + + // 只为真正需要启动的网关分配端口 + const endpoint = await this.resolvePort(name) + toStart.push({ name, endpoint }) + } + + // Phase 2: 并行启动 + // 串行启动网关,避免并发时的lock file竞争条件 + for (const { name, endpoint } of toStart) { + try { + await this.startResolved(name, endpoint) + } catch (err: any) { + logger.error(err, '%s: failed to start', name) + } + } + } +} diff --git a/packages/server/src/services/hermes/gateway-runner.ts b/packages/server/src/services/hermes/gateway-runner.ts new file mode 100644 index 0000000..2ade359 --- /dev/null +++ b/packages/server/src/services/hermes/gateway-runner.ts @@ -0,0 +1,22 @@ +import { getActiveProfileDir } from './hermes-profile' +import { spawnHermesWithBin } from './hermes-process' + +export function startGatewayRunManaged( + hermesBin: string, + opts: { profileDir?: string } = {}, +): { pid: number | null; reused: boolean } { + const profileDir = opts.profileDir || getActiveProfileDir() + const child = spawnHermesWithBin(hermesBin, ['gateway', 'run', '--replace'], { + detached: true, + stdio: 'ignore', + windowsHide: true, + env: { + ...process.env, + HERMES_HOME: profileDir, + }, + }) + child.unref() + + const pid = child.pid ?? null + return { pid, reused: false } +} diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts new file mode 100644 index 0000000..33c985c --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -0,0 +1,1161 @@ +import { io, Socket } from 'socket.io-client' +import { randomBytes } from 'crypto' +import { getToken } from '../../../services/auth' +import { logger } from '../../../services/logger' +import { updateUsage } from '../../../db/hermes/usage-store' +import { countTokens } from '../../../lib/context-compressor' +import { AgentBridgeClient, type AgentBridgeContextEstimate, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge' +import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks' +import type { ContentBlock } from '../run-chat/types' +import { + isAllAgentsMentioned, + resolveMentionTargets, + stripMentionRoutingTokens, +} from './mention-routing' + +export const GROUP_CHAT_AGENT_SOCKET_SECRET = randomBytes(32).toString('hex') + +// ─── Types ──────────────────────────────────────────────────── + +interface AgentConfig { + agentId?: string + profile: string + name: string + description: string + invited: number +} + +interface MessageData { + id: string + roomId: string + senderId: string + senderName: string + content: string + timestamp: number +} + +type MentionMessage = { + content: string + senderName: string + senderId: string + timestamp: number + input?: string | ContentBlock[] + mentionDepth?: number +} + +type GroupEstimateMessage = { role: 'user' | 'assistant'; content: string } + +interface BridgeContextCache { + fixedContextTokens: number + instructions?: string + systemPromptTokens?: number + toolTokens?: number + systemPromptChars?: number + toolCount?: number + toolNames?: string[] + profile?: string + model?: string + provider?: string +} + +export function estimateGroupHistoryMessageTokens(history: Array<{ content?: unknown }>): number { + return history.reduce((sum, message) => sum + countTokens(String(message.content || '')), 0) +} + +export function groupContextTokensWithFixedOverhead( + fixedContextTokens: number | null | undefined, + history: Array<{ content?: unknown }>, +): number | undefined { + if (typeof fixedContextTokens !== 'number' || !Number.isFinite(fixedContextTokens) || fixedContextTokens < 0) { + return undefined + } + return Math.floor(fixedContextTokens) + estimateGroupHistoryMessageTokens(history) +} + +export function groupBridgeReasoningDeltaFromEvent(event: Record): string | null { + if (String(event.event || '') !== 'reasoning.delta') return null + const text = String(event.text || '') + return text ? text : null +} + +interface MemberData { + id: string + name: string + joinedAt: number +} + +interface JoinResult { + roomId: string + roomName: string + members: MemberData[] + messages: MessageData[] + rooms: string[] +} + +export interface AgentEventHandler { + onMessage?: (data: { roomId: string; msg: MessageData }) => void + onTyping?: (data: { roomId: string; userId: string; userName: string }) => void + onStopTyping?: (data: { roomId: string; userId: string; userName: string }) => void + onMemberJoined?: (data: { roomId: string; memberId: string; memberName: string; members: MemberData[] }) => void + onMemberLeft?: (data: { roomId: string; memberId: string; memberName: string; members: MemberData[] }) => void +} + +// ─── Agent Client (single connection) ───────────────────────── + +class AgentClient { + readonly agentId: string + readonly profile: string + readonly name: string + readonly description: string + private socket: Socket | null = null + private joinedRooms = new Set() + private handlers: AgentEventHandler + private _reconnecting = false + private contextEngine: any = null + private storage: any = null + private pendingToolCallIds = new Map() + private pendingToolBaseIds = new Map() + private bridgeContextCache = new Map() + + constructor(config: AgentConfig, handlers: AgentEventHandler = {}) { + this.agentId = config.agentId || Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + this.profile = config.profile + this.name = config.name + this.description = config.description + this.handlers = handlers + } + + get connected(): boolean { + return this.socket?.connected ?? false + } + + get id(): string | undefined { + return this.socket?.id + } + + setContextEngine(engine: any): void { + this.contextEngine = engine + } + + setStorage(storage: any): void { + this.storage = storage + } + + async connect(port?: number): Promise { + const actualPort = port ?? parseInt(process.env.PORT || '8648', 10) + const token = await getToken() + + this.socket = io(`http://127.0.0.1:${actualPort}/group-chat`, { + auth: { + token: token || undefined, + userId: this.agentId, + name: this.name, + description: this.description, + source: 'agent', + agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET, + }, + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + randomizationFactor: 0.5, + timeout: 30000, + }) + + this.bindEvents() + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000) + + this.socket!.on('connect', () => { + clearTimeout(timeout) + logger.debug(`[AgentClient] ${this.name} connected, socket id: ${this.socket!.id}`) + resolve() + }) + + this.socket!.on('connect_error', (err) => { + clearTimeout(timeout) + logger.error(err, `[AgentClient] ${this.name} connect_error`) + reject(err) + }) + }) + } + + disconnect(): void { + if (this.socket) { + this.socket.disconnect() + this.socket = null + this.joinedRooms.clear() + this.bridgeContextCache.clear() + } + } + + async joinRoom(roomId: string): Promise { + this.ensureConnected() + return new Promise((resolve, reject) => { + this.socket!.emit('join', { roomId }, (res: JoinResult | { error: string }) => { + if ('error' in res) { + reject(new Error(res.error)) + } else { + this.joinedRooms.add(roomId) + resolve(res) + } + }) + }) + } + + sendMessage(roomId: string, content: string, messageId?: string, extra?: Record): Promise { + this.ensureConnected() + return new Promise((resolve, reject) => { + this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => { + if (res.error) { + reject(new Error(res.error)) + } else { + resolve(res.id!) + } + }) + }) + } + + startTyping(roomId: string): void { + this.ensureConnected() + this.socket!.emit('typing', { roomId }) + } + + stopTyping(roomId: string): void { + this.ensureConnected() + this.socket!.emit('stop_typing', { roomId }) + } + + emitContextStatus(roomId: string, status: 'compressing' | 'replying' | 'ready', extra?: Record): void { + this.ensureConnected() + this.socket!.emit('context_status', { roomId, agentName: this.name, status, ...extra }) + } + + emitApprovalRequested(roomId: string, payload: Record): void { + this.ensureConnected() + this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload }) + } + + emitApprovalResolved(roomId: string, payload: Record): void { + this.ensureConnected() + this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload }) + } + + async interrupt(roomId: string): Promise { + const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') + const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) + await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile) + this.stopTyping(roomId) + this.emitContextStatus(roomId, 'ready') + } + + emitMessageStreamStart(roomId: string, messageId: string): void { + this.ensureConnected() + this.socket!.emit('message_stream_start', { + roomId, + id: messageId, + senderId: this.socket?.id || this.agentId, + senderName: this.name, + timestamp: Date.now(), + }) + } + + emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void { + if (!delta) return + this.ensureConnected() + this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta }) + } + + emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void { + if (!delta) return + this.ensureConnected() + this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta }) + } + + emitMessageStreamEnd(roomId: string, messageId: string): void { + this.ensureConnected() + this.socket!.emit('message_stream_end', { roomId, id: messageId }) + } + + getJoinedRooms(): string[] { + return Array.from(this.joinedRooms) + } + + private finiteToken(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined + } + + private cacheBridgeContext(sessionId: string, data: Record | AgentBridgeContextEstimate, instructions?: string): void { + const fixedContextTokens = this.finiteToken(data.fixed_context_tokens) + if (fixedContextTokens == null) return + this.bridgeContextCache.set(sessionId, { + fixedContextTokens, + instructions, + systemPromptTokens: this.finiteToken(data.system_prompt_tokens), + toolTokens: this.finiteToken(data.tool_tokens), + systemPromptChars: this.finiteToken(data.system_prompt_chars), + toolCount: this.finiteToken(data.tool_count), + toolNames: Array.isArray(data.tool_names) ? data.tool_names.map(String) : undefined, + profile: typeof data.profile === 'string' ? data.profile : undefined, + model: typeof data.model === 'string' ? data.model : undefined, + provider: typeof data.provider === 'string' ? data.provider : undefined, + }) + } + + private estimateHistoryMessageTokens(history: GroupEstimateMessage[]): number { + return estimateGroupHistoryMessageTokens(history) + } + + private estimateWithCachedBridgeContext(sessionId: string, history: GroupEstimateMessage[], instructions?: string): number | undefined { + const cache = this.bridgeContextCache.get(sessionId) + if (!cache) return undefined + if (cache.instructions !== instructions) return undefined + return groupContextTokensWithFixedOverhead(cache.fixedContextTokens, history) + } + + private async estimateGroupContextTokens( + roomId: string, + sessionId: string, + bridge: AgentBridgeClient, + history: GroupEstimateMessage[], + instructions: string | undefined, + phase: string, + ): Promise { + const cachedTokens = this.estimateWithCachedBridgeContext(sessionId, history, instructions) + if (cachedTokens != null) { + logger.info({ + roomId, + agentName: this.name, + profile: this.profile, + sessionId, + messages: history.length, + fixedContextTokens: this.bridgeContextCache.get(sessionId)?.fixedContextTokens, + messageTokens: cachedTokens - (this.bridgeContextCache.get(sessionId)?.fixedContextTokens || 0), + fullContextTokens: cachedTokens, + phase, + source: 'cache', + }, '[GroupChat] full context estimate') + return cachedTokens + } + + const estimate = await bridge.contextEstimate( + sessionId, + history, + instructions, + this.profile, + ) + this.cacheBridgeContext(sessionId, estimate, instructions) + const totalTokens = Number(estimate.token_count || 0) + logger.info({ + roomId, + agentName: this.name, + profile: this.profile, + sessionId, + messages: estimate.message_count, + toolCount: estimate.tool_count, + systemPromptChars: estimate.system_prompt_chars, + fixedContextTokens: estimate.fixed_context_tokens, + fullContextTokens: estimate.token_count, + phase, + source: 'bridge', + }, '[GroupChat] full context estimate') + return Number.isFinite(totalTokens) && totalTokens > 0 ? Math.floor(totalTokens) : undefined + } + + private ensureConnected(): void { + if (!this.socket?.connected) { + throw new Error(`Agent "${this.name}" is not connected`) + } + } + + // ─── Hermes Agent Bridge Integration ─────────────────────── + + /** + * Handle an @mention from the server side. + * Called by AgentClients.processMentions() — no socket round-trip needed. + * onStatus is called to report context compression progress. + */ + async replyToMention( + roomId: string, + msg: MentionMessage, + onStatus?: (status: 'compressing' | 'replying' | 'ready', extra?: Record) => void, + ): Promise { + logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`) + const runMessageId = groupMessageId(roomId, this.profile, this.name) + let partIndex = 0 + let streamMessageId = groupMessagePartId(runMessageId, partIndex) + let currentContent = '' + let totalContent = '' + let reasoningContent = '' + let streamStarted = false + try { + // Notify room that agent is typing + this.startTyping(roomId) + + // Build compressed context if context engine is available + let conversationHistory: Array<{ role: string; content: string }> = [] + let instructions: string | undefined + const bridge = new AgentBridgeClient() + const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') + const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) + + if (this.contextEngine && this.storage) { + try { + logger.debug(`[AgentClients] ${this.name}: building context...`) + // Get room members with descriptions for context + const roomMembers: Array<{ userId: string; name: string; description: string }> = this.storage.getRoomMembers(roomId) || [] + const memberNames = roomMembers.map((m: any) => m.name) + const members = roomMembers.map((m: any) => ({ userId: m.userId, name: m.name, description: m.description })) + + // Get room compression config + const roomInfo = this.storage.getRoom(roomId) + const compression = roomInfo ? { + triggerTokens: roomInfo.triggerTokens, + maxHistoryTokens: roomInfo.maxHistoryTokens, + tailMessageCount: roomInfo.tailMessageCount, + } : undefined + + const ctx = await this.contextEngine.buildContext({ + roomId, + agentId: this.agentId, + agentName: this.name, + agentDescription: this.description, + agentSocketId: this.socket?.id || '', + roomName: roomId, + memberNames, + members, + upstream: '', + apiKey: null, + currentMessage: msg, + compression, + profile: this.profile, + onProgress: (event: { status: 'compressing'; messageCount: number; tokenCount: number }) => { + onStatus?.('compressing', { + messageCount: event.messageCount, + totalTokens: event.tokenCount, + }) + }, + contextTokenEstimator: async (history: Array<{ role: 'user' | 'assistant'; content: string }>, estimateInstructions: string) => { + return this.estimateGroupContextTokens( + roomId, + sessionId, + bridge, + history, + estimateInstructions, + 'build', + ) + }, + }) + conversationHistory = ctx.conversationHistory + instructions = ctx.instructions + if (typeof ctx.meta.contextTokenEstimate === 'number' && Number.isFinite(ctx.meta.contextTokenEstimate)) { + this.storage.updateRoomTotalTokens?.(roomId, ctx.meta.contextTokenEstimate) + onStatus?.('replying', { totalTokens: ctx.meta.contextTokenEstimate }) + } + logger.debug(`[AgentClients] ${this.name}: context built — historyLen=${conversationHistory.length}, meta=%j`, ctx.meta) + onStatus?.('replying') + } catch (err: any) { + logger.warn(`[AgentClients] ${this.name}: context engine failed: ${err.message}`) + onStatus?.('replying') + // Degrade: continue without context + } + } + + // Keep routing explicit while removing only the mention tokens that + // selected this agent. This avoids making @all look like an + // instruction for the model to fan out another routing cycle. + const routedPrefix = isAllAgentsMentioned(msg.content) + ? `群聊系统:这条消息通过 @all 提及所有 agent,你是其中之一,请直接回复。` + : `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。` + const rawInput = msg.input || msg.content + const input = isContentBlockArray(rawInput) + ? rawInput.map((block) => { + if (block.type !== 'text') return block + const text = stripMentionRoutingTokens(String(block.text || msg.content), this.name) + return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` } + }) + : `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}` + const runContext = [ + `[Current Hermes profile: ${this.profile}]`, + 'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.', + ].join('\n') + instructions = instructions ? `${runContext}\n${instructions}` : runContext + const bridgeInput: AgentBridgeMessage = isContentBlockArray(input) + ? await convertContentBlocksForAgent(input) + : input + const flushedAssistantParts = new Set() + let lastChunk: AgentBridgeOutput | null = null + const started = await bridge.chat( + sessionId, + bridgeInput, + conversationHistory, + instructions, + this.profile, + { + source: 'api_server', + }, + ) + + this.emitMessageStreamStart(roomId, streamMessageId) + streamStarted = true + for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) { + lastChunk = chunk + reasoningContent += await this.recordBridgeEvents(roomId, sessionId, instructions, chunk, () => streamMessageId, async () => { + const toolBaseId = streamMessageId + if (currentContent.trim()) { + await this.sendMessage(roomId, currentContent, streamMessageId, { + role: 'assistant', + mentionDepth: nextMentionDepth(msg), + reasoning: reasoningContent || null, + reasoning_content: reasoningContent || null, + }) + flushedAssistantParts.add(streamMessageId) + currentContent = '' + } + this.emitMessageStreamEnd(roomId, toolBaseId) + partIndex += 1 + streamMessageId = groupMessagePartId(runMessageId, partIndex) + this.emitMessageStreamStart(roomId, streamMessageId) + streamStarted = true + return toolBaseId + }) + if (chunk.delta) { + currentContent += chunk.delta + totalContent += chunk.delta + this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta) + } + } + + if (lastChunk?.status === 'error') { + logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`) + await this.sendAgentErrorMessage(roomId, streamMessageId, lastChunk.error || 'Run failed', msg, reasoningContent) + this.emitMessageStreamEnd(roomId, streamMessageId) + this.stopTyping(roomId) + onStatus?.('ready') + return + } + + if (!totalContent) { + currentContent = extractBridgeFinalText(lastChunk) + totalContent = currentContent + } + recordBridgeUsage(roomId, this.profile, lastChunk?.result) + logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`) + if (currentContent) { + this.stopTyping(roomId) + await this.sendMessage(roomId, currentContent, streamMessageId, { + role: 'assistant', + mentionDepth: nextMentionDepth(msg), + reasoning: reasoningContent || null, + reasoning_content: reasoningContent || null, + }) + this.emitMessageStreamEnd(roomId, streamMessageId) + await this.refreshRoomFullContextEstimate(roomId, sessionId, bridge, instructions) + onStatus?.('ready') + return + } + logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`) + this.emitMessageStreamEnd(roomId, streamMessageId) + this.stopTyping(roomId) + onStatus?.('ready') + } catch (err: any) { + logger.error(`[AgentClients] ${this.name}: error handling message: ${err.message}`) + try { + await this.sendAgentErrorMessage(roomId, streamMessageId, err, msg, reasoningContent) + if (streamStarted) this.emitMessageStreamEnd(roomId, streamMessageId) + } catch (sendErr: any) { + logger.warn(`[AgentClients] ${this.name}: failed to send error message: ${sendErr.message}`) + } + this.stopTyping(roomId) + onStatus?.('ready') + } + } + + private async refreshRoomFullContextEstimate( + roomId: string, + sessionId: string, + bridge: AgentBridgeClient, + instructions?: string, + ): Promise { + if (!this.storage?.getMessages) return + try { + const history = this.buildRoomEstimateHistory(roomId) + const cachedTokens = await this.estimateGroupContextTokens( + roomId, + sessionId, + bridge, + history, + instructions, + 'final', + ) + if (cachedTokens == null || cachedTokens <= 0) return + const rounded = Math.floor(cachedTokens) + this.storage.updateRoomTotalTokens?.(roomId, rounded) + this.emitContextStatus(roomId, 'replying', { totalTokens: rounded }) + } catch (err: any) { + logger.warn(`[GroupChat] failed to refresh final context estimate room=${roomId} agent=${this.name}: ${err.message}`) + } + } + + private buildRoomEstimateHistory(roomId: string): Array<{ role: 'user' | 'assistant'; content: string }> { + const messages = this.storage?.getMessages?.(roomId) || [] + return messages.map((message: any) => this.mapRoomMessageForEstimate(message)) + } + + private mapRoomMessageForEstimate(message: any): { role: 'user' | 'assistant'; content: string } { + const senderName = String(message?.senderName || 'unknown') + const role = String(message?.role || 'user') + const isOwnAgent = message?.senderId === this.socket?.id || senderName === this.name + + if (role === 'tool') { + const label = message?.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result' + return { role: 'user', content: `[${senderName}] [${label}]\n${message?.content || ''}` } + } + + if (role === 'assistant' && Array.isArray(message?.tool_calls) && message.tool_calls.length > 0) { + const toolsInfo = message.tool_calls.map((toolCall: any) => { + const name = toolCall?.function?.name || 'unknown' + let args = String(toolCall?.function?.arguments || '{}') + if (args.length > 4000) args = `${args.slice(0, 4000)}...` + return `[Calling tool: ${name} with arguments: ${args}]` + }).join('\n') + const content = String(message?.content || '').trim() + return { + role: isOwnAgent ? 'assistant' : 'user', + content: content + ? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName)}${toolsInfo}` + : `${this.formatAttributionPrefix(senderName)}${toolsInfo}`, + } + } + + return { + role: isOwnAgent ? 'assistant' : 'user', + content: this.formatAttributedContent(senderName, String(message?.content || '')), + } + } + + private formatAttributedContent(senderName: string, content: string): string { + return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}` + } + + private formatAttributionPrefix(senderName: string): string { + return `[${senderName}]: ` + } + + private stripMentions(content: string): string { + return String(content || '') + .replace(/@([^\s@]+)/g, '') + .replace(/[ \t]{2,}/g, ' ') + .replace(/^\s+/, '') + } + + private async sendAgentErrorMessage( + roomId: string, + messageId: string, + error: unknown, + sourceMsg: MentionMessage, + reasoningContent = '', + ): Promise { + const detail = error instanceof Error ? error.message : String(error || 'Run failed') + const content = detail.startsWith('Error:') ? detail : `Error: ${detail}` + await this.sendMessage(roomId, content, messageId, { + role: 'assistant', + mentionDepth: nextMentionDepth(sourceMsg), + finish_reason: 'error', + reasoning: reasoningContent || null, + reasoning_content: reasoningContent || null, + }) + } + + private async recordBridgeEvents( + roomId: string, + sessionId: string, + instructions: string | undefined, + chunk: AgentBridgeOutput, + getCurrentMessageId: () => string, + beforeToolStarted: () => Promise, + ): Promise { + let reasoning = '' + for (const ev of chunk.events || []) { + const eventType = String((ev as any)?.event || '') + if (eventType === 'bridge.context.ready') { + this.cacheBridgeContext(sessionId, ev as Record, instructions) + } else if (eventType === 'tool.started') { + const toolBaseId = await beforeToolStarted() + this.recordToolStarted(roomId, ev as Record, toolBaseId) + } else if (eventType === 'tool.completed') { + this.recordToolCompleted(roomId, ev as Record) + } else if (eventType === 'approval.requested') { + this.emitApprovalRequested(roomId, { + event: 'approval.requested', + approval_id: (ev as any).approval_id, + command: (ev as any).command, + description: (ev as any).description, + choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined, + allow_permanent: (ev as any).allow_permanent, + }) + } else if (eventType === 'approval.resolved') { + this.emitApprovalResolved(roomId, { + event: 'approval.resolved', + approval_id: (ev as any).approval_id, + choice: (ev as any).choice, + }) + } else { + const text = groupBridgeReasoningDeltaFromEvent(ev as Record) + if (text) { + reasoning += text + this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text) + } + } + } + return reasoning + } + + private recordToolStarted(roomId: string, ev: Record, runMessageId: string): void { + const toolName = String(ev.tool_name || ev.tool || ev.name || '') + const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName)) + this.trackPendingToolCall(roomId, toolName, toolCallId) + this.pendingToolBaseIds.set(toolCallId, runMessageId) + const timestamp = Date.now() + const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {} + const args = normalizeToolArgs(rawArgs) + const toolCall = { + id: toolCallId, + type: 'function', + function: { + name: toolName, + arguments: JSON.stringify(args), + }, + } + const msg: MessageData & Record = { + id: `${runMessageId}_toolcall_${safeId(toolCallId)}`, + roomId, + senderId: this.socket?.id || this.agentId, + senderName: this.name, + content: '', + timestamp, + role: 'assistant', + tool_calls: [toolCall], + finish_reason: 'tool_calls', + } + this.sendMessage(roomId, '', msg.id, { + role: 'assistant', + tool_calls: msg.tool_calls, + finish_reason: 'tool_calls', + timestamp, + }).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`)) + } + + private recordToolCompleted(roomId: string, ev: Record): void { + const toolName = String(ev.tool_name || ev.tool || ev.name || '') + const rawId = String(ev.tool_call_id || '').trim() + const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName)) + const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0) + this.pendingToolBaseIds.delete(toolCallId) + const output = bridgeToolOutput(ev) + const timestamp = Date.now() + const msg: MessageData & Record = { + id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`, + roomId, + senderId: this.socket?.id || this.agentId, + senderName: this.name, + content: output, + timestamp, + role: 'tool', + tool_call_id: toolCallId, + tool_name: toolName || null, + } + this.sendMessage(roomId, output, msg.id, { + role: 'tool', + tool_call_id: toolCallId, + tool_name: toolName || null, + timestamp, + }).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`)) + } + + private pendingToolKey(roomId: string, toolName: string): string { + return `${roomId}::${toolName || 'tool'}` + } + + private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void { + const key = this.pendingToolKey(roomId, toolName) + const list = this.pendingToolCallIds.get(key) || [] + list.push(toolCallId) + this.pendingToolCallIds.set(key, list) + } + + private takePendingToolCall(roomId: string, toolName: string): string | undefined { + const key = this.pendingToolKey(roomId, toolName) + const list = this.pendingToolCallIds.get(key) + if (!list?.length) return undefined + const id = list.shift() + if (list.length) this.pendingToolCallIds.set(key, list) + else this.pendingToolCallIds.delete(key) + return id + } + + private nextToolIndex(roomId: string, toolName: string): number { + const key = this.pendingToolKey(roomId, toolName) + return (this.pendingToolCallIds.get(key)?.length || 0) + 1 + } + + private bindEvents(): void { + const s = this.socket! + + s.on('typing', (data: any) => { + this.handlers.onTyping?.(data) + }) + + s.on('stop_typing', (data: any) => { + this.handlers.onStopTyping?.(data) + }) + + s.on('member_joined', (data: any) => { + this.handlers.onMemberJoined?.(data) + }) + + s.on('member_left', (data: any) => { + this.handlers.onMemberLeft?.(data) + }) + + // Auto rejoin rooms on reconnect + s.io.on('reconnect', async () => { + if (this._reconnecting) return + this._reconnecting = true + logger.info(`[AgentClients] ${this.name} reconnecting, rejoining ${this.joinedRooms.size} rooms...`) + const rooms = Array.from(this.joinedRooms) + for (const roomId of rooms) { + try { + await this.joinRoom(roomId) + } catch (err: any) { + logger.error(`[AgentClients] ${this.name} failed to rejoin room ${roomId}: ${err.message}`) + } + } + this._reconnecting = false + }) + } +} + +function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string { + const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}` + return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120) +} + +function groupMessageId(roomId: string, profile: string, name: string): string { + const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160) +} + +function groupMessagePartId(runMessageId: string, partIndex: number): string { + return `${safeId(runMessageId)}_part_${partIndex}` +} + +function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string { + const raw = String(rawToolCallId || '').trim() + if (raw) return raw + return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}` +} + +function safeId(value: string): string { + return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) +} + +function bridgeToolOutput(ev: Record): string { + const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? '' + return typeof value === 'string' ? value : JSON.stringify(value ?? '') +} + +function normalizeToolArgs(value: unknown): Record { + if (!value) return {} + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : { value } + } catch { + return { value } + } + } + return typeof value === 'object' && !Array.isArray(value) ? value as Record : { value } +} + +function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string { + const result = chunk?.result as any + const output = result?.final_response || chunk?.output || '' + return typeof output === 'string' ? output.trim() : '' +} + +function recordBridgeUsage(roomId: string, profile: string, result: unknown): void { + const payload = result as any + const usage = payload?.usage || payload?.response?.usage + if (!usage) return + updateUsage(roomId, { + inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0, + outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0, + cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0, + cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0, + reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0, + model: payload?.model || payload?.response?.model || '', + profile, + }) +} + +// ─── AgentClients (roomId -> agents) ────────────────────────── + +export class AgentClients { + private rooms = new Map>() + private _contextEngine: any = null + private _storage: any = null + + // Per-room processing lock + mention queue + private _processingRooms = new Set() + private _mentionQueue = new Map>() + + /** + * Create an agent client and connect it to the server. + * The agent will NOT auto-join any room — call addAgentToRoom separately. + */ + async createAgent(config: AgentConfig, handlers?: AgentEventHandler, port?: number): Promise { + const client = new AgentClient(config, handlers) + await client.connect(port) + + // Auto-apply stored references (fixes propagation for agents created after set*) + if (this._contextEngine) client.setContextEngine(this._contextEngine) + if (this._storage) client.setStorage(this._storage) + + logger.info(`[AgentClients] Connected: ${client.name} (${client.agentId})`) + return client + } + + /** + * Connect an agent to a room. + */ + async addAgentToRoom(roomId: string, client: AgentClient): Promise { + let room = this.rooms.get(roomId) + if (!room) { + room = new Map() + this.rooms.set(roomId, room) + } + + room.set(client.agentId, client) + try { + const result = await client.joinRoom(roomId) + logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`) + return result + } catch (err) { + room.delete(client.agentId) + if (room.size === 0) this.rooms.delete(roomId) + client.disconnect() + throw err + } + } + + /** + * Remove an agent from a room and disconnect it. + */ + removeAgentFromRoom(roomId: string, agentId: string): void { + const room = this.rooms.get(roomId) + if (!room) return + + const client = room.get(agentId) + if (client) { + client.disconnect() + room.delete(agentId) + logger.info(`[AgentClients] ${client.name} left room: ${roomId}`) + + // Invalidate context engine cache for this agent + if (this._contextEngine) { + try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ } + } + } + + if (room.size === 0) { + this.rooms.delete(roomId) + } + } + + /** + * Get all agents in a room. + */ + getAgents(roomId: string): AgentClient[] { + const room = this.rooms.get(roomId) + return room ? Array.from(room.values()) : [] + } + + /** + * Get a specific agent in a room. + */ + getAgent(roomId: string, agentId: string): AgentClient | undefined { + return this.rooms.get(roomId)?.get(agentId) + } + + /** + * Get all room IDs that have agents. + */ + getRoomIds(): string[] { + return Array.from(this.rooms.keys()) + } + + /** + * Send a message from a specific agent in a room. + */ + async sendMessage(roomId: string, agentId: string, content: string): Promise { + const client = this.getAgent(roomId, agentId) + if (!client) { + throw new Error(`Agent "${agentId}" not found in room "${roomId}"`) + } + return client.sendMessage(roomId, content) + } + + /** + * Broadcast a message from all agents in a room. + */ + async broadcastFromRoom(roomId: string, content: string): Promise { + const agents = this.getAgents(roomId) + return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content))) + } + + async interruptAgent(roomId: string, agentName: string): Promise { + const agent = this.getAgents(roomId).find(a => a.name === agentName) + if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`) + this._mentionQueue.delete(`${roomId}:${agent.name}`) + await agent.interrupt(roomId) + } + + /** + * Disconnect all agents in a room. + */ + disconnectRoom(roomId: string): void { + const room = this.rooms.get(roomId) + if (!room) return + + room.forEach((client) => client.disconnect()) + this.rooms.delete(roomId) + logger.info(`[AgentClients] All agents disconnected from room: ${roomId}`) + + // Invalidate context engine cache for this room + if (this._contextEngine) { + try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ } + } + } + + resetRoomContext(roomId: string): void { + this._mentionQueue.delete(roomId) + for (const key of Array.from(this._mentionQueue.keys())) { + if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key) + } + for (const key of Array.from(this._processingRooms)) { + if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key) + } + if (this._contextEngine) { + try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ } + } + } + + /** + * Disconnect all agents in all rooms. + */ + disconnectAll(): void { + this.rooms.forEach((room) => { + room.forEach((client) => client.disconnect()) + }) + this.rooms.clear() + logger.info('[AgentClients] All agents disconnected') + } + + /** + * Set context engine for all existing and future agents. + */ + setContextEngine(engine: any): void { + this._contextEngine = engine + this.rooms.forEach((room) => { + room.forEach((client) => client.setContextEngine(engine)) + }) + } + + /** + * Set message storage for all existing and future agents. + */ + setStorage(storage: any): void { + this._storage = storage + this.rooms.forEach((room) => { + room.forEach((client) => client.setStorage(storage)) + }) + } + + + /** + * Server-side: parse @mentions and forward to matching agents directly. + * If the room is already processing (compressing/replying), queue the mention. + */ + async processMentions(roomId: string, msg: MentionMessage): Promise { + const agents = this.getAgents(roomId) + const mentioned = resolveMentionTargets(agents, msg.content, msg.senderId) + if (mentioned.length === 0) return + + logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`) + + for (const agent of mentioned) { + this._processAgentMention(roomId, agent, msg).catch((err) => { + logger.error(`[AgentClients] error processing mention for ${agent.name}: ${err.message}`) + }) + } + } + + /** + * Process a single agent mention with status reporting and queue drain. + */ + private async _processAgentMention( + roomId: string, + agent: AgentClient, + msg: MentionMessage, + ): Promise { + const agentKey = `${roomId}:${agent.name}` + if (this._processingRooms.has(agentKey)) { + // Queue for this specific agent + let queue = this._mentionQueue.get(agentKey) + if (!queue) { + queue = [] + this._mentionQueue.set(agentKey, queue) + } + queue.push({ agent, msg }) + logger.debug(`[AgentClients] agent ${agent.name} is processing, queued mention in room ${roomId}`) + return + } + + this._processingRooms.add(agentKey) + const onStatus = (status: 'compressing' | 'replying' | 'ready', extra?: Record) => { + agent.emitContextStatus(roomId, status, extra) + logger.debug(`[AgentClients] room ${roomId} agent ${agent.name} status: ${status}`) + } + + try { + await agent.replyToMention(roomId, msg, onStatus) + } finally { + this._processingRooms.delete(agentKey) + await this._drainQueue(agentKey, roomId) + } + } + + /** + * Drain queued mentions for a room after processing completes. + */ + private async _drainQueue(agentKey: string, roomId: string): Promise { + const queue = this._mentionQueue.get(agentKey) + if (!queue || queue.length === 0) return + + this._mentionQueue.delete(agentKey) + logger.debug(`[AgentClients] draining ${queue.length} queued mention(s) for ${agentKey}`) + + // Process the last queued mention only (most recent, discards stale intermediate ones) + const last = queue[queue.length - 1] + await this._processAgentMention(roomId, last.agent, last.msg) + } +} + +function nextMentionDepth(msg: MentionMessage): number { + return Math.max(0, msg.mentionDepth || 0) + 1 +} diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts new file mode 100644 index 0000000..5050919 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -0,0 +1,1272 @@ +import { Server, Socket, Namespace } from 'socket.io' +import type { Server as HttpServer } from 'http' +import { logger } from '../../../services/logger' +import { getDb } from '../../../db' +import { normalizeMessageContentForStorage, normalizeMessageContentForStorageRole } from '../../../db/hermes/message-content' +import { AgentClients, GROUP_CHAT_AGENT_SOCKET_SECRET } from './agent-clients' +import { ContextEngine } from '../context-engine/compressor' +import { SessionDeleter } from '../session-deleter' +import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' +import { AgentBridgeClient } from '../agent-bridge' +import { authenticateUserToken, isAuthEnabled } from '../../../middleware/user-auth' + +// ─── Types ──────────────────────────────────────────────────── + +interface ChatMessage { + id: string + roomId: string + senderId: string + senderName: string + content: string + timestamp: number + role?: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + finish_reason?: string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null + mentionDepth?: number +} + +function contentToStorageString(content: unknown): string { + if (typeof content === 'string') return content + return JSON.stringify(content ?? '') +} + +function messageContentForStorage(role: string | undefined, content: string): string { + return normalizeMessageContentForStorageRole(role, content) +} + +function contentToText(content: unknown): string { + if (typeof content === 'string') { + const trimmed = content.trim() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + return contentToText(JSON.parse(trimmed)) + } catch { + return content + } + } + return content + } + if (Array.isArray(content)) { + return content.map((block: any) => { + if (block?.type === 'text') return block.text || '' + if (block?.type === 'image') return `[Image: ${block.name || block.path || ''}]` + if (block?.type === 'file') return `[File: ${block.name || block.path || ''}]` + return '' + }).filter(Boolean).join('\n') + } + return content == null ? '' : String(content) +} + +interface RoomAgent { + id: string + roomId: string + agentId: string + profile: string + name: string + description: string + invited: number +} + +interface RoomInfo { + id: string + name: string + inviteCode: string | null + triggerTokens: number + maxHistoryTokens: number + tailMessageCount: number + totalTokens: number + sessionSeed: string +} + +interface Member { + id: string + userId: string + name: string + description: string + joinedAt: number + online: boolean + socketId: string + source?: 'human' | 'agent' +} + +let _tablesEnsured = false + +interface PendingSessionDelete { + session_id: string + profile_name: string + status: string + attempt_count: number + last_error: string | null + created_at: number + updated_at: number + next_attempt_at: number +} + +interface GroupChatSessionProfile { + session_id: string + room_id: string + agent_id: string + profile_name: string + created_at: number +} + +export interface PendingSessionDeleteDrainResult { + deleted: string[] + failed: Array<{ sessionId: string; error: string }> +} + +function parseJsonArray(value: unknown): any[] | null { + if (value == null || value === '') return null + if (Array.isArray(value)) return value + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +function normalizeMessageRole(role: unknown): string { + const value = String(role || '').trim() + return ['user', 'assistant', 'tool', 'command'].includes(value) ? value : 'user' +} + +function normalizeMentionDepth(depth: unknown): number { + const value = Number(depth) + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 +} + +function maxAgentMentionDepth(): number { + const value = Number(process.env.HERMES_GROUP_CHAT_MAX_AGENT_MENTION_DEPTH) + if (!Number.isFinite(value) || value <= 0) return 4 + return Math.min(10, Math.floor(value)) +} + +function groupRunOrder(id: string): { baseId: string; phase: number } { + const value = String(id || '') + const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/) + if (partMatch) { + const part = Number(partMatch[2] || 0) + const kind = partMatch[3] || 'assistant' + const offset = kind === 'toolcall' ? 1 : kind === 'toolresult' ? 2 : 0 + return { baseId: partMatch[1], phase: part * 3 + offset } + } + const toolIdx = value.indexOf('_toolcall_') + if (toolIdx >= 0) return { baseId: value.slice(0, toolIdx), phase: 0 } + const resultIdx = value.indexOf('_toolresult_') + if (resultIdx >= 0) return { baseId: value.slice(0, resultIdx), phase: 1 } + return { baseId: value, phase: 2 } +} + +function sortGroupMessages(messages: T[]): T[] { + const baseMinTimestamp = new Map() + for (const msg of messages) { + const { baseId } = groupRunOrder(msg.id) + const existing = baseMinTimestamp.get(baseId) + if (existing == null || msg.timestamp < existing) baseMinTimestamp.set(baseId, msg.timestamp) + } + return [...messages].sort((a, b) => { + const ao = groupRunOrder(a.id) + const bo = groupRunOrder(b.id) + const at = baseMinTimestamp.get(ao.baseId) ?? a.timestamp + const bt = baseMinTimestamp.get(bo.baseId) ?? b.timestamp + if (at !== bt) return at - bt + if (ao.baseId !== bo.baseId) return ao.baseId.localeCompare(bo.baseId) + if (ao.phase !== bo.phase) return ao.phase - bo.phase + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp + return a.id.localeCompare(b.id) + }) +} + +class ChatStorage { + private db() { return getDb() } + + init(): void { + if (_tablesEnsured) return + const db = this.db() + if (!db) return + // Tables are now created centrally in initAllHermesTables() + // Only create indexes here + try { db.exec('CREATE INDEX IF NOT EXISTS idx_gc_messages_room ON gc_messages(roomId, timestamp)') } catch { /* ignore */ } + try { db.exec('CREATE INDEX IF NOT EXISTS idx_gc_room_agents_room ON gc_room_agents(roomId)') } catch { /* ignore */ } + try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_gc_room_members_unique ON gc_room_members(roomId, userId)') } catch { /* ignore */ } + try { db.exec('CREATE INDEX IF NOT EXISTS idx_gc_pending_session_deletes_profile ON gc_pending_session_deletes(profile_name, status, next_attempt_at, created_at)') } catch { /* ignore */ } + try { db.exec('CREATE INDEX IF NOT EXISTS idx_gc_session_profiles_profile ON gc_session_profiles(profile_name, created_at)') } catch { /* ignore */ } + _tablesEnsured = true + } + + saveSessionProfile(sessionId: string, roomId: string, agentId: string, profileName: string): void { + this.db()?.prepare( + 'INSERT INTO gc_session_profiles (session_id, room_id, agent_id, profile_name, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET room_id = excluded.room_id, agent_id = excluded.agent_id, profile_name = excluded.profile_name' + ).run(sessionId, roomId, agentId, profileName, Date.now()) + } + + getSessionProfile(sessionId: string): GroupChatSessionProfile | null { + return (this.db()?.prepare( + 'SELECT session_id, room_id, agent_id, profile_name, created_at FROM gc_session_profiles WHERE session_id = ?' + ).get(sessionId) as GroupChatSessionProfile | undefined) ?? null + } + + deleteSessionProfile(sessionId: string): void { + this.db()?.prepare('DELETE FROM gc_session_profiles WHERE session_id = ?').run(sessionId) + } + + listPendingSessionDeletes(profileName: string, limit = 50): PendingSessionDelete[] { + const rows = this.db()?.prepare( + `SELECT session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at + FROM gc_pending_session_deletes + WHERE profile_name = ? AND status = 'pending' AND next_attempt_at <= ? + ORDER BY created_at ASC + LIMIT ?` + ).all(profileName, Date.now(), limit) || [] + return rows.map((row: any) => ({ + session_id: String(row.session_id || ''), + profile_name: String(row.profile_name || ''), + status: String(row.status || 'pending'), + attempt_count: Number(row.attempt_count || 0), + last_error: row.last_error == null ? null : String(row.last_error), + created_at: Number(row.created_at || 0), + updated_at: Number(row.updated_at || 0), + next_attempt_at: Number(row.next_attempt_at || 0), + })) + } + + enqueuePendingSessionDelete(sessionId: string, profileName: string): void { + const now = Date.now() + this.db()?.prepare( + `INSERT INTO gc_pending_session_deletes (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'pending', 0, NULL, ?, ?, 0) + ON CONFLICT(session_id) DO UPDATE SET + profile_name = excluded.profile_name, + status = 'pending', + updated_at = excluded.updated_at, + next_attempt_at = 0` + ).run(sessionId, profileName, now, now) + } + + claimPendingSessionDeletes(profileName: string, limit = 50): PendingSessionDelete[] { + const rows = this.listPendingSessionDeletes(profileName, limit) + if (rows.length === 0) return [] + const now = Date.now() + const stmt = this.db()?.prepare( + `UPDATE gc_pending_session_deletes + SET status = 'processing', updated_at = ? + WHERE session_id = ? AND status = 'pending'` + ) + const claimed: PendingSessionDelete[] = [] + for (const row of rows) { + const result = stmt?.run(now, row.session_id) + if (result?.changes) { + claimed.push({ ...row, status: 'processing', updated_at: now }) + } + } + return claimed + } + + markPendingSessionDeleteFailed(sessionId: string, error: string): void { + const now = Date.now() + this.db()?.prepare( + `UPDATE gc_pending_session_deletes + SET status = 'pending', + attempt_count = attempt_count + 1, + last_error = ?, + updated_at = ?, + next_attempt_at = ? + WHERE session_id = ?` + ).run(error, now, now + 60_000, sessionId) + } + + removePendingSessionDelete(sessionId: string): void { + this.db()?.prepare('DELETE FROM gc_pending_session_deletes WHERE session_id = ?').run(sessionId) + } + + getPendingDeletedSessionIds(): Set { + const rows = (this.db()?.prepare( + `SELECT session_id FROM gc_pending_session_deletes WHERE status IN ('pending', 'processing')` + ).all() || []) as Array<{ session_id: string }> + return new Set(rows.map(row => row.session_id)) + } + + // ─── Rooms ──────────────────────────────────────────────── + + getRoom(roomId: string): RoomInfo | undefined { + return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any + } + + getRoomByInviteCode(code: string): RoomInfo | undefined { + return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any + } + + getAllRooms(): RoomInfo[] { + return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[] + } + + getRoomsForProfiles(profiles: string[]): RoomInfo[] { + const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))] + if (!uniqueProfiles.length) return [] + const placeholders = uniqueProfiles.map(() => '?').join(', ') + return (this.db()?.prepare( + `SELECT DISTINCT r.id, r.name, r.inviteCode, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed + FROM gc_rooms r + INNER JOIN gc_room_agents a ON a.roomId = r.id + WHERE a.profile IN (${placeholders}) + ORDER BY r.id` + ).all(...uniqueProfiles) || []) as any[] + } + + saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void { + this.db()?.prepare( + 'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)' + ).run(id, name, inviteCode || null, config?.triggerTokens ?? 100000, config?.maxHistoryTokens ?? 32000, config?.tailMessageCount ?? 10) + } + + updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void { + const sets: string[] = [] + const vals: any[] = [] + if (config.triggerTokens !== undefined) { sets.push('triggerTokens = ?'); vals.push(config.triggerTokens) } + if (config.maxHistoryTokens !== undefined) { sets.push('maxHistoryTokens = ?'); vals.push(config.maxHistoryTokens) } + if (config.tailMessageCount !== undefined) { sets.push('tailMessageCount = ?'); vals.push(config.tailMessageCount) } + if (sets.length === 0) return + vals.push(roomId) + this.db()?.prepare(`UPDATE gc_rooms SET ${sets.join(', ')} WHERE id = ?`).run(...vals) + } + + updateRoomInviteCode(roomId: string, inviteCode: string): void { + this.db()?.prepare('UPDATE gc_rooms SET inviteCode = ? WHERE id = ?').run(inviteCode, roomId) + } + + updateRoomTotalTokens(roomId: string, tokens: number): void { + this.db()?.prepare('UPDATE gc_rooms SET totalTokens = ? WHERE id = ?').run(tokens, roomId) + } + + rotateRoomSessionSeed(roomId: string): string { + const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` + this.db()?.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?').run(seed, roomId) + return seed + } + + estimateTokens(text: string): number { + const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length + const other = text.length - cjk + return Math.ceil(cjk * 1.5 + other / 4) + } + + private contentToUsageText(content: unknown): string { + if (typeof content === 'string') return content + if (!content) return '' + if (Array.isArray(content)) { + return content.map((block: any) => { + if (typeof block?.text === 'string') return block.text + if (typeof block?.type === 'string') return `[${block.type}]` + return String(block || '') + }).join('\n') + } + return String(content) + } + + private estimateUsageTokensFromMessages(messages: ChatMessage[]): { inputTokens: number; outputTokens: number } { + const inputTokens = messages + .filter(m => (m.role || 'user') === 'user') + .reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)), 0) + const outputTokens = messages + .filter(m => m.role === 'assistant' || m.role === 'tool') + .reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0) + return { inputTokens, outputTokens } + } + + private estimateRoomTotalTokens(roomId: string, messages: ChatMessage[]): number { + const snapshot = this.getContextSnapshot(roomId) + if (snapshot && messages.length) { + const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId) + const newMessages = snapshotIdx >= 0 + ? messages.slice(snapshotIdx + 1) + : messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp) + const newUsage = this.estimateUsageTokensFromMessages(newMessages) + return countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens + newUsage.outputTokens + } + const usage = this.estimateUsageTokensFromMessages(messages) + return usage.inputTokens + usage.outputTokens + } + + // ─── Messages ───────────────────────────────────────────── + + getMessages(roomId: string, limit = 300, offset = 0): ChatMessage[] { + const rows = (this.db()?.prepare( + 'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?' + ).all(roomId, limit, offset) || []) as any[] + return sortGroupMessages(rows.map(row => ({ + ...row, + tool_calls: parseJsonArray(row.tool_calls), + }))) + } + + getMessageCount(roomId: string): number { + const row = this.db()?.prepare( + 'SELECT COUNT(*) as total FROM gc_messages WHERE roomId = ?' + ).get(roomId) as { total: number } | undefined + return row?.total || 0 + } + + getMessage(messageId: string): ChatMessage | null { + const row = this.db()?.prepare( + 'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?' + ).get(messageId) as any + if (!row) return null + return { + ...row, + tool_calls: parseJsonArray(row.tool_calls), + } + } + + addMessage(msg: ChatMessage): void { + this.upsertMessage(msg) + } + + upsertMessage(msg: ChatMessage): void { + const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null + this.db()?.prepare( + `INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + ` ON CONFLICT(id) DO UPDATE SET + roomId = excluded.roomId, + senderId = excluded.senderId, + senderName = excluded.senderName, + content = excluded.content, + timestamp = excluded.timestamp, + role = excluded.role, + tool_call_id = excluded.tool_call_id, + tool_calls = excluded.tool_calls, + tool_name = excluded.tool_name, + finish_reason = excluded.finish_reason, + reasoning = excluded.reasoning, + reasoning_details = excluded.reasoning_details, + reasoning_content = excluded.reasoning_content` + ).run( + msg.id, msg.roomId, msg.senderId, msg.senderName, messageContentForStorage(msg.role, msg.content), msg.timestamp, + msg.role || 'user', + msg.tool_call_id ?? null, + toolCallsJson, + msg.tool_name ?? null, + msg.finish_reason ?? null, + msg.reasoning ?? null, + msg.reasoning_details ?? null, + msg.reasoning_content ?? null, + ) + } + + saveMessageAndRefreshRoom(msg: ChatMessage, options: { preserveExistingTimestamp?: boolean } = {}): { message: ChatMessage; totalTokens: number } { + const db = this.db() + if (!db) return { message: msg, totalTokens: 0 } + db.exec('BEGIN IMMEDIATE') + try { + const existing = this.getMessage(msg.id) + const message = existing && options.preserveExistingTimestamp ? { ...msg, timestamp: existing.timestamp } : msg + this.upsertMessage(message) + this.pruneMessages(msg.roomId) + const messages = this.getMessages(msg.roomId) + const totalTokens = this.estimateRoomTotalTokens(msg.roomId, messages) + this.updateRoomTotalTokens(msg.roomId, totalTokens) + db.exec('COMMIT') + return { message, totalTokens } + } catch (err) { + try { db.exec('ROLLBACK') } catch { /* ignore */ } + throw err + } + } + + clearRoomContext(roomId: string): void { + const db = this.db() + if (!db) return + db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId) + db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId) + } + + pruneMessages(roomId: string, keep = 500): void { + const db = this.db() + if (!db) return + const count = (db.prepare('SELECT COUNT(*) as c FROM gc_messages WHERE roomId = ?').get(roomId) as any)?.c + if (count > keep) { + const cutoff = db.prepare( + 'SELECT timestamp FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT 1 OFFSET ?' + ).get(roomId, keep - 1) as any + if (cutoff) { + const result = db.prepare('DELETE FROM gc_messages WHERE roomId = ? AND timestamp < ?').run(roomId, cutoff.timestamp) + logger.info(`[GroupChat] pruned ${result.changes} messages from room ${roomId} (had ${count}, keeping ${keep})`) + } + } + } + + // ─── Room Agents ────────────────────────────────────────── + + getRoomAgents(roomId: string): RoomAgent[] { + return (this.db()?.prepare( + 'SELECT id, roomId, agentId, profile, name, description, invited FROM gc_room_agents WHERE roomId = ?' + ).all(roomId) || []) as unknown as RoomAgent[] + } + + addRoomAgent(roomId: string, agentId: string, profile: string, name: string, description: string, invited: number): RoomAgent { + const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + this.db()?.prepare( + 'INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(id, roomId, agentId, profile, name, description, invited) + return { id, roomId, agentId, profile, name, description, invited } + } + + getRoomAgent(roomId: string, agentRef: string): RoomAgent | null { + return (this.db()?.prepare( + 'SELECT id, roomId, agentId, profile, name, description, invited FROM gc_room_agents WHERE roomId = ? AND (id = ? OR agentId = ?)' + ).get(roomId, agentRef, agentRef) as any) ?? null + } + + getRoomAgentByAgentId(roomId: string, agentId: string): RoomAgent | null { + return (this.db()?.prepare( + 'SELECT id, roomId, agentId, profile, name, description, invited FROM gc_room_agents WHERE roomId = ? AND agentId = ?' + ).get(roomId, agentId) as any) ?? null + } + + removeRoomAgent(roomId: string, agentRef: string): void { + this.db()?.prepare('DELETE FROM gc_room_agents WHERE roomId = ? AND (id = ? OR agentId = ?)').run(roomId, agentRef, agentRef) + } + + // ─── Context Snapshots ────────────────────────────────── + + getContextSnapshot(roomId: string): { roomId: string; summary: string; lastMessageId: string; lastMessageTimestamp: number; updatedAt: number } | null { + return (this.db()?.prepare( + 'SELECT roomId, summary, lastMessageId, lastMessageTimestamp, updatedAt FROM gc_context_snapshots WHERE roomId = ?' + ).get(roomId) as any) ?? null + } + + saveContextSnapshot(roomId: string, summary: string, lastMessageId: string, lastMessageTimestamp: number): void { + this.db()?.prepare( + 'INSERT INTO gc_context_snapshots (roomId, summary, lastMessageId, lastMessageTimestamp, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(roomId) DO UPDATE SET summary = excluded.summary, lastMessageId = excluded.lastMessageId, lastMessageTimestamp = excluded.lastMessageTimestamp, updatedAt = excluded.updatedAt' + ).run(roomId, summary, lastMessageId, lastMessageTimestamp, Date.now()) + } + + deleteContextSnapshot(roomId: string): void { + this.db()?.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId) + } + + deleteRoom(roomId: string): void { + const db = this.db() + if (!db) return + db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_room_agents WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_room_members WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_rooms WHERE id = ?').run(roomId) + } + + // ─── Room Members ────────────────────────────────────── + + getRoomMembers(roomId: string): { id: string; userId: string; name: string; description: string; joinedAt: number }[] { + return (this.db()?.prepare( + `SELECT m.id, m.userId, m.userName as name, m.description, m.joinedAt + FROM gc_room_members m + WHERE m.roomId = ? + AND NOT EXISTS ( + SELECT 1 FROM gc_room_agents a + WHERE a.roomId = m.roomId + AND (a.agentId = m.userId OR (m.userId NOT GLOB '????????-????-????-????-????????????' AND COALESCE(m.description, '') = '' AND a.name = m.userName)) + ) + ORDER BY m.joinedAt` + ).all(roomId) || []) as unknown as { id: string; userId: string; name: string; description: string; joinedAt: number }[] + } + + removeRoomMembersForAgent(roomId: string, agent: Pick): void { + this.db()?.prepare( + `DELETE FROM gc_room_members + WHERE roomId = ? + AND (userId = ? OR (userId NOT GLOB '????????-????-????-????-????????????' AND COALESCE(description, '') = '' AND userName = ?))` + ).run(roomId, agent.agentId, agent.name) + } + + addRoomMember(roomId: string, userId: string, userName: string, description: string): void { + const existing = this.getMemberByUserId(roomId, userId) + if (existing) { + // Update name/description on rejoin, refresh updatedAt + this.db()?.prepare( + 'UPDATE gc_room_members SET userName = ?, description = ?, updatedAt = ? WHERE roomId = ? AND userId = ?' + ).run(userName, description, Date.now(), roomId, userId) + return + } + const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + const now = Date.now() + this.db()?.prepare( + 'INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(id, roomId, userId, userName, description, now, now) + } + + getMemberByUserId(roomId: string, userId: string): Member | null { + return (this.db()?.prepare( + 'SELECT id, userId, userName as name, description, joinedAt FROM gc_room_members WHERE roomId = ? AND userId = ?' + ).get(roomId, userId) as any) ?? null + } + + updateMemberActivity(roomId: string, userId: string): void { + this.db()?.prepare( + 'UPDATE gc_room_members SET updatedAt = ? WHERE roomId = ? AND userId = ?' + ).run(Date.now(), roomId, userId) + } +} + +export async function drainPendingSessionDeletes(profileName: string): Promise { + const deleterResult = await SessionDeleter.getInstance().drain(profileName) + return { + deleted: deleterResult.deleted, + failed: deleterResult.failed.map(id => ({ sessionId: id, error: 'unknown' })), + } +} + +// ─── ChatRoom (in-memory, for online members) ───────────────── + +class ChatRoom { + readonly id: string + name: string + readonly members = new Map() + + constructor(id: string, name?: string) { + this.id = id + this.name = name || id + } + + addOrUpdateMember(socketId: string, userId: string, name: string, description: string, source: 'human' | 'agent' = 'human'): Member { + const existing = this.members.get(userId) + if (existing) { + existing.name = name + existing.description = description + existing.online = true + existing.socketId = socketId + existing.source = source + return existing + } + const member: Member = { id: socketId, userId, name, description, joinedAt: Date.now(), online: true, socketId, source } + this.members.set(userId, member) + return member + } + + removeMember(socketId: string): void { + for (const member of this.members.values()) { + if (member.socketId === socketId) { + member.online = false + break + } + } + } + + getMembersList(): Member[] { + return Array.from(this.members.values()).filter(member => member.source !== 'agent') + } + + getOnlineMemberBySocketId(socketId: string): Member | undefined { + for (const member of this.members.values()) { + if (member.socketId === socketId && member.online) return member + } + return undefined + } + + hasOnlineMember(socketId: string): boolean { + return this.getOnlineMemberBySocketId(socketId) !== undefined + } +} + +// ─── GroupChat Server ──────────────────────────────────────── + +export class GroupChatServer { + private io: Server + private nsp: Namespace + private storage: ChatStorage + private rooms = new Map() + /** Map: socket.id → persistent userId */ + private socketUserMap = new Map() + /** Map: userId → { name, description } (from auth) */ + private userInfoMap = new Map() + /** Map: socket.id → requested participant source from handshake */ + private socketRequestedSourceMap = new Map() + readonly agentClients = new AgentClients() + private _contextEngine: ContextEngine | null = null + private _restoreScheduled = false + /** roomId -> (userId -> { userName, timer }) */ + private typingState = new Map }>>() + /** roomId -> (agentName -> { agentName, status }) */ + private contextStatusState = new Map>() + + constructor(httpServers: HttpServer | HttpServer[]) { + this.storage = new ChatStorage() + this.storage.init() + const servers = Array.isArray(httpServers) ? httpServers : [httpServers] + + this.io = new Server(servers[0], { + cors: { origin: '*' }, + pingInterval: 25_000, + pingTimeout: 90_000, + connectionStateRecovery: { + maxDisconnectionDuration: 2 * 60_000, + skipMiddlewares: true, + }, + }) + servers.slice(1).forEach((httpServer) => this.io.attach(httpServer)) + this.nsp = this.io.of('/group-chat') + this.nsp.use(this.authMiddleware.bind(this)) + this.nsp.on('connection', this.onConnection.bind(this)) + + // Restore persisted rooms into memory + this.storage.getAllRooms().forEach((row) => { + this.rooms.set(row.id, new ChatRoom(row.id, row.name)) + }) + + logger.info('[GroupChat] Socket.IO ready at /group-chat') + + // Initialize context engine for group chat compression + const contextEngine = new ContextEngine({ + messageFetcher: this.storage, + sessionCleaner: async (sessionId: string) => { + // TODO: re-enable session deletion after confirming it doesn't + // accidentally remove user-created sessions outside group chat. + // try { + // const profile = this.storage.getSessionProfile(sessionId) + // const profileName = profile?.profile_name || 'default' + // this.storage.enqueuePendingSessionDelete(sessionId, profileName) + // } catch (err: any) { + // logger.warn(`[GroupChat] failed to enqueue compression session delete ${sessionId}: ${err.message}`) + // } + }, + }) + this.agentClients.setContextEngine(contextEngine) + this.agentClients.setStorage(this.storage) + this._contextEngine = contextEngine + + // Restore agent connections — call restoreAgents() after server is listening + this._restoreScheduled = false + } + + getIO(): Server { + return this.io + } + + getStorage(): ChatStorage { + return this.storage + } + + getContextEngine(): ContextEngine | null { + return this._contextEngine || null + } + + getRoomIds(): string[] { + return Array.from(this.rooms.keys()) + } + + clearRoomRuntimeState(roomId: string): void { + const roomTyping = this.typingState.get(roomId) + if (roomTyping) { + for (const entry of roomTyping.values()) clearTimeout(entry.timer) + this.typingState.delete(roomId) + } + this.contextStatusState.delete(roomId) + this.agentClients.resetRoomContext(roomId) + this.nsp.to(roomId).emit('room_cleared', { roomId, totalTokens: 0 }) + this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens: 0 }) + } + + // ─── Restore Agents ───────────────────────────────────────── + + /** + * Restore persisted agent connections. Safe to call multiple times; + * will only execute once. + */ + async restoreWhenReady(): Promise { + if (this._restoreScheduled) return + this._restoreScheduled = true + await this.restoreAgents() + } + + private async restoreAgents(): Promise { + const rooms = this.storage.getAllRooms() + let total = 0 + + for (const room of rooms) { + const agents = this.storage.getRoomAgents(room.id) + for (const agent of agents) { + try { + const client = await this.agentClients.createAgent({ + agentId: agent.agentId, + profile: agent.profile, + name: agent.name, + description: agent.description, + invited: agent.invited, + }) + await this.agentClients.addAgentToRoom(room.id, client) + total++ + } catch (err: any) { + logger.error(`[GroupChat] Failed to restore agent ${agent.name} in room ${room.id}: ${err.message}`) + } + } + } + + if (total > 0) { + logger.info(`[GroupChat] Restored ${total} agent(s) across ${rooms.length} room(s)`) + } + } + + // ─── Auth ─────────────────────────────────────────────────── + + private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise { + const auth = socket.handshake.auth as { source?: string; agentSocketSecret?: string; token?: string } + const isAgentSocket = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET + if (isAgentSocket) { + next() + return + } + + const token = auth.token || socket.handshake.query.token || '' + if (await isAuthEnabled() && !await authenticateUserToken(String(token))) { + return next(new Error('Unauthorized')) + } + next() + } + + // ─── Connection ───────────────────────────────────────────── + + private onConnection(socket: Socket): void { + const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string; source?: string; agentSocketSecret?: string } + const userId = auth.userId || socket.id + const userName = auth.name || `User-${userId.slice(0, 6)}` + const description = auth.description || '' + const requestedSource = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET ? 'agent' : 'human' + + this.socketUserMap.set(socket.id, userId) + this.socketRequestedSourceMap.set(socket.id, requestedSource) + this.userInfoMap.set(userId, { name: userName, description }) + + logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`) + + socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack)) + socket.on('message', (data: Partial & { roomId?: string; content: string | Array>; id?: string; mentionDepth?: number }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack)) + socket.on('message_stream_start', (data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }) => this.handleMessageStreamStart(socket, data)) + socket.on('message_stream_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageStreamDelta(socket, data)) + socket.on('message_reasoning_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageReasoningDelta(socket, data)) + socket.on('message_stream_end', (data: { roomId?: string; id?: string }) => this.handleMessageStreamEnd(socket, data)) + socket.on('typing', (data: { roomId?: string }) => this.handleTyping(socket, data)) + socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data)) + socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data)) + socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack)) + socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data)) + socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data)) + socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack)) + socket.on('disconnect', () => this.handleDisconnect(socket)) + } + + // ─── Handlers ─────────────────────────────────────────────── + + private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void { + const socketId = socket.id + const userId = this.socketUserMap.get(socketId) || socketId + const requestedSource = this.socketRequestedSourceMap.get(socketId) || 'human' + const roomId = data.roomId || 'general' + const roomAgent = this.storage.getRoomAgentByAgentId(roomId, userId) + const source = requestedSource === 'agent' && roomAgent ? 'agent' : 'human' + if (source === 'human' && roomAgent) { + ack?.({ error: 'Reserved member identity' }) + return + } + const existingMember = this.storage.getMemberByUserId(roomId, userId) + const userInfo = this.userInfoMap.get(userId) || { + name: existingMember?.name || `User-${userId.slice(0, 6)}`, + description: existingMember?.description || '', + } + const userName = data.name || existingMember?.name || userInfo.name + const description = data.description || existingMember?.description || userInfo.description + + // Update stored user info + this.userInfoMap.set(userId, { name: userName, description }) + + let room = this.rooms.get(roomId) + if (!room) { + room = new ChatRoom(roomId) + this.rooms.set(roomId, room) + this.storage.saveRoom(roomId, roomId) + } + + // Persist only human members. Agent sockets are runtime participants + // tracked through gc_room_agents and AgentClients; storing them in + // gc_room_members makes member counts grow on reconnect/restore. + if (source !== 'agent') { + this.storage.addRoomMember(roomId, userId, userName, description) + } + + // Add to in-memory online participants (keyed by userId) + room.addOrUpdateMember(socketId, userId, userName, description, source) + socket.join(roomId) + + if (source !== 'agent') { + socket.to(roomId).emit('member_joined', { + roomId, + memberId: userId, + memberName: userName, + members: room.getMembersList(), + }) + } + + // Load history from SQLite + const messages = this.storage.getMessages(roomId) + const agents = this.storage.getRoomAgents(roomId) + + ack?.({ + roomId, + roomName: room.name, + members: room.getMembersList(), + messages, + agents, + rooms: this.getRoomIds(), + typingUsers: this.getTypingUsers(roomId), + contextStatuses: this.getContextStatuses(roomId), + }) + + logger.debug(`[GroupChat] ${userName} (user=${userId}) joined room: ${roomId}`) + } + + private handleMessage(socket: Socket, data: Partial & { roomId?: string; content: string | Array>; id?: string; mentionDepth?: number }, ack?: (res: any) => void): void { + const socketId = socket.id + const roomId = data.roomId || 'general' + const room = this.rooms.get(roomId) + + if (!room || !room.hasOnlineMember(socketId)) { + ack?.({ error: 'Not in room' }) + return + } + + const member = room.getOnlineMemberBySocketId(socketId) + const userId = member?.userId || socketId + const userName = member?.name || `User-${socketId.slice(0, 6)}` + + const msg: ChatMessage = { + id: this.normalizeClientMessageId(data.id) || this.generateId(), + roomId, + senderId: userId, + senderName: userName, + content: contentToStorageString(data.content), + timestamp: this.normalizeMessageTimestamp(data.timestamp, data.role), + role: normalizeMessageRole(data.role), + tool_call_id: data.tool_call_id ?? null, + tool_calls: Array.isArray(data.tool_calls) ? data.tool_calls : null, + tool_name: data.tool_name ?? null, + finish_reason: data.finish_reason ?? null, + reasoning: data.reasoning ?? null, + reasoning_details: data.reasoning_details ?? null, + reasoning_content: data.reasoning_content ?? null, + } + + const saved = this.storage.saveMessageAndRefreshRoom(msg) + const savedMsg = saved.message + const totalTokens = saved.totalTokens + + this.nsp.to(roomId).emit('message', savedMsg) + this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens }) + ack?.({ id: savedMsg.id }) + + const mentionDepth = normalizeMentionDepth(data.mentionDepth) + const isAgentReply = savedMsg.role === 'assistant' && member?.source === 'agent' + const shouldRouteMentions = savedMsg.role === 'user' || + (isAgentReply && mentionDepth < maxAgentMentionDepth()) + + if (shouldRouteMentions) { + // Server-side @mention routing — parse mentions and invoke agents directly. + // Agent replies are allowed to mention other agents, but mentionDepth + // bounds chained agent-to-agent handoffs so one prompt cannot loop forever. + this.agentClients.processMentions(roomId, { + content: contentToText(savedMsg.content), + input: Array.isArray(data.content) ? data.content : undefined, + senderName: savedMsg.senderName, + senderId: savedMsg.senderId, + timestamp: savedMsg.timestamp, + mentionDepth, + }).catch((err) => { + logger.error(`[GroupChat] processMentions error: ${err.message}`) + }) + } + } + + private handleMessageStreamStart(socket: Socket, data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }): void { + const roomId = data.roomId || 'general' + const room = this.rooms.get(roomId) + if (!room || !room.hasOnlineMember(socket.id)) return + const id = this.normalizeClientMessageId(data.id) + if (!id) return + + const member = room.getOnlineMemberBySocketId(socket.id) + this.nsp.to(roomId).emit('message_stream_start', { + id, + roomId, + senderId: data.senderId || member?.userId || socket.id, + senderName: data.senderName || member?.name || `User-${socket.id.slice(0, 6)}`, + content: '', + timestamp: data.timestamp || Date.now(), + role: 'assistant', + finish_reason: 'streaming', + }) + } + + private handleMessageStreamDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void { + const roomId = data.roomId || 'general' + const room = this.rooms.get(roomId) + if (!room || !room.hasOnlineMember(socket.id)) return + const id = this.normalizeClientMessageId(data.id) + if (!id || !data.delta) return + this.nsp.to(roomId).emit('message_stream_delta', { + roomId, + id, + delta: String(data.delta), + }) + } + + private handleMessageReasoningDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void { + const roomId = data.roomId || 'general' + const room = this.rooms.get(roomId) + if (!room || !room.hasOnlineMember(socket.id)) return + const id = this.normalizeClientMessageId(data.id) + if (!id || !data.delta) return + this.nsp.to(roomId).emit('message_reasoning_delta', { + roomId, + id, + delta: String(data.delta), + }) + } + + private handleMessageStreamEnd(socket: Socket, data: { roomId?: string; id?: string }): void { + const roomId = data.roomId || 'general' + const room = this.rooms.get(roomId) + if (!room || !room.hasOnlineMember(socket.id)) return + const id = this.normalizeClientMessageId(data.id) + if (!id) return + this.nsp.to(roomId).emit('message_stream_end', { roomId, id }) + } + + private handleTyping(socket: Socket, data: { roomId?: string }): void { + const roomId = data.roomId || 'general' + const userId = this.socketUserMap.get(socket.id) || socket.id + const userName = this.userInfoMap.get(userId)?.name || `User-${socket.id.slice(0, 6)}` + + // Track typing state for rejoin recovery + let roomTyping = this.typingState.get(roomId) + if (!roomTyping) { + roomTyping = new Map() + this.typingState.set(roomId, roomTyping) + } + const existing = roomTyping.get(userId) + if (existing) clearTimeout(existing.timer) + roomTyping.set(userId, { + userName, + timer: setTimeout(() => { + roomTyping!.delete(userId) + if (roomTyping!.size === 0) this.typingState.delete(roomId) + }, 30000), + }) + + socket.to(roomId).emit('typing', { + roomId, + userId, + userName, + }) + } + + private handleStopTyping(socket: Socket, data: { roomId?: string }): void { + const roomId = data.roomId || 'general' + const userId = this.socketUserMap.get(socket.id) || socket.id + + // Remove from typing state + const roomTyping = this.typingState.get(roomId) + if (roomTyping) { + const entry = roomTyping.get(userId) + if (entry) clearTimeout(entry.timer) + roomTyping.delete(userId) + if (roomTyping.size === 0) this.typingState.delete(roomId) + } + + socket.to(roomId).emit('stop_typing', { + roomId, + userId, + }) + } + + private handleContextStatus(socket: Socket, data: { roomId?: string; agentName?: string; status?: string; totalTokens?: number }): void { + const roomId = data.roomId || 'general' + const agentName = data.agentName || '' + const status = data.status || '' + + if (!agentName) return + + let roomStatuses = this.contextStatusState.get(roomId) + if (!roomStatuses) { + roomStatuses = new Map() + this.contextStatusState.set(roomId, roomStatuses) + } + + if (status === 'ready') { + roomStatuses.delete(agentName) + if (roomStatuses.size === 0) this.contextStatusState.delete(roomId) + } else { + roomStatuses.set(agentName, { agentName, status }) + } + + // Relay to all other sockets in the room + socket.to(roomId).emit('context_status', { + roomId, + agentName, + status, + }) + + if (typeof data.totalTokens === 'number' && Number.isFinite(data.totalTokens) && data.totalTokens >= 0) { + this.storage.updateRoomTotalTokens(roomId, Math.floor(data.totalTokens)) + this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens: Math.floor(data.totalTokens) }) + } + } + + private async handleInterruptAgent(socket: Socket, data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void): Promise { + const roomId = data.roomId + const agentName = data.agentName + if (!roomId || !agentName) { + ack?.({ error: 'roomId and agentName are required' }) + return + } + const room = this.rooms.get(roomId) + if (!room?.hasOnlineMember(socket.id)) { + ack?.({ error: 'Not in room' }) + return + } + try { + await this.agentClients.interruptAgent(roomId, agentName) + this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' }) + ack?.({ ok: true }) + } catch (err: any) { + logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`) + ack?.({ error: err.message || 'interrupt failed' }) + } + } + + private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void { + const roomId = data.roomId + if (!roomId || !data.approval_id) return + this.nsp.to(roomId).emit('approval.requested', { + event: 'approval.requested', + roomId, + agentName: data.agentName || '', + approval_id: data.approval_id, + command: data.command || '', + description: data.description || '', + choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'], + allow_permanent: Boolean(data.allow_permanent), + }) + } + + private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }): void { + const roomId = data.roomId + if (!roomId || !data.approval_id) return + this.nsp.to(roomId).emit('approval.resolved', { + event: 'approval.resolved', + roomId, + agentName: data.agentName || '', + approval_id: data.approval_id, + choice: data.choice || '', + }) + } + + private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise { + const roomId = data.roomId + if (!roomId || !data.approval_id) { + ack?.({ error: 'roomId and approval_id are required' }) + return + } + const room = this.rooms.get(roomId) + if (!room?.hasOnlineMember(socket.id)) { + ack?.({ error: 'Not in room' }) + return + } + try { + const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny') + ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) }) + } catch (err: any) { + logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`) + ack?.({ error: err.message || 'approval response failed' }) + } + } + + private handleDisconnect(socket: Socket): void { + const socketId = socket.id + const userId = this.socketUserMap.get(socketId) + const userName = userId ? this.userInfoMap.get(userId)?.name : undefined + + logger.debug(`[GroupChat] Disconnected: ${userName || socketId} (socket=${socketId}, user=${userId || socketId})`) + + // Clean up typing state for this socket + for (const [roomId, roomTyping] of this.typingState) { + const entry = roomTyping.get(userId || socketId) + if (entry) { + clearTimeout(entry.timer) + roomTyping.delete(userId || socketId) + if (roomTyping.size === 0) this.typingState.delete(roomId) + } + } + + this.leaveAllRooms(socket, socketId) + this.socketUserMap.delete(socketId) + this.socketRequestedSourceMap.delete(socketId) + // Don't delete userInfoMap — it persists across reconnects + } + + // ─── Helpers ──────────────────────────────────────────────── + + private getTypingUsers(roomId: string): Array<{ userId: string; userName: string }> { + const roomTyping = this.typingState.get(roomId) + if (!roomTyping) return [] + return Array.from(roomTyping.entries()).map(([userId, entry]) => ({ userId, userName: entry.userName })) + } + + private getContextStatuses(roomId: string): Array<{ agentName: string; status: string }> { + const roomStatuses = this.contextStatusState.get(roomId) + if (!roomStatuses) return [] + return Array.from(roomStatuses.values()) + } + + private leaveAllRooms(socket: Socket, socketId: string): void { + this.rooms.forEach((room, rid) => { + if (room.hasOnlineMember(socketId)) { + const member = room.getOnlineMemberBySocketId(socketId) + room.removeMember(socketId) + socket.leave(rid) + if (member?.source !== 'agent') { + this.nsp.to(rid).emit('member_left', { + roomId: rid, + memberId: member?.userId || socketId, + memberName: member?.name || `User-${socketId.slice(0, 6)}`, + members: room.getMembersList(), + }) + } + } + }) + } + + private generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + } + + private normalizeClientMessageId(id?: string): string | null { + const cleaned = String(id || '').trim() + if (!cleaned || cleaned.length > 160) return null + return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : null + } + + private normalizeMessageTimestamp(timestamp?: unknown, role?: unknown): number { + const normalizedRole = normalizeMessageRole(role) + if (normalizedRole !== 'user') { + const value = Number(timestamp) + if (Number.isFinite(value) && value > 0) return value + } + return Date.now() + } +} diff --git a/packages/server/src/services/hermes/group-chat/mention-routing.ts b/packages/server/src/services/hermes/group-chat/mention-routing.ts new file mode 100644 index 0000000..f5d2e95 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/mention-routing.ts @@ -0,0 +1,103 @@ +export const ALL_AGENTS_MENTION = 'all' + +type MentionableAgent = { + name: string + id?: string + agentId?: string +} + +type MentionRange = { + start: number + end: number +} + +const BEFORE_BOUNDARY = new Set(['(', '[', '{', '<']) +const AFTER_BOUNDARY = new Set(['.', ',', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', ')', ']', '}', '>']) + +export function escapeMentionName(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function isReservedMentionName(name: string): boolean { + return name.trim().toLowerCase() === ALL_AGENTS_MENTION +} + +function isBeforeBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) || BEFORE_BOUNDARY.has(char) +} + +function isAfterBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) || AFTER_BOUNDARY.has(char) +} + +function findMentionRanges(content: string, mentionName: string): MentionRange[] { + if (!content || !mentionName) return [] + + const contentLower = content.toLowerCase() + const mentionLower = mentionName.toLowerCase() + const ranges: MentionRange[] = [] + let fromIndex = 0 + + while (fromIndex < content.length) { + const atIndex = contentLower.indexOf(`@${mentionLower}`, fromIndex) + if (atIndex === -1) break + + const start = atIndex + const end = atIndex + mentionName.length + 1 + if (isBeforeBoundary(content[start - 1]) && isAfterBoundary(content[end])) { + ranges.push({ start, end }) + } + fromIndex = atIndex + 1 + } + + return ranges +} + +export function isAgentMentioned(content: string, agentName: string): boolean { + return findMentionRanges(content, agentName).length > 0 +} + +export function isAllAgentsMentioned(content: string): boolean { + return isAgentMentioned(content, ALL_AGENTS_MENTION) +} + +function isSenderAgent(agent: MentionableAgent, senderId: string): boolean { + return Boolean(senderId && (agent.id === senderId || agent.agentId === senderId)) +} + +export function resolveMentionTargets( + agents: T[], + content: string, + senderId: string, +): T[] { + const candidates = agents.filter((agent) => !isSenderAgent(agent, senderId)) + + if (isAllAgentsMentioned(content)) { + return candidates + } + + return candidates.filter((agent) => isAgentMentioned(content, agent.name)) +} + +export function stripMentionRoutingTokens(content: string, ownAgentName: string): string { + const rangesByKey = new Map() + for (const range of [ + ...findMentionRanges(content, ALL_AGENTS_MENTION), + ...findMentionRanges(content, ownAgentName), + ]) { + rangesByKey.set(`${range.start}:${range.end}`, range) + } + + const ranges = [...rangesByKey.values()].sort((a, b) => b.start - a.start) + + let result = content + for (const range of ranges) { + result = `${result.slice(0, range.start)}${result.slice(range.end)}` + } + + return result + .replace(/^[\s,,::;;.!?。!?]+/, '') + .replace(/[\s,,::;;]+$/g, '') + .replace(/[ \t]{2,}/g, ' ') + .trim() +} diff --git a/packages/server/src/services/hermes/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts new file mode 100644 index 0000000..5d6456d --- /dev/null +++ b/packages/server/src/services/hermes/hermes-cli.ts @@ -0,0 +1,810 @@ +import { execFile, spawn } from 'child_process' +import { existsSync, readFileSync, unlinkSync } from 'fs' +import { join } from 'path' +import { promisify } from 'util' +import YAML from 'js-yaml' +import { logger } from '../logger' +import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers' +import { getActiveProfileDir, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from './hermes-profile' +import { startGatewayRunManaged } from './gateway-runner' +import { isGatewayRunningForProfile } from './gateway-autostart' +import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser' +import { execHermesWithBin, spawnHermesWithBin } from './hermes-process' + +const execFileAsync = promisify(execFile) + +const execOpts = { windowsHide: true } +const isDocker = existsSync('/.dockerenv') +const isTermux = !!process.env.TERMUX_VERSION || + (process.env.PREFIX || '').includes('/com.termux/') || + existsSync('/data/data/com.termux/files/usr') + +/** + * 解析 Hermes CLI 二进制路径 + * 优先使用环境变量 HERMES_BIN,否则使用 PATH 中的 'hermes' 命令 + */ +function resolveHermesBin(): string { + return process.env.HERMES_BIN?.trim() || 'hermes' +} + +const HERMES_BIN = resolveHermesBin() + +async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await isGatewayRunningForProfile(HERMES_BIN, profileDir)) return true + await new Promise(resolve => setTimeout(resolve, 500)) + } + return false +} + +async function stopGatewayForActiveProfile(): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], { + timeout: 30000, + ...activeGatewayExecOpts(), + }) + } catch (err) { + logger.warn(err, 'hermes gateway stop before restart failed; continuing with run --replace') + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err: any) { + return err?.code === 'EPERM' + } +} + +function readJsonPid(path: string): number | null { + if (!existsSync(path)) return null + try { + const data = JSON.parse(readFileSync(path, 'utf-8')) + const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10) + return Number.isFinite(pid) && pid > 0 ? pid : null + } catch { + return null + } +} + +function readGatewayLockPid(profileDir: string): number | null { + return readJsonPid(join(profileDir, 'gateway.lock')) +} + +function readGatewayStatePid(profileDir: string): number | null { + const pid = readJsonPid(join(profileDir, 'gateway.pid')) + if (pid) return pid + const statePath = join(profileDir, 'gateway_state.json') + if (!existsSync(statePath)) return null + try { + const data = JSON.parse(readFileSync(statePath, 'utf-8')) + const state = data?.gateway_state + const statePid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10) + return statePid && Number.isFinite(statePid) && statePid > 0 && (state === 'running' || state === 'starting') + ? statePid + : null + } catch { + return null + } +} + +async function killWindowsPid(pid: number): Promise { + if (!pid || process.platform !== 'win32') return + try { + await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + timeout: 5000, + windowsHide: true, + }) + } catch (err) { + logger.warn(err, 'Failed to taskkill gateway PID %d; falling back to process.kill', pid) + try { process.kill(pid) } catch {} + } +} + +function cleanupStaleGatewayLock(profileDir: string, allowMalformedDelete = false): boolean { + const lockPath = join(profileDir, 'gateway.lock') + if (!existsSync(lockPath)) return true + try { + const lockData = JSON.parse(readFileSync(lockPath, 'utf-8')) + const pid = Number(lockData?.pid) + if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return false + unlinkSync(lockPath) + return true + } catch { + if (!allowMalformedDelete) return false + try { + unlinkSync(lockPath) + return true + } catch { + return false + } + } +} + +async function waitForGatewayLockReleased(profileDir: string, timeoutMs = 15000, allowMalformedDelete = false): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (cleanupStaleGatewayLock(profileDir, allowMalformedDelete)) return true + await sleep(500) + } + return cleanupStaleGatewayLock(profileDir, allowMalformedDelete) +} + +async function forceReleaseWindowsGatewayLock(profileDir: string): Promise { + if (process.platform !== 'win32') return + const pids = new Set() + const lockPid = readGatewayLockPid(profileDir) + const statePid = readGatewayStatePid(profileDir) + if (lockPid) pids.add(lockPid) + if (statePid) pids.add(statePid) + + for (const pid of pids) { + if (isProcessAlive(pid)) { + logger.warn('Gateway lock is still held by PID %d; force killing Windows process tree', pid) + await killWindowsPid(pid) + } + } +} + +async function waitForGatewayLockReleasedAfterStop(profileDir: string, timeoutMs = 15000): Promise { + if (await waitForGatewayLockReleased(profileDir, timeoutMs)) return true + await forceReleaseWindowsGatewayLock(profileDir) + return waitForGatewayLockReleased(profileDir, 5000, true) +} + +function activeGatewayExecOpts() { + return { + ...execOpts, + env: { + ...process.env, + HERMES_HOME: getActiveProfileDir(), + }, + } +} + +async function clearLegacyApiServerGatewayConfig(): Promise { + try { + await updateConfigYaml((config) => { + const result = stripLegacyApiServerGatewayConfig(config) + return { data: result.config, result: undefined, write: result.changed } + }) + } catch (err) { + logger.warn(err, 'Failed to clear legacy api_server gateway config before restart') + } +} + +export interface HermesSession { + id: string + source: string + user_id: string | null + model: string + title: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd: number | null + cost_status: string + messages?: any[] +} + +export interface HermesSessionFull { + id: string + source: string + user_id: string | null + model: string + title: string | null + started_at: number + ended_at: number | null + end_reason: string | null + message_count: number + tool_call_count: number + input_tokens: number + output_tokens: number + cache_read_tokens?: number + cache_write_tokens?: number + reasoning_tokens?: number + billing_provider: string | null + estimated_cost_usd: number + actual_cost_usd?: number | null + cost_status?: string + messages?: any[] + system_prompt?: string + model_config?: string + cost_source?: string + pricing_version?: string | null + [key: string]: any +} + +function parseSessionExport(stdout: string): HermesSessionFull[] { + const lines = stdout.trim().split('\n').filter(Boolean) + const sessions: HermesSessionFull[] = [] + for (const line of lines) { + try { + const raw: HermesSessionFull = JSON.parse(line) + sessions.push(raw) + } catch { + // Skip non-JSON lines such as "Session 'x' not found." + } + } + return sessions +} + +export async function exportSessionsRaw(source?: string): Promise { + const args = ['sessions', 'export', '-'] + if (source) args.push('--source', source) + + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, args, { + maxBuffer: 50 * 1024 * 1024, // 50MB + timeout: 30000, + ...execOpts, + }) + return parseSessionExport(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: sessions export failed') + throw new Error(`Failed to list sessions: ${err.message}`) + } +} + +/** + * List sessions from Hermes CLI (without messages) + */ +export async function listSessions(source?: string, limit?: number): Promise { + const raws = await exportSessionsRaw(source) + const sessions: HermesSession[] = [] + + for (const raw of raws) { + let title = raw.title + if (!title && raw.messages) { + const firstUser = raw.messages.find((m: any) => m.role === 'user') + if (firstUser?.content) { + const t = String(firstUser.content).slice(0, 40) + title = t + (String(firstUser.content).length > 40 ? '...' : '') + } + } + sessions.push({ + id: raw.id, + source: raw.source, + user_id: raw.user_id, + model: raw.model, + title, + started_at: raw.started_at, + ended_at: raw.ended_at, + end_reason: raw.end_reason, + message_count: raw.message_count, + tool_call_count: raw.tool_call_count, + input_tokens: raw.input_tokens, + output_tokens: raw.output_tokens, + cache_read_tokens: raw.cache_read_tokens || 0, + cache_write_tokens: raw.cache_write_tokens || 0, + reasoning_tokens: raw.reasoning_tokens || 0, + billing_provider: raw.billing_provider, + estimated_cost_usd: raw.estimated_cost_usd, + actual_cost_usd: raw.actual_cost_usd ?? null, + cost_status: raw.cost_status || '', + }) + } + + // Sort by started_at descending + sessions.sort((a, b) => b.started_at - a.started_at) + + if (limit && limit > 0) { + return sessions.slice(0, limit) + } + return sessions +} + +/** + * Get a single session with messages from Hermes CLI + */ +export async function getSession(id: string): Promise { + const args = ['sessions', 'export', '-', '--session-id', id] + + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + + const raws = parseSessionExport(stdout) + if (raws.length === 0) return null + + const raw: HermesSessionFull = raws[0] + return { + id: raw.id, + source: raw.source, + user_id: raw.user_id, + model: raw.model, + title: raw.title, + started_at: raw.started_at, + ended_at: raw.ended_at, + end_reason: raw.end_reason, + message_count: raw.message_count, + tool_call_count: raw.tool_call_count, + input_tokens: raw.input_tokens, + output_tokens: raw.output_tokens, + cache_read_tokens: raw.cache_read_tokens || 0, + cache_write_tokens: raw.cache_write_tokens || 0, + reasoning_tokens: raw.reasoning_tokens || 0, + billing_provider: raw.billing_provider, + estimated_cost_usd: raw.estimated_cost_usd, + actual_cost_usd: raw.actual_cost_usd ?? null, + cost_status: raw.cost_status || '', + messages: raw.messages, + } + } catch (err: any) { + if (err.code === 1 || err.status === 1) return null + logger.error(err, 'Hermes CLI: session export failed') + throw new Error(`Failed to get session: ${err.message}`) + } +} + +/** + * Delete a session from Hermes CLI + */ +export async function deleteSession(id: string): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['sessions', 'delete', id, '--yes'], { + timeout: 10000, + ...execOpts, + }) + return true + } catch (err: any) { + logger.error(err, 'Hermes CLI: session delete failed') + return false + } +} + +/** + * Delete a session from a specific Hermes profile. + */ +export async function deleteSessionForProfile(id: string, profile: string): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['sessions', 'delete', id, '--yes'], { + timeout: 10000, + ...execOpts, + env: { + ...process.env, + HERMES_HOME: getProfileDir(profile), + }, + }) + return true + } catch (err: any) { + logger.error({ err, sessionId: id, profile }, 'Hermes CLI: profile session delete failed') + return false + } +} + +/** + * Rename a session title via Hermes CLI + */ +export async function renameSession(id: string, title: string): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['sessions', 'rename', id, title], { + timeout: 10000, + ...execOpts, + }) + return true + } catch (err: any) { + logger.error(err, 'Hermes CLI: session rename failed') + return false + } +} + +export interface LogFileInfo { + name: string + size: string + modified: string +} + +/** + * Get Hermes version + */ +export async function getVersion(): Promise { + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts }) + return stdout.trim() + } catch { + return '' + } +} + +/** + * Start Hermes gateway (uses launchd/systemd) + */ +export async function startGateway(): Promise { + if (isDocker) { + const pid = await startGatewayBackground() + return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered' + } + + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'start'], { + timeout: 30000, + ...activeGatewayExecOpts(), + }) + return stdout || stderr +} + +/** + * Start Hermes gateway in background (for WSL where launchd/systemd is unavailable) + * Uses "hermes gateway run" as a detached background process + */ +export async function startGatewayBackground(): Promise { + const child = spawnHermesWithBin(HERMES_BIN, ['gateway', 'run'], { + detached: true, + stdio: 'ignore', + windowsHide: true, + env: { + ...process.env, + HERMES_HOME: getActiveProfileDir(), + }, + }) + child.unref() + return child.pid ?? null +} + +/** + * Restart Hermes gateway through Hermes CLI, falling back to detached + * `gateway run` when the environment does not support `gateway restart`. + */ +export async function restartGateway(): Promise { + await clearLegacyApiServerGatewayConfig() + const profileDir = getActiveProfileDir() + if (isDocker || isTermux || process.platform === 'win32') { + await stopGatewayForActiveProfile() + const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir) + if (!lockReleased) throw new Error('Gateway stopped but runtime lock is still held by another process') + const result = startGatewayRunManaged(HERMES_BIN, { profileDir }) + const ready = await waitForGatewayRunning(profileDir) + if (!ready) throw new Error(`Gateway run replace triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`) + return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced' + } + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'restart'], { + timeout: 30000, + ...activeGatewayExecOpts(), + }) + const ready = await waitForGatewayRunning(profileDir) + if (!ready) throw new Error('Hermes gateway restart completed but gateway did not report running within timeout') + return stdout || stderr + } catch (err: any) { + logger.warn(err, 'hermes gateway restart failed; falling back to gateway run') + await stopGatewayForActiveProfile() + const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir) + if (!lockReleased) throw new Error('Gateway restart failed and runtime lock is still held by another process') + const result = startGatewayRunManaged(HERMES_BIN, { profileDir }) + const ready = await waitForGatewayRunning(profileDir) + if (!ready) throw new Error(`Gateway run fallback triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`) + return result.pid ? `Gateway run started (PID: ${result.pid})` : 'Gateway run started' + } +} + +/** + * Stop Hermes gateway + */ +export async function stopGateway(): Promise { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], { + timeout: 30000, + ...activeGatewayExecOpts(), + }) + return stdout || stderr +} + +/** + * List available log files + */ +export async function listLogFiles(): Promise { + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, ['logs', 'list'], { + timeout: 10000, + ...execOpts, + }) + const files: LogFileInfo[] = [] + // Windows 可能使用 \r\n 换行符,统一处理 + const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const lines = normalized.trim().split('\n').filter(l => l.includes('.log')) + for (const line of lines) { + const match = line.match(/^\s+(\S+)\s+([\d.]+\w+)\s+(.+)$/) + if (match) { + const rawName = match[1] + const name = rawName.replace(/\.log$/, '') + // 支持更多日志类型:agent, errors, gateway, 以及其他可能的日志文件 + if (['agent', 'errors', 'gateway', 'error'].includes(name)) { + files.push({ name, size: match[2], modified: match[3].trim() }) + } + } + } + return files + } catch (err: any) { + logger.error(err, 'Hermes CLI: logs list failed') + return [] + } +} + +/** + * Read log lines + */ +export async function readLogs( + logName: string = 'agent', + lines: number = 100, + level?: string, + session?: string, + since?: string, +): Promise { + const args = ['logs', logName, '-n', String(lines)] + if (level) args.push('--level', level) + if (session) args.push('--session', session) + if (since) args.push('--since', since) + + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, args, { + maxBuffer: 10 * 1024 * 1024, + timeout: 15000, + ...execOpts, + }) + return stdout + } catch (err: any) { + logger.error(err, 'Hermes CLI: logs read failed') + throw new Error(`Failed to read logs: ${err.message}`) + } +} + +// ─── Profile management ────────────────────────────────────── + +export interface HermesProfile { + name: string + active: boolean + model: string + gatewayStatus?: string + alias: string +} + +export interface HermesProfileDetail { + name: string + path: string + model: string + provider: string + skills: number + hasEnv: boolean + hasSoulMd: boolean +} + +function readProfileDefaultModel(name: string): string { + const configPath = join(getProfileDir(name), 'config.yaml') + if (!existsSync(configPath)) return '—' + try { + const config = YAML.load(readFileSync(configPath, 'utf-8'), { json: true }) as Record | null + const model = config?.model + if (typeof model === 'string') return model.trim() || '—' + if (model && typeof model === 'object') { + return String(model.default || '').trim() || '—' + } + } catch (err) { + logger.warn(err, 'Hermes CLI: failed to read profile config model for %s', name) + } + return '—' +} + +/** + * List all profiles + */ +export async function listProfiles(): Promise { + const profileNames = listProfileNamesFromDisk() + const activeProfileName = getActiveProfileName() + let runtimeInfo = new Map() + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'list'], { + timeout: 10000, + ...execOpts, + }) + runtimeInfo = parseProfileListRuntimeInfo(stdout, profileNames) + } catch (err: any) { + logger.warn(err, 'Hermes CLI: profile list failed; falling back to disk profile list') + } + + return profileNames.map(name => { + const runtime = runtimeInfo.get(name) + const gatewayStatus = runtime?.gatewayStatus + return { + name, + active: runtime?.active ?? name === activeProfileName, + model: readProfileDefaultModel(name), + gatewayStatus: gatewayStatus && gatewayStatus !== '—' && gatewayStatus !== '-' ? gatewayStatus : undefined, + alias: runtime?.alias || '', + } + }) +} + +/** + * Get profile details + */ +export async function getProfile(name: string): Promise { + try { + const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'show', name], { + timeout: 10000, + ...execOpts, + }) + + const result: Record = {} + for (const line of stdout.trim().split('\n')) { + const match = line.match(/^([^\s:]+):\s+(.+)$/) + if (match) { + result[match[1].trim().toLowerCase().replace(/\s+/g, '_')] = match[2].trim() + } + } + + const modelFull = result.model || '' + const providerMatch = modelFull.match(/\((.+)\)/) + const model = providerMatch ? modelFull.replace(/\s*\(.+\)/, '').trim() : modelFull + + return { + name: result.profile || name, + path: result.path || '', + model, + provider: providerMatch ? providerMatch[1] : '', + skills: parseInt(result.skills || '0', 10), + hasEnv: result['.env'] === 'exists', + hasSoulMd: result['soul.md'] === 'exists', + } + } catch (err: any) { + if (err.code === 1 || err.status === 1) { + throw new Error(`Profile "${name}" not found`) + } + logger.error(err, 'Hermes CLI: profile show failed') + throw new Error(`Failed to get profile: ${err.message}`) + } +} + +/** + * Create a new profile + */ +export async function createProfile(name: string, clone?: boolean): Promise { + const args = ['profile', 'create', name] + if (clone) args.push('--clone') + + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, { + timeout: 15000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile create failed') + throw new Error(`Failed to create profile: ${err.message}`) + } +} + +/** + * Delete a profile + */ +export async function deleteProfile(name: string): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['profile', 'delete', name, '--yes'], { + timeout: 10000, + ...execOpts, + }) + return true + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile delete failed') + return false + } +} + +/** + * Rename a profile + */ +export async function renameProfile(oldName: string, newName: string): Promise { + try { + await execHermesWithBin(HERMES_BIN, ['profile', 'rename', oldName, newName], { + timeout: 10000, + ...execOpts, + }) + return true + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile rename failed') + return false + } +} + +/** + * Switch active profile + */ +export async function useProfile(name: string): Promise { + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['profile', 'use', name], { + timeout: 10000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile use failed') + throw new Error(`Failed to switch profile: ${err.message}`) + } +} + +/** + * Export profile to archive + */ +export async function exportProfile(name: string, outputPath?: string): Promise { + const args = ['profile', 'export', name] + if (outputPath) args.push('--output', outputPath) + + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, { + timeout: 60000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile export failed') + throw new Error(`Failed to export profile: ${err.message}`) + } +} + +/** + * Run hermes setup --non-interactive --reset to generate default config for current profile + */ +export async function setupReset(): Promise { + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['setup', '--non-interactive', '--reset'], { + timeout: 30000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, 'Hermes CLI: setup reset failed') + throw new Error(`Failed to reset config: ${err.message}`) + } +} + +/** + * Import profile from archive + */ +export async function importProfile(archivePath: string, name?: string): Promise { + const args = ['profile', 'import', archivePath] + if (name) args.push('--name', name) + + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, { + timeout: 60000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, 'Hermes CLI: profile import failed') + throw new Error(`Failed to import profile: ${err.message}`) + } +} + +/** + * Pin or unpin a skill via hermes curator + */ +export async function pinSkill(name: string, pinned: boolean): Promise { + const subcmd = pinned ? 'pin' : 'unpin' + try { + const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['curator', subcmd, name], { + timeout: 15000, + ...execOpts, + }) + return stdout || stderr + } catch (err: any) { + logger.error(err, `Hermes CLI: curator ${subcmd} failed`) + throw new Error(`Failed to ${subcmd} skill: ${err.message}`) + } +} diff --git a/packages/server/src/services/hermes/hermes-kanban.ts b/packages/server/src/services/hermes/hermes-kanban.ts new file mode 100644 index 0000000..170fa15 --- /dev/null +++ b/packages/server/src/services/hermes/hermes-kanban.ts @@ -0,0 +1,644 @@ +import type { ChildProcess } from 'child_process' +import { logger } from '../logger' +import { execHermes, spawnHermes } from './hermes-process' + +const execOpts = { windowsHide: true } +const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ +const NO_WORKER_LOG_PATTERNS = [ + /^\(no log for [^)]+?\s+—\s+task may not have spawned yet\)$/i, + /^no worker log(?: for [^\n]+)?$/i, +] + +export function normalizeBoardSlug(board?: string | null): string { + if (board === undefined || board === null) return 'default' + const trimmed = board.trim().toLowerCase() + if (!trimmed) throw new Error('Invalid kanban board slug') + if (!BOARD_SLUG_RE.test(trimmed)) { + throw new Error('Invalid kanban board slug') + } + return trimmed +} + +function boardArgs(board?: string | null): string[] { + return ['kanban', '--board', normalizeBoardSlug(board)] +} + +// ─── Types ────────────────────────────────────────────────────── + +export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived' + +export interface KanbanTask { + id: string + title: string + body: string | null + assignee: string | null + status: KanbanTaskStatus + priority: number + created_by: string | null + created_at: number + started_at: number | null + completed_at: number | null + workspace_kind: string + workspace_path: string | null + tenant: string | null + result: string | null + skills: string[] | null +} + +export interface KanbanRun { + id: number + task_id: string + profile: string | null + status: string + started_at: number + ended_at: number | null + outcome: string | null + summary: string | null + error: string | null +} + +export interface KanbanComment { + id: number + task_id: string + author: string + body: string + created_at: number +} + +export interface KanbanEvent { + id: number + task_id: string + kind: string + payload: Record | null + created_at: number + run_id: number | null +} + +export interface KanbanTaskDetail { + task: KanbanTask + comments: KanbanComment[] + events: KanbanEvent[] + runs: KanbanRun[] +} + +export interface KanbanStats { + by_status: Record + by_assignee: Record + total: number +} + +export interface KanbanAssignee { + name: string + on_disk: boolean + counts: Record | null +} + +export interface KanbanBoard { + slug: string + name: string + description: string + icon: string + color: string + created_at: number | null + archived: boolean + db_path?: string + is_current?: boolean + counts: Record + total: number +} + +export interface KanbanBoardCreateOptions { + slug: string + name?: string + description?: string + icon?: string + color?: string + switchCurrent?: boolean +} + +export interface KanbanCapabilities { + source: 'hermes-cli' + supports: Record + missing: string[] + capabilities: KanbanCapabilityStatus[] +} + +export interface KanbanTaskLog { + task_id: string + path: string | null + exists: boolean + size_bytes: number + content: string + truncated: boolean +} + +export interface KanbanCapabilityStatus { + key: string + status: 'supported' | 'partial' | 'missing' + reason?: string + canonicalRoute?: string + canonicalCommand?: string + requiresBoard: boolean +} + +export interface KanbanBoardOptions { + board?: string +} + +export interface KanbanWatchOptions extends KanbanBoardOptions { + interval?: number +} + +export interface KanbanBulkTaskUpdateOptions extends KanbanBoardOptions { + ids: string[] + status?: KanbanTaskStatus + assignee?: string | null + archive?: boolean + summary?: string + reason?: string +} + +export interface KanbanBulkTaskResult { + id: string + ok: boolean + error?: string +} + +export interface KanbanBulkTaskUpdateResult { + results: KanbanBulkTaskResult[] +} + +// ─── CLI wrappers ─────────────────────────────────────────────── + +export async function listBoards(opts?: { includeArchived?: boolean }): Promise { + const args = ['kanban', 'boards', 'list', '--json'] + if (opts?.includeArchived) args.push('--all') + + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban boards list failed') + throw new Error(`Failed to list kanban boards: ${err.message}`) + } +} + +async function findBoard(slug: string, includeArchived = true): Promise { + const boards = await listBoards({ includeArchived }) + return boards.find(board => board.slug === slug) || null +} + +export async function createBoard(opts: KanbanBoardCreateOptions): Promise { + const slug = normalizeBoardSlug(opts.slug) + const args = ['kanban', 'boards', 'create', slug] + if (opts.name?.trim()) args.push('--name', opts.name.trim()) + if (opts.description?.trim()) args.push('--description', opts.description.trim()) + if (opts.icon?.trim()) args.push('--icon', opts.icon.trim()) + if (opts.color?.trim()) args.push('--color', opts.color.trim()) + if (opts.switchCurrent) args.push('--switch') + + try { + await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + const board = await findBoard(slug) + if (!board) throw new Error('created board was not returned by boards list') + return board + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban boards create failed') + throw new Error(`Failed to create kanban board: ${err.message}`) + } +} + +export async function archiveBoard(slugInput: string): Promise { + const slug = normalizeBoardSlug(slugInput) + if (slug === 'default') throw new Error('Cannot archive the default kanban board') + + try { + await execHermes(['kanban', 'boards', 'rm', slug], { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban boards archive failed') + throw new Error(`Failed to archive kanban board: ${err.message}`) + } +} + +export async function getCapabilities(): Promise { + const capabilities: KanbanCapabilityStatus[] = [ + { key: 'explicitBoard', status: 'supported', canonicalCommand: '--board', requiresBoard: true }, + { key: 'boardsList', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards list', requiresBoard: false }, + { key: 'boardCreate', status: 'supported', canonicalRoute: '/boards', canonicalCommand: 'boards create', requiresBoard: false }, + { key: 'boardArchive', status: 'supported', canonicalRoute: '/boards/{slug}', canonicalCommand: 'boards rm', requiresBoard: false }, + { key: 'cliCurrentSwitch', status: 'partial', reason: 'Backend keeps explicit board context and does not expose a WUI route for mutating canonical CLI current board', canonicalRoute: '/boards/{slug}/switch', canonicalCommand: 'boards switch', requiresBoard: false }, + { key: 'taskCrudLite', status: 'supported', canonicalRoute: '/tasks', canonicalCommand: 'list/show/create/complete/block/unblock/assign', requiresBoard: true }, + { key: 'commentsWrite', status: 'supported', canonicalRoute: '/tasks/{task_id}/comments', canonicalCommand: 'comment', requiresBoard: true }, + { key: 'commentsRead', status: 'supported', reason: 'Comments are returned on task detail responses', canonicalRoute: '/tasks/{task_id}', canonicalCommand: 'show --json', requiresBoard: true }, + { key: 'taskLog', status: 'supported', canonicalRoute: '/tasks/{task_id}/log', canonicalCommand: 'log', requiresBoard: true }, + { key: 'diagnostics', status: 'supported', canonicalRoute: '/diagnostics', canonicalCommand: 'diagnostics', requiresBoard: true }, + { key: 'reclaim', status: 'supported', canonicalRoute: '/tasks/{task_id}/reclaim', canonicalCommand: 'reclaim', requiresBoard: true }, + { key: 'reassign', status: 'supported', canonicalRoute: '/tasks/{task_id}/reassign', canonicalCommand: 'reassign', requiresBoard: true }, + { key: 'specify', status: 'supported', canonicalRoute: '/tasks/{task_id}/specify', canonicalCommand: 'specify', requiresBoard: true }, + { key: 'dispatch', status: 'supported', canonicalRoute: '/dispatch', canonicalCommand: 'dispatch', requiresBoard: true }, + { key: 'links', status: 'supported', canonicalRoute: '/links', canonicalCommand: 'link/unlink', requiresBoard: true }, + { key: 'bulk', status: 'partial', reason: 'WUI applies supported bulk-equivalent CLI transitions per id and returns per-task outcomes; direct priority/status patch parity remains deferred', canonicalRoute: '/tasks/bulk', canonicalCommand: 'bulk-equivalent via complete/block/unblock/archive/assign', requiresBoard: true }, + { key: 'events', status: 'partial', reason: 'WUI exposes a board-scoped WebSocket bridge backed by the canonical `kanban watch` stream; payload is currently a refresh invalidation signal, not a typed event model', canonicalRoute: '/events', canonicalCommand: 'watch', requiresBoard: true }, + { key: 'homeSubscriptions', status: 'missing', reason: 'Deferred from current WUI parity batch', canonicalRoute: '/home-channels and subscription routes', canonicalCommand: 'notify-*', requiresBoard: true }, + ] + const supports = Object.fromEntries(capabilities.map(capability => [capability.key, capability.status === 'supported'])) as Record + const missing = capabilities + .filter(capability => capability.status !== 'supported') + .map(capability => capability.key) + return { source: 'hermes-cli', supports, missing, capabilities } +} + +function parseJsonPayload(stdout: string): unknown[] { + const trimmed = stdout.trim() + if (!trimmed) return [] + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) return parsed + return [parsed] +} + +function isNoWorkerLogError(err: any): boolean { + const lines = [err?.stderr, err?.stdout, err?.message] + .filter(Boolean) + .flatMap(value => String(value).split(/\r?\n/).map(line => line.trim()).filter(Boolean)) + return lines.some(line => NO_WORKER_LOG_PATTERNS.some(pattern => pattern.test(line))) +} + +function pushOptional(args: string[], flag: string, value?: string | number | null): void { + if (value !== undefined && value !== null && String(value).trim() !== '') args.push(flag, String(value)) +} + +function textFromExecValue(value: unknown): string { + if (Buffer.isBuffer(value)) return value.toString('utf8') + return value === undefined || value === null ? '' : String(value) +} + +async function execKanbanMutation(args: string[], logMessage: string, errorPrefix: string): Promise { + try { + const { stdout, stderr } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + const stderrText = textFromExecValue(stderr).trim() + if (stderrText) throw new Error(stderrText) + return textFromExecValue(stdout) + } catch (err: any) { + logger.error(err, logMessage) + throw new Error(`${errorPrefix}: ${err.message}`) + } +} + +export function buildWatchArgs(opts?: KanbanWatchOptions): string[] { + const args = [...boardArgs(opts?.board), 'watch'] + pushOptional(args, '--interval', opts?.interval ?? 0.5) + return args +} + +export function watchEvents(opts?: KanbanWatchOptions): ChildProcess { + return spawnHermes(buildWatchArgs(opts), { + stdio: ['ignore', 'pipe', 'pipe'], + ...execOpts, + }) +} + +export async function linkTasks(parentId: string, childId: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output: string }> { + const output = await execKanbanMutation( + [...boardArgs(opts?.board), 'link', parentId, childId], + 'Hermes CLI: kanban link failed', + 'Failed to link kanban tasks', + ) + return { ok: true, output } +} + +export async function unlinkTasks(parentId: string, childId: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output: string }> { + const output = await execKanbanMutation( + [...boardArgs(opts?.board), 'unlink', parentId, childId], + 'Hermes CLI: kanban unlink failed', + 'Failed to unlink kanban tasks', + ) + return { ok: true, output } +} + +export async function addComment(taskId: string, body: string, opts?: KanbanBoardOptions & { author?: string }): Promise<{ ok: boolean; output: string }> { + const args = [...boardArgs(opts?.board), 'comment', taskId, body] + pushOptional(args, '--author', opts?.author) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return { ok: true, output: stdout } + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban comment failed') + throw new Error(`Failed to comment on kanban task: ${err.message}`) + } +} + +export async function getTaskLog(taskId: string, opts?: KanbanBoardOptions & { tail?: number }): Promise { + const args = [...boardArgs(opts?.board), 'log', taskId] + pushOptional(args, '--tail', opts?.tail) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + const sizeBytes = Buffer.byteLength(stdout, 'utf8') + return { + task_id: taskId, + path: null, + exists: true, + size_bytes: sizeBytes, + content: stdout, + truncated: opts?.tail !== undefined && sizeBytes >= opts.tail, + } + } catch (err: any) { + const detail = await getTask(taskId, opts) + if (!detail) throw new Error('Kanban task not found') + if ((err.code === 1 || err.status === 1) && isNoWorkerLogError(err)) { + return { + task_id: taskId, + path: null, + exists: false, + size_bytes: 0, + content: '', + truncated: false, + } + } + logger.error(err, 'Hermes CLI: kanban log failed') + throw new Error(`Failed to read kanban task log: ${err.message}`) + } +} + +export async function getDiagnostics(opts?: KanbanBoardOptions & { task?: string; severity?: string }): Promise { + const args = [...boardArgs(opts?.board), 'diagnostics', '--json'] + pushOptional(args, '--task', opts?.task) + pushOptional(args, '--severity', opts?.severity) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban diagnostics failed') + throw new Error(`Failed to get kanban diagnostics: ${err.message}`) + } +} + +export async function reclaimTask(taskId: string, opts?: KanbanBoardOptions & { reason?: string }): Promise<{ ok: boolean; output: string }> { + const args = [...boardArgs(opts?.board), 'reclaim', taskId] + pushOptional(args, '--reason', opts?.reason) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return { ok: true, output: stdout } + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban reclaim failed') + throw new Error(`Failed to reclaim kanban task: ${err.message}`) + } +} + +export async function reassignTask(taskId: string, profile: string, opts?: KanbanBoardOptions & { reclaim?: boolean; reason?: string }): Promise<{ ok: boolean; output: string }> { + const args = [...boardArgs(opts?.board), 'reassign', taskId, profile] + if (opts?.reclaim) args.push('--reclaim') + pushOptional(args, '--reason', opts?.reason) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return { ok: true, output: stdout } + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban reassign failed') + throw new Error(`Failed to reassign kanban task: ${err.message}`) + } +} + +export async function specifyTask(taskId: string, opts?: KanbanBoardOptions & { author?: string }): Promise { + const args = [...boardArgs(opts?.board), 'specify', taskId, '--json'] + pushOptional(args, '--author', opts?.author) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return parseJsonPayload(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban specify failed') + throw new Error(`Failed to specify kanban task: ${err.message}`) + } +} + +export async function dispatch(opts?: KanbanBoardOptions & { dryRun?: boolean; max?: number; failureLimit?: number }): Promise { + const args = [...boardArgs(opts?.board), 'dispatch', '--json'] + if (opts?.dryRun) args.push('--dry-run') + pushOptional(args, '--max', opts?.max) + pushOptional(args, '--failure-limit', opts?.failureLimit) + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban dispatch failed') + throw new Error(`Failed to dispatch kanban tasks: ${err.message}`) + } +} + +export async function listTasks(opts?: { + board?: string + status?: string + assignee?: string + tenant?: string + includeArchived?: boolean +}): Promise { + const args = [...boardArgs(opts?.board), 'list', '--json'] + if (opts?.includeArchived) args.push('--archived') + if (opts?.status) args.push('--status', opts.status) + if (opts?.assignee) args.push('--assignee', opts.assignee) + if (opts?.tenant) args.push('--tenant', opts.tenant) + + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban list failed') + throw new Error(`Failed to list kanban tasks: ${err.message}`) + } +} + +export async function getTask(taskId: string, opts?: KanbanBoardOptions): Promise { + try { + const { stdout } = await execHermes([...boardArgs(opts?.board), 'show', taskId, '--json'], { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + if (err.code === 1 || err.status === 1) return null + logger.error(err, 'Hermes CLI: kanban show failed') + throw new Error(`Failed to get kanban task: ${err.message}`) + } +} + +export async function createTask( + title: string, + opts?: { + board?: string + body?: string + assignee?: string + priority?: number + tenant?: string + }, +): Promise { + const args = [...boardArgs(opts?.board), 'create', title, '--json'] + if (opts?.body) args.push('--body', opts.body) + if (opts?.assignee) args.push('--assignee', opts.assignee) + if (opts?.priority !== undefined) args.push('--priority', String(opts.priority)) + if (opts?.tenant) args.push('--tenant', opts.tenant) + + try { + const { stdout } = await execHermes(args, { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban create failed') + throw new Error(`Failed to create kanban task: ${err.message}`) + } +} + +export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise { + const args = [...boardArgs(opts?.board), 'complete', ...taskIds] + if (summary) args.push('--summary', summary) + + await execKanbanMutation(args, 'Hermes CLI: kanban complete failed', 'Failed to complete kanban tasks') +} + +export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise { + await execKanbanMutation( + [...boardArgs(opts?.board), 'block', taskId, reason], + 'Hermes CLI: kanban block failed', + 'Failed to block kanban task', + ) +} + +export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise { + await execKanbanMutation( + [...boardArgs(opts?.board), 'unblock', ...taskIds], + 'Hermes CLI: kanban unblock failed', + 'Failed to unblock kanban tasks', + ) +} + +export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise { + await execKanbanMutation( + [...boardArgs(opts?.board), 'assign', taskId, profile], + 'Hermes CLI: kanban assign failed', + 'Failed to assign kanban task', + ) +} + +export async function archiveTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise { + await execKanbanMutation( + [...boardArgs(opts?.board), 'archive', ...taskIds], + 'Hermes CLI: kanban archive failed', + 'Failed to archive kanban tasks', + ) +} + +async function applyBulkStatus(taskId: string, opts: KanbanBulkTaskUpdateOptions): Promise { + switch (opts.status) { + case undefined: + return + case 'done': + return completeTasks([taskId], opts.summary, opts) + case 'blocked': + return blockTask(taskId, opts.reason?.trim() || 'Bulk update', opts) + case 'ready': + return unblockTasks([taskId], opts) + case 'archived': + return archiveTasks([taskId], opts) + default: + throw new Error(`Bulk status ${opts.status} is not supported by the CLI bridge`) + } +} + +export async function bulkUpdateTasks(opts: KanbanBulkTaskUpdateOptions): Promise { + const ids = opts.ids.map(id => id.trim()).filter(Boolean) + const results: KanbanBulkTaskResult[] = [] + for (const id of ids) { + try { + if (opts.archive) await archiveTasks([id], opts) + else await applyBulkStatus(id, opts) + if (opts.assignee !== undefined) await assignTask(id, opts.assignee?.trim() || 'none', opts) + results.push({ id, ok: true }) + } catch (err: any) { + results.push({ id, ok: false, error: err?.message || String(err) }) + } + } + return { results } +} + +export async function getStats(opts?: KanbanBoardOptions): Promise { + try { + const { stdout } = await execHermes([...boardArgs(opts?.board), 'stats', '--json'], { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + const stats = JSON.parse(stdout) as KanbanStats + const archivedTasks = await listTasks({ board: opts?.board, status: 'archived', includeArchived: true }) + const existingArchived = stats.by_status?.archived || 0 + const archivedCount = archivedTasks.length + stats.by_status = { ...(stats.by_status || {}), archived: archivedCount } + stats.total = (stats.total || 0) + Math.max(0, archivedCount - existingArchived) + return stats + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban stats failed') + throw new Error(`Failed to get kanban stats: ${err.message}`) + } +} + +export async function getAssignees(opts?: KanbanBoardOptions): Promise { + try { + const { stdout } = await execHermes([...boardArgs(opts?.board), 'assignees', '--json'], { + maxBuffer: 50 * 1024 * 1024, + timeout: 30000, + ...execOpts, + }) + return JSON.parse(stdout) + } catch (err: any) { + logger.error(err, 'Hermes CLI: kanban assignees failed') + throw new Error(`Failed to get kanban assignees: ${err.message}`) + } +} diff --git a/packages/server/src/services/hermes/hermes-path.ts b/packages/server/src/services/hermes/hermes-path.ts new file mode 100644 index 0000000..c18a99e --- /dev/null +++ b/packages/server/src/services/hermes/hermes-path.ts @@ -0,0 +1,91 @@ +/** + * Hermes 路径检测工具 - 跨平台兼容 + * + * Hermes 数据目录在不同平台上的位置: + * - Windows 原生安装: %LOCALAPPDATA%\hermes when it exists + * - Linux/macOS/WSL2: ~/.hermes + * - 用户自定义: HERMES_HOME 环境变量 + */ + +import { existsSync } from 'fs' +import { basename, dirname, isAbsolute, relative, resolve, join } from 'path' +import { homedir } from 'os' + +/** + * 智能检测 Hermes 数据目录 + * + * 检测优先级: + * 1. HERMES_HOME 环境变量(用户自定义) + * 2. Windows: existing %LOCALAPPDATA%\hermes or %APPDATA%\hermes + * 3. 默认: ~/.hermes(Linux/macOS/WSL2) + * + * @returns Hermes 数据目录的绝对路径 + */ +export function detectHermesHome(): string { + // 1. 用户自定义的环境变量(最高优先级) + if (process.env.HERMES_HOME) { + return resolve(process.env.HERMES_HOME) + } + + const defaultHome = resolve(homedir(), '.hermes') + + // 2. Windows:优先使用存在的原生安装数据目录;不存在时回退到 ~/.hermes。 + if (process.platform === 'win32') { + const candidates = [ + process.env.LOCALAPPDATA, + process.env.APPDATA, + ] + .map(value => value?.trim()) + .filter((value): value is string => !!value) + .map(value => resolve(value, 'hermes')) + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + } + + // 3. Linux/macOS:~/.hermes + return defaultHome +} + +/** + * Detect the Hermes root data directory. + * + * `HERMES_HOME` may intentionally point at a profile directory when launching a + * specific gateway (`/profiles/`). Web UI profile management needs + * the root directory so it can read `active_profile` and enumerate profiles. + */ +export function detectHermesRootHome(): string { + const home = detectHermesHome() + const parent = dirname(home) + if (basename(parent) === 'profiles') return dirname(parent) + return home +} + +/** + * 获取 Hermes CLI 二进制文件路径 + * @param customBin 自定义的 hermes 二进制路径 + * @returns hermes 命令名称或路径 + */ +export function getHermesBin(customBin?: string): string { + if (customBin?.trim()) return customBin.trim() + if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim() + return 'hermes' +} + +function comparablePath(path: string): string { + return process.platform === 'win32' ? path.toLowerCase() : path +} + +export function isPathWithin(targetPath: string, basePath: string): boolean { + const base = resolve(basePath) + const target = resolve(targetPath) + const rel = relative(comparablePath(base), comparablePath(target)) + return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel)) +} + +export function relativePathFromBase(targetPath: string, basePath: string): string | null { + if (!isPathWithin(targetPath, basePath)) return null + const rel = relative(resolve(basePath), resolve(targetPath)) + return rel.replace(/\\/g, '/') +} diff --git a/packages/server/src/services/hermes/hermes-process.ts b/packages/server/src/services/hermes/hermes-process.ts new file mode 100644 index 0000000..cc64968 --- /dev/null +++ b/packages/server/src/services/hermes/hermes-process.ts @@ -0,0 +1,80 @@ +import { execFile, spawn } from 'child_process' +import type { ChildProcess, ExecFileOptions, SpawnOptions } from 'child_process' +import { existsSync } from 'fs' +import { basename, dirname, resolve } from 'path' + +export interface HermesInvocation { + command: string + argsPrefix: string[] +} + +export interface HermesExecResult { + stdout: string + stderr: string +} + +export function resolveHermesBin(customBin?: string): string { + return customBin?.trim() || process.env.HERMES_BIN?.trim() || 'hermes' +} + +function bundledCliPythonForWindows(hermesBin: string): string | null { + const envPython = process.env.HERMES_AGENT_CLI_PYTHON?.trim() + if (envPython) return envPython + + if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null + const python = resolve(dirname(hermesBin), '..', 'python.exe') + return existsSync(python) ? python : null +} + +function withWindowsHide(options?: T): T { + if (process.platform !== 'win32') return (options || {}) as T + return { windowsHide: true, ...(options || {}) } as T +} + +export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation { + if (process.platform === 'win32') { + const python = bundledCliPythonForWindows(hermesBin) + if (python) return { command: python, argsPrefix: ['-m', 'hermes_cli.main'] } + } + + return { command: hermesBin, argsPrefix: [] } +} + +export function execHermesWithBin( + hermesBin: string, + args: readonly string[], + options?: ExecFileOptions, +): Promise { + const invocation = resolveHermesInvocation(hermesBin) + return new Promise((resolveExec, rejectExec) => { + execFile( + invocation.command, + [...invocation.argsPrefix, ...args], + { ...withWindowsHide(options), encoding: 'utf8' }, + (error, stdout, stderr) => { + if (error) { + rejectExec(Object.assign(error, { stdout, stderr })) + return + } + resolveExec({ stdout: String(stdout || ''), stderr: String(stderr || '') }) + }, + ) + }) +} + +export function execHermes(args: readonly string[], options?: ExecFileOptions) { + return execHermesWithBin(resolveHermesBin(), args, options) +} + +export function spawnHermesWithBin( + hermesBin: string, + args: readonly string[], + options?: SpawnOptions, +): ChildProcess { + const invocation = resolveHermesInvocation(hermesBin) + return spawn(invocation.command, [...invocation.argsPrefix, ...args], withWindowsHide(options)) +} + +export function spawnHermes(args: readonly string[], options?: SpawnOptions): ChildProcess { + return spawnHermesWithBin(resolveHermesBin(), args, options) +} diff --git a/packages/server/src/services/hermes/hermes-profile.ts b/packages/server/src/services/hermes/hermes-profile.ts new file mode 100644 index 0000000..5858947 --- /dev/null +++ b/packages/server/src/services/hermes/hermes-profile.ts @@ -0,0 +1,89 @@ +import { join } from 'path' +import { readFileSync, existsSync, readdirSync } from 'fs' +import { detectHermesRootHome } from './hermes-path' + +export function getHermesBaseDir(): string { + return detectHermesRootHome() +} + +/** + * Get the active profile's home directory. + * default → ~/.hermes/ + * other → ~/.hermes/profiles/{name}/ + */ +export function getActiveProfileDir(): string { + const hermesBase = getHermesBaseDir() + const activeFile = join(hermesBase, 'active_profile') + try { + const name = readFileSync(activeFile, 'utf-8').trim() + if (name && name !== 'default') { + const dir = join(hermesBase, 'profiles', name) + if (existsSync(dir)) return dir + } + } catch { } + return hermesBase +} + +/** + * Get the active profile's config.yaml path. + */ +export function getActiveConfigPath(): string { + return join(getActiveProfileDir(), 'config.yaml') +} + +/** + * Get the active profile's auth.json path. + */ +export function getActiveAuthPath(): string { + return join(getActiveProfileDir(), 'auth.json') +} + +/** + * Get the active profile's .env path. + */ +export function getActiveEnvPath(): string { + return join(getActiveProfileDir(), '.env') +} + +/** + * Get the active profile name. + */ +export function getActiveProfileName(): string { + const activeFile = join(getHermesBaseDir(), 'active_profile') + try { + const name = readFileSync(activeFile, 'utf-8').trim() + return name || 'default' + } catch { + return 'default' + } +} + +/** + * Get profile directory by name. + * default → ~/.hermes/ + * other → ~/.hermes/profiles/{name}/ + */ +export function getProfileDir(name: string): string { + const hermesBase = getHermesBaseDir() + if (!name || name === 'default') return hermesBase + const dir = join(hermesBase, 'profiles', name) + return existsSync(dir) ? dir : hermesBase +} + +export function listProfileNamesFromDisk(): string[] { + const hermesBase = getHermesBaseDir() + const names = new Set(['default']) + const profilesDir = join(hermesBase, 'profiles') + try { + for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.trim()) { + names.add(entry.name) + } + } + } catch {} + return [...names].sort((a, b) => { + if (a === 'default') return -1 + if (b === 'default') return 1 + return a.localeCompare(b) + }) +} diff --git a/packages/server/src/services/hermes/mcp-types.ts b/packages/server/src/services/hermes/mcp-types.ts new file mode 100644 index 0000000..ed36f0a --- /dev/null +++ b/packages/server/src/services/hermes/mcp-types.ts @@ -0,0 +1,67 @@ +/** + * Shared MCP types used by both the bridge client and the service layer. + */ + +export interface McpServerEntry { + name: string + transport: string + connected: boolean + tools: number + tools_registered: number + tool_names: string[] + tool_names_registered: string[] + error?: string | null + command?: string + args?: string[] + url?: string + env?: Record + headers?: Record + tools_config?: { include?: string[]; exclude?: string[] } + prompts?: boolean + resources?: boolean + enabled?: boolean +} + +export interface McpToolEntry { + name: string + description: string + input_schema: Record +} + +export interface McpActionResult { + ok: boolean + error?: string +} + +export interface McpListResponse extends McpActionResult { + servers: McpServerEntry[] + total_tools: number +} + +export interface McpAddResponse extends McpActionResult { + name?: string +} + +export interface McpTestResponse extends McpActionResult { + tools?: string[] +} + +export interface McpToolsListResponse extends McpActionResult { + results?: Array<{ server: string; tools: McpToolEntry[] }> +} + +export interface McpReloadResponse extends McpActionResult { + message?: string +} + +/** + * Union of all MCP action responses. + * Bridge client methods return this; controllers narrow by action. + */ +export type McpActionResponse = + | McpListResponse + | McpAddResponse + | McpTestResponse + | McpToolsListResponse + | McpReloadResponse + | McpActionResult diff --git a/packages/server/src/services/hermes/mcp.ts b/packages/server/src/services/hermes/mcp.ts new file mode 100644 index 0000000..d7e756e --- /dev/null +++ b/packages/server/src/services/hermes/mcp.ts @@ -0,0 +1,67 @@ +import { AgentBridgeClient } from './agent-bridge/client' +import type { McpActionResponse } from './mcp-types' + +export type { McpServerEntry, McpActionResponse } from './mcp-types' + +let bridgeClient: AgentBridgeClient | null = null + +export function getBridgeClient(): AgentBridgeClient { + if (!bridgeClient) { + bridgeClient = new AgentBridgeClient() + } + return bridgeClient +} + +/** + * Send an MCP action to the AgentBridge using typed client methods. + */ +export async function bridgeMcpAction( + action: string, + payload: Record = {}, + profile?: string +): Promise { + const client = getBridgeClient() + let raw: McpActionResponse + + switch (action) { + case 'mcp_list': + raw = await client.mcpList(profile) + break + case 'mcp_server_add': { + const addName = String(payload.name || '') + const addConfig = payload.config as Record | undefined + if (!addName || !addConfig) throw new Error('name and config are required') + raw = await client.mcpAdd(addName, addConfig, profile) + break + } + case 'mcp_server_update': { + const updName = String(payload.name || '') + const updConfig = payload.config as Record | undefined + if (!updName || !updConfig) throw new Error('name and config are required') + raw = await client.mcpUpdate(updName, updConfig, profile) + break + } + case 'mcp_server_remove': { + const rmName = String(payload.name || '') + if (!rmName) throw new Error('name is required') + raw = await client.mcpRemove(rmName, profile) + break + } + case 'mcp_server_test': { + const testName = String(payload.name || '') + if (!testName) throw new Error('name is required') + raw = await client.mcpTest(testName, profile) + break + } + case 'mcp_tools_list': + raw = await client.mcpTools(payload.server as string | undefined, profile, payload.raw as boolean | undefined) + break + case 'mcp_reload': + raw = await client.mcpReload(payload.server as string | undefined, profile) + break + default: + throw new Error(`Unknown MCP action: ${action}`) + } + + return raw +} diff --git a/packages/server/src/services/hermes/model-context.ts b/packages/server/src/services/hermes/model-context.ts new file mode 100644 index 0000000..b76a786 --- /dev/null +++ b/packages/server/src/services/hermes/model-context.ts @@ -0,0 +1,448 @@ +import { resolve, join } from 'path' +import { readFileSync, existsSync, statSync } from 'fs' +import yaml from 'js-yaml' +import { PROVIDER_PRESETS } from '../../shared/providers' +import { getDb } from '../../db' +import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas' +import { detectHermesHome } from './hermes-path' + +const HERMES_BASE = detectHermesHome() +const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json') +const DEFAULT_CONTEXT_LENGTH = 256_000 + +export interface ModelContextLengthOptions { + profile?: string + model?: string | null + provider?: string | null +} + +interface ModelLimit { + context?: number + output?: number + input?: number +} + +interface ModelEntry { + id?: string + name?: string + limit?: ModelLimit +} + +interface ProviderEntry { + models?: Record +} + +interface CustomProviderEntry { + name?: string + base_url?: string + model?: string + models?: Record +} + +type ConfigProviderModels = Record | string[] + +interface ConfigProviderEntry { + context_length?: number + default_model?: string + model?: string + models?: ConfigProviderModels +} + +const MODEL_CACHE_PROVIDER_ALIASES: Record = { + gemini: ['google'], + moonshot: ['moonshotai'], + kilocode: ['kilo'], + 'ai-gateway': ['vercel'], + 'opencode-zen': ['opencode'], + 'opencode-go': ['opencode'], + 'glm-coding-plan': ['zai-coding-plan'], + 'kimi-coding': ['kimi-for-coding'], + 'kimi-coding-cn': ['kimi-for-coding'], + 'xai-oauth': ['xai'], +} + +// --- Config YAML helpers (js-yaml) --- + +function loadConfig(profileDir: string): any | null { + const configPath = join(profileDir, 'config.yaml') + if (!existsSync(configPath)) return null + try { + return yaml.load(readFileSync(configPath, 'utf-8'), { json: true }) as any + } catch { + return null + } +} + +// --- In-memory cache: parsed models_dev_cache (1.7MB), invalidated by mtime --- + +let _cache: Record | null = null +let _cacheMtime = 0 +const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes +let _cacheLoadedAt = 0 + +function loadModelsDevCache(): Record | null { + if (!existsSync(MODELS_DEV_CACHE)) return null + try { + const stat = statSync(MODELS_DEV_CACHE) + const now = Date.now() + // Return cached if file hasn't changed and within TTL + if (_cache && stat.mtimeMs === _cacheMtime && now - _cacheLoadedAt < CACHE_TTL_MS) { + return _cache + } + const raw = readFileSync(MODELS_DEV_CACHE, 'utf-8') + _cache = JSON.parse(raw) as Record + _cacheMtime = stat.mtimeMs + _cacheLoadedAt = now + return _cache + } catch { + return _cache // return stale cache on error + } +} + +// --- Profile helpers --- + +function getProfileDir(profile?: string): string { + if (!profile || profile === 'default') return HERMES_BASE + const dir = join(HERMES_BASE, 'profiles', profile) + return existsSync(dir) ? dir : HERMES_BASE +} + +function getDefaultModel(config: any): string | null { + const model = config?.model + if (!model || typeof model !== 'object') return null + return typeof model.default === 'string' ? model.default.trim() || null : null +} + +function getDefaultProvider(config: any): string | null { + const model = config?.model + if (!model || typeof model !== 'object') return null + return typeof model.provider === 'string' ? model.provider.trim() || null : null +} + +/** + * Read context_length from config.yaml, only as a sibling of default. + * e.g. model:\n default: gpt-5.4\n context_length: 256000 + */ +function getConfigContextLength(config: any): number | null { + const model = config?.model + if (!model || typeof model !== 'object') return null + const val = model.context_length + if (typeof val !== 'number' || !Number.isFinite(val) || val <= 0) return null + return val +} + +function getConfigProvider(config: any, provider: string | null): ConfigProviderEntry | null { + if (!provider) return null + const providers = config?.providers + if (!providers || typeof providers !== 'object') return null + const exact = providers[provider] + if (exact && typeof exact === 'object') return exact as ConfigProviderEntry + const lower = provider.toLowerCase() + const match = Object.entries(providers).find(([name]) => name.toLowerCase() === lower) + const value = match?.[1] + return value && typeof value === 'object' ? value as ConfigProviderEntry : null +} + +function getPositiveNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function providerHasModel(provider: ConfigProviderEntry, modelName: string): boolean { + if (provider.default_model === modelName || provider.model === modelName) return true + const models = provider.models + if (Array.isArray(models)) return models.includes(modelName) + return !!models && typeof models === 'object' && Object.prototype.hasOwnProperty.call(models, modelName) +} + +function lookupProviderConfigContextLength(config: any, modelName: string, provider: string | null): number | null { + const providerEntry = getConfigProvider(config, provider) + if (!providerEntry) return null + + const models = providerEntry.models + if (models && !Array.isArray(models) && typeof models === 'object') { + const modelEntry = models[modelName] + if (modelEntry && typeof modelEntry === 'object') { + const modelCtx = getPositiveNumber(modelEntry.context_length) + if (modelCtx) return modelCtx + } + } + + if (!providerHasModel(providerEntry, modelName)) return null + return getPositiveNumber(providerEntry.context_length) +} + +function normalizeCustomProviderName(name: string): string { + return name.trim().toLowerCase().replace(/ /g, '-') +} + +function normalizeBaseUrl(url: string): string { + return url.trim().toLowerCase().replace(/\/+$/, '') +} + +function getModelBaseUrl(config: any): string | null { + const model = config?.model + if (!model || typeof model !== 'object') return null + return typeof model.base_url === 'string' ? model.base_url.trim() || null : null +} + +function getCustomProviders(config: any): CustomProviderEntry[] { + return Array.isArray(config?.custom_providers) ? config.custom_providers as CustomProviderEntry[] : [] +} + +function resolveCustomProviderEntry(config: any, modelName: string, provider: string | null): CustomProviderEntry | null { + if (!provider || !provider.startsWith('custom')) return null + + const providers = getCustomProviders(config) + if (provider !== 'custom') { + const suffix = normalizeCustomProviderName(provider.slice('custom:'.length)) + return providers.find((cp) => normalizeCustomProviderName(String(cp?.name || '')) === suffix) || null + } + + const modelBaseUrl = getModelBaseUrl(config) + if (modelBaseUrl) { + const normalizedBaseUrl = normalizeBaseUrl(modelBaseUrl) + const exactByBaseUrl = providers.find((cp) => + normalizeBaseUrl(String(cp?.base_url || '')) === normalizedBaseUrl + && String(cp?.model || '').trim() === modelName, + ) + if (exactByBaseUrl) return exactByBaseUrl + } + + const matchesByModel = providers.filter((cp) => String(cp?.model || '').trim() === modelName) + return matchesByModel.length === 1 ? matchesByModel[0] : null +} + +/** + * Lookup context_length from custom_providers in config.yaml. + * - "custom:xxx" → strip prefix, match by name + * - "custom" → match by model name + */ +function lookupCustomProviderContextLength(config: any, modelName: string, provider: string | null): number | null { + const matched = resolveCustomProviderEntry(config, modelName, provider) + if (!matched) return null + + const models = matched.models + if (!models || typeof models !== 'object') return null + + const modelEntry = models[modelName] + if (!modelEntry || typeof modelEntry !== 'object') return null + + const val = modelEntry.context_length + if (typeof val !== 'number' || !Number.isFinite(val) || val <= 0) return null + return val +} + +// --- Context lookup --- + +function getCachedContext(entry: ModelEntry | undefined): number | null { + const context = entry?.limit?.context + return typeof context === 'number' && Number.isFinite(context) && context > 0 ? context : null +} + +function normalizeProviderKey(provider: string): string { + return provider.trim().toLowerCase() +} + +function getProviderCandidates(provider: string): string[] { + const normalized = normalizeProviderKey(provider) + return [normalized, ...(MODEL_CACHE_PROVIDER_ALIASES[normalized] || [])] +} + +function getProviderEntry(data: Record, provider: string): ProviderEntry | null { + const candidates = getProviderCandidates(provider) + + for (const candidate of candidates) { + const exact = data[candidate] + if (exact) return exact + } + + const entries = Object.entries(data) + for (const candidate of candidates) { + const match = entries.find(([name]) => name.toLowerCase() === candidate) + if (match) return match[1] + } + + return null +} + +function findModelEntry(models: Record, modelName: string): ModelEntry | undefined { + const exact = models[modelName] + if (exact) return exact + + const lower = modelName.toLowerCase() + for (const [name, entry] of Object.entries(models)) { + if (name.toLowerCase() === lower) return entry + if (entry.id?.toLowerCase() === lower) return entry + if (entry.name?.toLowerCase() === lower) return entry + } + + const suffix = `/${lower}` + for (const [name, entry] of Object.entries(models)) { + if (name.toLowerCase().endsWith(suffix)) return entry + if (entry.id?.toLowerCase().endsWith(suffix)) return entry + } + + return undefined +} + +function lookupContextInProvider(provider: ProviderEntry | null, modelName: string): number | null { + const models = provider?.models || {} + return getCachedContext(findModelEntry(models, modelName)) +} + +function lookupContextGloballyByModelName(data: Record, modelName: string): number | null { + for (const prov of Object.values(data)) { + const context = getCachedContext(prov.models?.[modelName]) + if (context) return context + } + + const lower = modelName.toLowerCase() + for (const prov of Object.values(data)) { + const models = prov.models || {} + for (const [name, entry] of Object.entries(models)) { + if (name.toLowerCase() === lower) { + const context = getCachedContext(entry) + if (context) return context + } + } + } + + return null +} + +function lookupUniqueContextGloballyByModelName(data: Record, modelName: string): number | null { + const exactMatches: number[] = [] + for (const prov of Object.values(data)) { + const context = getCachedContext(prov.models?.[modelName]) + if (context) exactMatches.push(context) + if (exactMatches.length > 1) return null + } + if (exactMatches.length === 1) return exactMatches[0] + + const lower = modelName.toLowerCase() + const ciMatches: number[] = [] + for (const prov of Object.values(data)) { + const models = prov.models || {} + for (const [name, entry] of Object.entries(models)) { + if (name.toLowerCase() !== lower) continue + const context = getCachedContext(entry) + if (context) ciMatches.push(context) + break + } + if (ciMatches.length > 1) return null + } + + return ciMatches[0] || null +} + +function resolveCacheProviderFromBaseUrl(baseUrl: string | null): string | null { + if (!baseUrl) return null + const normalizedBaseUrl = normalizeBaseUrl(baseUrl) + const preset = PROVIDER_PRESETS.find((entry) => normalizeBaseUrl(entry.base_url) === normalizedBaseUrl) + return preset?.value || null +} + +function resolveCustomCacheProvider(config: any, modelName: string, provider: string): string | null { + const customEntry = resolveCustomProviderEntry(config, modelName, provider) + const entryBaseUrl = typeof customEntry?.base_url === 'string' ? customEntry.base_url : null + const providerFromEntryBaseUrl = resolveCacheProviderFromBaseUrl(entryBaseUrl) + if (providerFromEntryBaseUrl) return providerFromEntryBaseUrl + + return resolveCacheProviderFromBaseUrl(getModelBaseUrl(config)) +} + +function lookupContextFromCache(config: any, modelName: string, provider: string | null): number | null { + const data = loadModelsDevCache() + if (!data) return null + + if (provider) { + if (provider === 'custom' || provider.startsWith('custom:')) { + const inferredProvider = resolveCustomCacheProvider(config, modelName, provider) + + if (inferredProvider) { + const scoped = lookupContextInProvider(getProviderEntry(data, inferredProvider), modelName) + if (scoped) return scoped + return null + } + + if (provider === 'custom') { + return lookupUniqueContextGloballyByModelName(data, modelName) + } + + return null + } + + return lookupContextInProvider(getProviderEntry(data, provider), modelName) + } + + // Legacy configs may omit model.provider; preserve the old global exact/CI lookup semantics. + return lookupContextGloballyByModelName(data, modelName) +} + +/** + * Get the context length for the current profile's default model. + * Resolution order: + * 1. model_context database override + * 2. provider/model-specific providers..models..context_length + * 3. provider-level providers..context_length when the model belongs to that provider + * 4. custom_providers models..context_length + * 5. top-level model.context_length fallback + * 6. models_dev_cache.json, scoped to model.provider when configured + * 7. DEFAULT_CONTEXT_LENGTH + */ +/** + * 从数据库 model_context 表查找上下文长度(最高优先级) + */ +function lookupContextFromDatabase(modelName: string, provider: string | null): number | null { + const db = getDb() + if (!db) return null + + try { + // 尝试精确匹配 provider 和 model + const row = db + .prepare(`SELECT context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`) + .get(provider || 'default', modelName) as { context_limit: number } | undefined + + return row?.context_limit || null + } catch { + return null + } +} + +export function getModelContextLength(input?: string | ModelContextLengthOptions): number { + const options: ModelContextLengthOptions = typeof input === 'string' + ? { profile: input } + : input || {} + const profile = options.profile + const profileDir = getProfileDir(profile) + const config = loadConfig(profileDir) + if (!config) return DEFAULT_CONTEXT_LENGTH + + const model = String(options.model || '').trim() || getDefaultModel(config) + if (!model) return DEFAULT_CONTEXT_LENGTH + + const provider = String(options.provider || '').trim() || getDefaultProvider(config) + + // 0. Database model_context table (highest priority) + const dbCtx = lookupContextFromDatabase(model, provider) + if (dbCtx && dbCtx > 0) return dbCtx + + // 1. Provider-specific context_length in config.yaml + const providerConfigCtx = lookupProviderConfigContextLength(config, model, provider) + if (providerConfigCtx && providerConfigCtx > 0) return providerConfigCtx + + // 2. Custom provider context_length + const customCtx = lookupCustomProviderContextLength(config, model, provider) + if (customCtx && customCtx > 0) return customCtx + + // 3. Global context_length fallback in config.yaml + const configCtx = getConfigContextLength(config) + if (configCtx && configCtx > 0) return configCtx + + // 4. models_dev_cache.json + const cached = lookupContextFromCache(config, model, provider) + if (cached) return cached + + // 5. Fallback + return DEFAULT_CONTEXT_LENGTH +} diff --git a/packages/server/src/services/hermes/ops-monitor.ts b/packages/server/src/services/hermes/ops-monitor.ts new file mode 100644 index 0000000..23348a1 --- /dev/null +++ b/packages/server/src/services/hermes/ops-monitor.ts @@ -0,0 +1,635 @@ +import { execFileSync } from 'child_process' +import { readFileSync } from 'fs' +import { cpus, freemem, loadavg, platform, totalmem, uptime } from 'os' +import { AgentBridgeClient } from './agent-bridge' +import { getAgentBridgeManager } from './agent-bridge/manager' + +export interface ProcessUsage { + pid: number + role: 'web' | 'broker' | 'worker' + profile?: string + running: boolean + cpuPercent: number + memoryRssBytes: number + command?: string + error?: string +} + +export interface OpsRuntimeSnapshot { + timestamp: number + system: { + platform: NodeJS.Platform + arch: string + uptimeSeconds: number + cpuCount: number + cpuPercent: number + loadAverage: number[] + totalMemoryBytes: number + freeMemoryBytes: number + usedMemoryBytes: number + memoryPercent: number + } + web: { + pid: number + uptimeSeconds: number + memory: NodeJS.MemoryUsage + cpuPercent: number + } + bridge: { + endpoint: string + reachable: boolean + error?: string + broker: { + running: boolean + ready: boolean + pid?: number + process?: ProcessUsage + restartScheduled: boolean + restartAttempts: number + } + workers: Array + totalWorkerMemoryRssBytes: number + } + sessions: { + active: number + running: number + byProfile: Record + } +} + +interface CpuTimesSample { + idle: number + total: number +} + +interface WebCpuSample { + at: number + usage: NodeJS.CpuUsage +} + +interface ProcessCpuSample { + at: number + cpuSeconds: number +} + +interface SystemMemoryUsage { + totalMemoryBytes: number + freeMemoryBytes: number + usedMemoryBytes: number + memoryPercent: number +} + +let previousSystemCpu: CpuTimesSample | null = null +let previousWebCpu: WebCpuSample | null = null +const previousWindowsProcessCpu = new Map() + +function safeCpus(): ReturnType { + try { + return cpus() + } catch { + return [] + } +} + +function readProcStatCpuTimes(): CpuTimesSample | null { + try { + const line = readFileSync('/proc/stat', 'utf-8').split(/\r?\n/, 1)[0] + const parts = line.trim().split(/\s+/) + if (parts[0] !== 'cpu') return null + const values = parts.slice(1).map(value => Number(value)).filter(Number.isFinite) + if (values.length < 4) return null + const idle = (values[3] || 0) + (values[4] || 0) + const total = values.reduce((sum, value) => sum + value, 0) + return total > 0 ? { idle, total } : null + } catch { + return null + } +} + +function procCpuCount(): number { + try { + const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8') + const processors = cpuinfo.match(/^processor\s*:/gim)?.length || 0 + if (processors > 0) return processors + const hardwareThreads = cpuinfo.match(/^CPU part\s*:/gim)?.length || 0 + return hardwareThreads > 0 ? hardwareThreads : 0 + } catch { + return 0 + } +} + +function safeCpuCount(): number { + return safeCpus().length || procCpuCount() || 1 +} + +function safeLoadAverage(): number[] { + try { + return loadavg() + } catch { + return [0, 0, 0] + } +} + +function safeUptime(): number { + try { + return uptime() + } catch { + return 0 + } +} + +function safeProcessUptime(): number { + try { + return process.uptime() + } catch { + return 0 + } +} + +function safeProcessMemoryUsage(): NodeJS.MemoryUsage { + try { + return process.memoryUsage() + } catch { + return { + rss: 0, + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + } + } +} + +function readCpuTimes(): CpuTimesSample { + let idle = 0 + let total = 0 + for (const cpu of safeCpus()) { + idle += cpu.times.idle + total += Object.values(cpu.times).reduce((sum, value) => sum + value, 0) + } + if (total > 0) return { idle, total } + return readProcStatCpuTimes() || { idle: 0, total: 0 } +} + +function sampleSystemCpuPercent(): number | null { + try { + const current = readCpuTimes() + const previous = previousSystemCpu + previousSystemCpu = current + if (!previous) return null + + const idleDelta = current.idle - previous.idle + const totalDelta = current.total - previous.total + if (totalDelta <= 0) return null + return clampPercent(((totalDelta - idleDelta) / totalDelta) * 100) + } catch { + return null + } +} + +function sampleWebCpuPercent(): number | null { + try { + const current = { + at: Date.now(), + usage: process.cpuUsage(), + } + const previous = previousWebCpu + previousWebCpu = current + if (!previous) return null + + const elapsedMicros = (current.at - previous.at) * 1000 + const used = (current.usage.user - previous.usage.user) + (current.usage.system - previous.usage.system) + if (elapsedMicros <= 0 || used < 0) return null + return clampPercent((used / elapsedMicros / safeCpuCount()) * 100) + } catch { + return null + } +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(value * 10) / 10)) +} + +function numberOrNull(value: unknown): number | null { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function fallbackSystemMemoryUsage(): SystemMemoryUsage { + let memoryTotal = 0 + let memoryFree = 0 + try { + memoryTotal = totalmem() + memoryFree = freemem() + } catch {} + const usedMemory = memoryTotal - memoryFree + return { + totalMemoryBytes: memoryTotal, + freeMemoryBytes: memoryFree, + usedMemoryBytes: usedMemory, + memoryPercent: memoryTotal > 0 ? clampPercent((usedMemory / memoryTotal) * 100) : 0, + } +} + +function parseVmStatPageCount(line: string): number | null { + const match = line.match(/:\s+([\d.]+)\.?$/) + if (!match) return null + const value = Number(match[1].replace(/\./g, '')) + return Number.isFinite(value) ? value : null +} + +export function parseMacVmStatMemory(vmStatOutput: string, totalMemoryBytes: number): SystemMemoryUsage | null { + const pageSize = Number(vmStatOutput.match(/page size of\s+(\d+)\s+bytes/i)?.[1]) + if (!Number.isFinite(pageSize) || pageSize <= 0 || totalMemoryBytes <= 0) return null + + const pages: Record = {} + for (const line of vmStatOutput.split(/\r?\n/)) { + const count = parseVmStatPageCount(line.trim()) + if (count == null) continue + if (line.includes('Pages active')) pages.active = count + else if (line.includes('Pages wired down')) pages.wired = count + else if (line.includes('Pages occupied by compressor')) pages.compressed = count + } + + const usedPages = (pages.active || 0) + (pages.wired || 0) + (pages.compressed || 0) + if (usedPages <= 0) return null + const usedMemory = Math.min(totalMemoryBytes, usedPages * pageSize) + const freeMemory = Math.max(0, totalMemoryBytes - usedMemory) + + return { + totalMemoryBytes, + freeMemoryBytes: freeMemory, + usedMemoryBytes: usedMemory, + memoryPercent: clampPercent((usedMemory / totalMemoryBytes) * 100), + } +} + +function collectMacSystemMemoryUsage(): SystemMemoryUsage | null { + try { + const totalRaw = execFileSync('sysctl', ['-n', 'hw.memsize'], { + encoding: 'utf-8', + timeout: 3000, + }).trim() + const totalMemoryBytes = Number(totalRaw) + const vmStatOutput = execFileSync('vm_stat', { + encoding: 'utf-8', + timeout: 3000, + }) + return parseMacVmStatMemory(vmStatOutput, totalMemoryBytes) + } catch { + return null + } +} + +function collectSystemMemoryUsage(): SystemMemoryUsage { + if (platform() === 'darwin') { + return collectMacSystemMemoryUsage() || fallbackSystemMemoryUsage() + } + return fallbackSystemMemoryUsage() +} + +function collectPosixProcessMetrics(pids: number[]): Map> { + const metrics = collectProcfsProcessMetrics(pids) + if (!pids.length) return metrics + try { + const output = execFileSync('ps', ['-o', 'pid=,pcpu=,rss=,comm=', '-p', pids.join(',')], { + encoding: 'utf-8', + timeout: 3000, + }) + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) continue + const [pidRaw, cpuRaw, rssRaw, ...commandParts] = trimmed.split(/\s+/) + const pid = Number(pidRaw) + if (!Number.isFinite(pid)) continue + const rssKb = numberOrNull(rssRaw) + metrics.set(pid, { + cpuPercent: numberOrNull(cpuRaw) ?? 0, + memoryRssBytes: rssKb == null ? metrics.get(pid)?.memoryRssBytes : rssKb * 1024, + command: commandParts.join(' ') || undefined, + }) + } + return metrics + } catch { + return metrics + } +} + +function collectProcfsProcessMetrics(pids: number[]): Map> { + const metrics = new Map>() + for (const pid of pids) { + try { + const status = readFileSync(`/proc/${pid}/status`, 'utf-8') + const rssKb = Number(status.match(/^VmRSS:\s+(\d+)\s+kB/im)?.[1]) + const name = status.match(/^Name:\s+(.+)$/im)?.[1]?.trim() + metrics.set(pid, { + cpuPercent: 0, + memoryRssBytes: Number.isFinite(rssKb) ? rssKb * 1024 : 0, + command: name, + }) + } catch {} + } + return metrics +} + +function parseWindowsJson(output: string): any[] { + if (!output.trim()) return [] + const parsed = JSON.parse(output) + return Array.isArray(parsed) ? parsed : [parsed] +} + +function sampleWindowsProcessCpuPercent(pid: number, cpuSeconds: number): number { + const current = { at: Date.now(), cpuSeconds } + const previous = previousWindowsProcessCpu.get(pid) + previousWindowsProcessCpu.set(pid, current) + if (!previous) return 0 + + const elapsedSeconds = (current.at - previous.at) / 1000 + const cpuDelta = current.cpuSeconds - previous.cpuSeconds + if (elapsedSeconds <= 0 || cpuDelta < 0) return 0 + return clampPercent((cpuDelta / elapsedSeconds / safeCpuCount()) * 100) +} + +function collectWindowsProcessMetrics(pids: number[]): Map> { + if (!pids.length) return new Map() + const idList = pids.join(',') + try { + const script = [ + `$ids=@(${idList});`, + '$all=Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId;', + '$byParent=@{};', + 'foreach($p in $all){$parent=[int]$p.ParentProcessId;if(-not $byParent.ContainsKey($parent)){$byParent[$parent]=@()};$byParent[$parent]+=[int]$p.ProcessId};', + '$result=@();', + 'foreach($root in $ids){', + '$seen=@{};$queue=New-Object System.Collections.Queue;$queue.Enqueue([int]$root);$tree=@();', + 'while($queue.Count -gt 0){$current=[int]$queue.Dequeue();if($seen.ContainsKey($current)){continue};$seen[$current]=$true;$tree+=$current;if($byParent.ContainsKey($current)){foreach($child in $byParent[$current]){$queue.Enqueue([int]$child)}}};', + '$procs=Get-Process -Id $tree -ErrorAction SilentlyContinue;', + '$mem=0.0;$cpu=0.0;$names=@();', + 'foreach($proc in $procs){$mem+=[double]$proc.WorkingSet64;if($null -ne $proc.CPU){$cpu+=[double]$proc.CPU};$names+=$proc.ProcessName};', + '$result+=[pscustomobject]@{pid=[int]$root;cpuSeconds=[double]$cpu;memoryRssBytes=[double]$mem;command=($names -join "+")}', + '};', + '$result', + '| ConvertTo-Json -Compress', + ].join(' ') + const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }) + const metrics = new Map>() + for (const item of parseWindowsJson(output)) { + const pid = Number(item?.pid) + if (!Number.isFinite(pid)) continue + const cpuSeconds = numberOrNull(item?.cpuSeconds) ?? 0 + metrics.set(pid, { + cpuPercent: sampleWindowsProcessCpuPercent(pid, cpuSeconds), + memoryRssBytes: numberOrNull(item?.memoryRssBytes) ?? 0, + command: typeof item?.command === 'string' ? item.command : undefined, + }) + } + return metrics + } catch {} + + try { + const script = [ + `$ids=@(${idList});`, + 'Get-CimInstance Win32_PerfFormattedData_PerfProc_Process', + '| Where-Object { $ids -contains [int]$_.IDProcess }', + '| Select-Object @{Name="pid";Expression={[int]$_.IDProcess}},@{Name="cpuPercent";Expression={[double]$_.PercentProcessorTime}},@{Name="memoryRssBytes";Expression={[double]$_.WorkingSet}},@{Name="command";Expression={$_.Name}}', + '| ConvertTo-Json -Compress', + ].join(' ') + const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }) + const metrics = new Map>() + for (const item of parseWindowsJson(output)) { + const pid = Number(item?.pid) + if (!Number.isFinite(pid)) continue + metrics.set(pid, { + cpuPercent: numberOrNull(item?.cpuPercent) ?? 0, + memoryRssBytes: numberOrNull(item?.memoryRssBytes) ?? 0, + command: typeof item?.command === 'string' ? item.command : undefined, + }) + } + return metrics + } catch {} + + const metrics = new Map>() + for (const pid of pids) { + try { + const output = execFileSync('tasklist.exe', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + }) + const line = output.split(/\r?\n/).find(item => item.includes(`"${pid}"`)) + if (!line) continue + const columns = line.match(/(".*?"|[^",]+)(?=\s*,|\s*$)/g)?.map(value => value.replace(/^"|"$/g, '')) || [] + const memoryKb = Number(columns[4]?.replace(/[^\d]/g, '')) + metrics.set(pid, { + cpuPercent: 0, + memoryRssBytes: Number.isFinite(memoryKb) ? memoryKb * 1024 : 0, + command: columns[0], + }) + } catch {} + } + return metrics +} + +function collectProcessMetrics(pids: number[]): Map> { + const uniquePids = [...new Set(pids.filter(pid => Number.isFinite(pid) && pid > 0))] + return platform() === 'win32' + ? collectWindowsProcessMetrics(uniquePids) + : collectPosixProcessMetrics(uniquePids) +} + +function processUsage( + pid: number | undefined, + role: ProcessUsage['role'], + metrics: Map>, + profile?: string, +): ProcessUsage | undefined { + if (!pid) return undefined + const metric = metrics.get(pid) + return { + pid, + role, + profile, + running: !!metric, + cpuPercent: metric?.cpuPercent ?? 0, + memoryRssBytes: metric?.memoryRssBytes ?? 0, + command: metric?.command, + } +} + +function normalizeWorker(raw: unknown): { + running: boolean + pid?: number + endpoint?: string + lastUsedAt?: number +} { + if (typeof raw === 'boolean') return { running: raw } + if (!raw || typeof raw !== 'object') return { running: false } + const record = raw as Record + const pid = Number(record.pid) + const lastUsedAt = Number(record.last_used_at) + return { + running: !!record.running, + pid: Number.isFinite(pid) && pid > 0 ? pid : undefined, + endpoint: typeof record.endpoint === 'string' ? record.endpoint : undefined, + lastUsedAt: Number.isFinite(lastUsedAt) ? lastUsedAt : undefined, + } +} + +export function createEmptyOpsRuntimeSnapshot(error?: string): OpsRuntimeSnapshot { + return { + timestamp: Date.now(), + system: { + platform: process.platform, + arch: process.arch, + uptimeSeconds: safeUptime(), + cpuCount: safeCpuCount(), + cpuPercent: 0, + loadAverage: safeLoadAverage(), + totalMemoryBytes: 0, + freeMemoryBytes: 0, + usedMemoryBytes: 0, + memoryPercent: 0, + }, + web: { + pid: process.pid, + uptimeSeconds: safeProcessUptime(), + memory: safeProcessMemoryUsage(), + cpuPercent: 0, + }, + bridge: { + endpoint: '', + reachable: false, + error, + broker: { + running: false, + ready: false, + restartScheduled: false, + restartAttempts: 0, + }, + workers: [], + totalWorkerMemoryRssBytes: 0, + }, + sessions: { + active: 0, + running: 0, + byProfile: {}, + }, + } +} + +export async function getOpsRuntimeSnapshot(): Promise { + const manager = getAgentBridgeManager() + const managerState = manager.getRuntimeState() + let bridgeReachable = false + let bridgeError: string | undefined + let bridgePing: Record = {} + + try { + const client = new AgentBridgeClient({ endpoint: managerState.endpoint, timeoutMs: 2000, connectRetryMs: 0 }) + bridgePing = await client.ping() as Record + bridgeReachable = true + } catch (err: any) { + bridgeError = err?.message || 'Agent bridge is not reachable' + } + + const workerEntries = Object.entries((bridgePing.worker_details || {}) as Record) + .map(([profile, value]) => [profile, normalizeWorker(value)] as const) + const brokerPid = Number(bridgePing.broker?.pid || managerState.pid) + const pids = [ + process.pid, + Number.isFinite(brokerPid) ? brokerPid : undefined, + ...workerEntries.map(([, worker]) => worker.pid), + ].filter((pid): pid is number => typeof pid === 'number' && pid > 0) + const processMetrics = collectProcessMetrics(pids) + + const sessionCountsByProfile: Record = {} + if (bridgePing.sessions_by_profile && typeof bridgePing.sessions_by_profile === 'object') { + for (const [profileName, count] of Object.entries(bridgePing.sessions_by_profile)) { + const value = Number(count) + if (Number.isFinite(value)) sessionCountsByProfile[profileName] = value + } + } + const runningSessionCountsByProfile: Record = {} + if (bridgePing.running_sessions_by_profile && typeof bridgePing.running_sessions_by_profile === 'object') { + for (const [profileName, count] of Object.entries(bridgePing.running_sessions_by_profile)) { + const value = Number(count) + if (Number.isFinite(value)) runningSessionCountsByProfile[profileName] = value + } + } + const runningSessions = Number(bridgePing.running_sessions || 0) + + const workers = workerEntries.map(([profileName, worker]) => { + const usage = processUsage(worker.pid, 'worker', processMetrics, profileName) + return { + pid: worker.pid || 0, + role: 'worker' as const, + profile: profileName, + running: worker.running, + cpuPercent: usage?.cpuPercent ?? 0, + memoryRssBytes: usage?.memoryRssBytes ?? 0, + command: usage?.command, + endpoint: worker.endpoint, + lastUsedAt: worker.lastUsedAt, + sessionCount: sessionCountsByProfile[profileName] || 0, + runningSessionCount: runningSessionCountsByProfile[profileName] || 0, + } + }) + + const systemMemory = collectSystemMemoryUsage() + const totalWorkerMemory = workers.reduce((sum, worker) => sum + (worker.memoryRssBytes || 0), 0) + + return { + timestamp: Date.now(), + system: { + platform: process.platform, + arch: process.arch, + uptimeSeconds: safeUptime(), + cpuCount: safeCpuCount(), + cpuPercent: sampleSystemCpuPercent() ?? 0, + loadAverage: safeLoadAverage(), + totalMemoryBytes: systemMemory.totalMemoryBytes, + freeMemoryBytes: systemMemory.freeMemoryBytes, + usedMemoryBytes: systemMemory.usedMemoryBytes, + memoryPercent: systemMemory.memoryPercent, + }, + web: { + pid: process.pid, + uptimeSeconds: safeProcessUptime(), + memory: safeProcessMemoryUsage(), + cpuPercent: sampleWebCpuPercent() ?? 0, + }, + bridge: { + endpoint: managerState.endpoint, + reachable: bridgeReachable, + error: bridgeError, + broker: { + running: managerState.running, + ready: managerState.ready, + pid: Number.isFinite(brokerPid) && brokerPid > 0 ? brokerPid : undefined, + process: processUsage(Number.isFinite(brokerPid) ? brokerPid : undefined, 'broker', processMetrics), + restartScheduled: managerState.restartScheduled, + restartAttempts: managerState.restartAttempts, + }, + workers, + totalWorkerMemoryRssBytes: totalWorkerMemory, + }, + sessions: { + active: Number(bridgePing.active_sessions || 0), + running: runningSessions, + byProfile: sessionCountsByProfile, + }, + } +} diff --git a/packages/server/src/services/hermes/plugins.ts b/packages/server/src/services/hermes/plugins.ts new file mode 100644 index 0000000..46710f2 --- /dev/null +++ b/packages/server/src/services/hermes/plugins.ts @@ -0,0 +1,270 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { getActiveProfileDir, getProfileDir } from './hermes-profile' +import { resolveAgentBridgeCommand } from './agent-bridge/manager' + +const execFileAsync = promisify(execFile) + +export type HermesPluginSource = 'bundled' | 'user' | 'project' | 'entrypoint' +export type HermesPluginKind = 'standalone' | 'backend' | 'exclusive' | 'platform' | 'model-provider' +export type HermesPluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed' +export type HermesPluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed' + +export interface HermesPluginInfo { + key: string + name: string + kind: HermesPluginKind | string + source: HermesPluginSource | string + configStatus: HermesPluginConfigStatus + effectiveStatus: HermesPluginEffectiveStatus + version: string + description: string + author: string + path: string + providesTools: string[] + providesHooks: string[] + requiresEnv: Array> +} + +export interface HermesPluginsMetadata { + hermesAgentRoot: string + pythonExecutable: string + cwd: string + projectPluginsEnabled: boolean +} + +export interface HermesPluginsResponse { + plugins: HermesPluginInfo[] + warnings: string[] + metadata: HermesPluginsMetadata +} + +const PYTHON_BRIDGE = String.raw` +import json +import os +import sys +import traceback +from pathlib import Path + +warnings = [] +agent_root = os.environ.get("HERMES_AGENT_ROOT_RESOLVED", "") + +# python -c normally prepends the process cwd to sys.path. Remove it before any +# Hermes imports so an arbitrary WUI launch directory cannot shadow modules like +# hermes_cli, hermes_constants, utils, or yaml. The process cwd is still preserved +# separately for optional project-plugin scanning below. +sys.path = [entry for entry in sys.path if entry not in ("", os.getcwd())] +if agent_root: + sys.path.insert(0, agent_root) + +try: + from hermes_cli.plugins import ( + PluginManager, + get_bundled_plugins_dir, + _get_disabled_plugins, + _get_enabled_plugins, + ) + from hermes_constants import get_hermes_home +except Exception as exc: + print(json.dumps({ + "error": "Failed to import Hermes Agent plugin modules", + "detail": str(exc), + "traceback": traceback.format_exc(), + })) + sys.exit(2) + + +def env_enabled(name): + return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on") + + +def safe_scan(label, fn): + try: + return fn() + except Exception as exc: + warnings.append(f"{label}: {exc}") + return [] + + +def coerce_list(value): + return value if isinstance(value, list) else [] + + +def read_manifest_list(plugin_path, *keys): + try: + import yaml + plugin_dir = Path(plugin_path) + manifest_file = plugin_dir / "plugin.yaml" + if not manifest_file.exists(): + manifest_file = plugin_dir / "plugin.yml" + if not manifest_file.exists(): + return [] + data = yaml.safe_load(manifest_file.read_text(encoding="utf-8")) or {} + for key in keys: + value = data.get(key) + if isinstance(value, list): + return value + return [] + except Exception as exc: + warnings.append(f"manifest metadata at {plugin_path}: {exc}") + return [] + + +def manifest_list(manifest, attr, *manifest_keys): + value = coerce_list(getattr(manifest, attr, [])) + if value: + return value + return read_manifest_list(getattr(manifest, "path", ""), *manifest_keys) + +manager = PluginManager() +manifests = [] + +bundled_root = get_bundled_plugins_dir() +manifests.extend(safe_scan( + f"bundled plugins at {bundled_root}", + lambda: manager._scan_directory( + bundled_root, + source="bundled", + skip_names={"platforms"}, + ), +)) +manifests.extend(safe_scan( + f"bundled platform plugins at {bundled_root / 'platforms'}", + lambda: manager._scan_directory(bundled_root / "platforms", source="bundled"), +)) + +user_dir = get_hermes_home() / "plugins" +manifests.extend(safe_scan( + f"user plugins at {user_dir}", + lambda: manager._scan_directory(user_dir, source="user"), +)) + +project_plugins_enabled = env_enabled("HERMES_ENABLE_PROJECT_PLUGINS") +if project_plugins_enabled: + project_dir = Path.cwd() / ".hermes" / "plugins" + manifests.extend(safe_scan( + f"project plugins at {project_dir}", + lambda: manager._scan_directory(project_dir, source="project"), + )) + +manifests.extend(safe_scan( + "pip entry-point plugins", + lambda: manager._scan_entry_points(), +)) + +winners = {} +for manifest in manifests: + key = manifest.key or manifest.name + winners[key] = manifest + +disabled = _get_disabled_plugins() +enabled = _get_enabled_plugins() +enabled_set = enabled if enabled is not None else set() + +plugins = [] +for key, manifest in sorted(winners.items(), key=lambda item: item[0].lower()): + disabled_match = key in disabled or manifest.name in disabled + enabled_match = key in enabled_set or manifest.name in enabled_set + + if disabled_match: + config_status = "disabled" + effective_status = "disabled" + elif manifest.kind == "exclusive": + config_status = "provider-managed" + effective_status = "provider-managed" + elif manifest.kind == "model-provider": + config_status = "provider-managed" + effective_status = "provider-managed" + elif manifest.source == "bundled" and manifest.kind in ("backend", "platform"): + config_status = "auto" + effective_status = "auto-active" + elif enabled_match: + config_status = "enabled" + effective_status = "enabled" + else: + config_status = "not-enabled" + effective_status = "inactive" + + plugins.append({ + "key": key, + "name": manifest.name, + "kind": manifest.kind, + "source": manifest.source, + "configStatus": config_status, + "effectiveStatus": effective_status, + "version": manifest.version or "", + "description": manifest.description or "", + "author": manifest.author or "", + "path": manifest.path or "", + "providesTools": manifest_list(manifest, "provides_tools", "provides_tools", "tools"), + "providesHooks": manifest_list(manifest, "provides_hooks", "provides_hooks", "hooks"), + "requiresEnv": manifest_list(manifest, "requires_env", "requires_env"), + }) + +print(json.dumps({ + "plugins": plugins, + "warnings": warnings, + "metadata": { + "hermesAgentRoot": os.environ.get("HERMES_AGENT_ROOT_RESOLVED", ""), + "pythonExecutable": sys.executable, + "cwd": str(Path.cwd()), + "projectPluginsEnabled": project_plugins_enabled, + }, +})) +` + +function extractError(err: any): string { + const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' + const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' + return [err?.message, stdout, stderr].filter(Boolean).join('\n') +} + +export async function listHermesPlugins(profile?: string): Promise { + const command = resolveAgentBridgeCommand() + const agentRoot = command.agentRoot || '' + const hermesHome = profile ? getProfileDir(profile) : getActiveProfileDir() + const env: NodeJS.ProcessEnv = { + ...process.env, + HERMES_AGENT_ROOT_RESOLVED: agentRoot, + HERMES_HOME: hermesHome, + } + if (!agentRoot) { + delete env.PYTHONHOME + delete env.PYTHONPATH + } + const pythonArgs = [ + ...command.argsPrefix, + ...(agentRoot ? ['-I'] : []), + '-c', + PYTHON_BRIDGE, + ] + const displayArgs = [ + ...command.argsPrefix, + ...(agentRoot ? ['-I'] : []), + '-c', + '', + ].join(' ') + + const errors: string[] = [] + try { + const { stdout, stderr } = await execFileAsync(command.command, pythonArgs, { + cwd: process.cwd(), + env, + windowsHide: true, + timeout: 15000, + maxBuffer: 10 * 1024 * 1024, + }) + const parsed = JSON.parse(stdout) as HermesPluginsResponse & { error?: string; detail?: string } + if ((parsed as any).error) { + throw new Error(`${(parsed as any).error}: ${(parsed as any).detail || 'unknown error'}`) + } + if (stderr?.trim()) { + parsed.warnings = [...(parsed.warnings || []), stderr.trim()] + } + return parsed + } catch (err: any) { + errors.push(`${command.command} ${displayArgs}: ${extractError(err)}`) + } + + throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`) +} diff --git a/packages/server/src/services/hermes/profile-credentials.ts b/packages/server/src/services/hermes/profile-credentials.ts new file mode 100644 index 0000000..bf30d3b --- /dev/null +++ b/packages/server/src/services/hermes/profile-credentials.ts @@ -0,0 +1,188 @@ +/** + * 智能克隆 Profile 凭据管理 + * + * 背景:`hermes profile create --clone` 会完整复制源 profile 的 .env + config.yaml, + * 包括各平台的独占凭据(Weixin / Telegram / Slack / ...)。 + * 这导致多个 profile 同时持有同一个 bot token,hermes-agent 内部的 token 互斥机制 + * 会让后启动的 gateway 在健康检查阶段被 kill,表现为"profile 加载错误"。 + * + * 解决方案:clone 完成后,对新 profile 自动执行: + * 1. 从 .env 中删除所有匹配独占平台前缀的 KEY + * 2. 把 config.yaml 中独占平台的 `enabled: true` 改为 false + * 操作前会备份原文件为 `.bak.`。 + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import yaml from 'js-yaml' +import { detectHermesHome } from './hermes-path' + +const HERMES_BASE = detectHermesHome() + +/** + * 已知"独占型"平台的环境变量前缀正则 + * + * 这些平台的凭据本质上是"一对一身份绑定":一个 token / app_id 对应唯一一个机器人或账号。 + * 多个 profile 共享同一凭据会触发 hermes-agent 的 token 互斥机制 → 启动失败。 + * + * 不在此列表的(模型 provider API key、工具调试开关等)视为可安全共享。 + * + * **来源(不要凭主观推测扩展)**:与 hermes-agent `gateway/platforms/` 中实际调用 + * `_acquire_platform_lock` / `acquire_scoped_lock` 的 adapter 1:1 对齐。 + * 验证方法:`grep -l _acquire_platform_lock gateway/platforms/*.py`。 + * 当前匹配上游的 7 个:discord, feishu, signal, slack, telegram, weixin, whatsapp。 + */ +export const EXCLUSIVE_PLATFORM_ENV_PATTERNS: RegExp[] = [ + /^TELEGRAM_/, // Telegram bot + /^DISCORD_/, // Discord bot + /^SLACK_/, // Slack app + /^WHATSAPP_/, // WhatsApp Business + /^SIGNAL_/, // Signal + /^WEIXIN_/, // 个人微信 bot + /^FEISHU_/, // 飞书 +] + +/** + * 已知"独占型"平台在 config.yaml 中 `platforms.` 节点的名称集合 + * 与 EXCLUSIVE_PLATFORM_ENV_PATTERNS 一一对应,用于禁用 `enabled` 字段。 + */ +export const EXCLUSIVE_PLATFORMS = [ + 'telegram', 'discord', 'slack', 'whatsapp', 'signal', 'weixin', 'feishu', +] + +/** + * config.yaml 中独占平台节点下的"敏感凭据字段"黑名单 + * + * 仅在 EXCLUSIVE_PLATFORMS 节点(含其 `extra` 子节点)下作用,避免误伤模型 provider key + * 等其他配置。clone 时这些字段会被一并删除,防止用户后续 re-enable 平台时复用源 profile + * 的身份。 + */ +export const EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS = [ + 'token', 'bot_token', 'app_token', + 'signing_secret', 'app_secret', 'client_secret', + 'access_token', 'webhook_secret', + 'account_id', 'phone_number_id', 'app_id', +] + +/** 判断 .env 中的 KEY 是否属于独占平台凭据 */ +export function isExclusivePlatformKey(key: string): boolean { + return EXCLUSIVE_PLATFORM_ENV_PATTERNS.some(re => re.test(key)) +} + +/** + * 清理 .env 文件中的独占平台凭据 + * @param envPath .env 文件绝对路径 + * @returns 被删除的 KEY 名列表(按 .env 中出现顺序);文件不存在或无需删除时返回 [] + * + * 副作用:实际删除前会备份为 `.env.bak.`,便于用户恢复。 + */ +export function stripExclusivePlatformCredentials(envPath: string): string[] { + if (!existsSync(envPath)) return [] + const original = readFileSync(envPath, 'utf-8') + const lines = original.split('\n') + const removedKeys: string[] = [] + const kept: string[] = [] + for (const line of lines) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/) + if (m && isExclusivePlatformKey(m[1])) { + removedKeys.push(m[1]) + } else { + kept.push(line) + } + } + if (removedKeys.length === 0) return [] + writeFileSync(`${envPath}.bak.${Date.now()}`, original, 'utf-8') + writeFileSync(envPath, kept.join('\n'), 'utf-8') + return removedKeys +} + +/** + * 禁用 config.yaml 中已知独占平台的 enabled 字段,并清理节点下的敏感凭据 + * @param configPath config.yaml 绝对路径 + * @returns + * - disabled: 被禁用的平台名列表 + * - strippedConfigCredentials: 被清理的凭据字段路径(如 'weixin.extra.token') + * 无任何修改时两个字段均为空数组。 + * + * 副作用:实际改写前会备份为 `config.yaml.bak.`。 + */ +export function disableExclusivePlatformsInConfig(configPath: string): { + disabled: string[] + strippedConfigCredentials: string[] +} { + if (!existsSync(configPath)) return { disabled: [], strippedConfigCredentials: [] } + const original = readFileSync(configPath, 'utf-8') + let cfg: any + try { + cfg = yaml.load(original, { json: true }) + } catch { + return { disabled: [], strippedConfigCredentials: [] } + } + if (!cfg || typeof cfg !== 'object') return { disabled: [], strippedConfigCredentials: [] } + const platforms = cfg.platforms + if (!platforms || typeof platforms !== 'object') return { disabled: [], strippedConfigCredentials: [] } + + const disabled: string[] = [] + const strippedConfigCredentials: string[] = [] + + for (const platName of EXCLUSIVE_PLATFORMS) { + const node = platforms[platName] + if (!node || typeof node !== 'object') continue + + if (node.enabled === true) { + node.enabled = false + disabled.push(platName) + } + + // 清理节点直挂的凭据字段 + for (const k of EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS) { + if (k in node) { + delete node[k] + strippedConfigCredentials.push(`${platName}.${k}`) + } + } + // 清理 extra 子节点中的凭据字段 + if (node.extra && typeof node.extra === 'object') { + for (const k of EXCLUSIVE_PLATFORM_CREDENTIAL_KEYS) { + if (k in node.extra) { + delete node.extra[k] + strippedConfigCredentials.push(`${platName}.extra.${k}`) + } + } + } + } + + if (disabled.length === 0 && strippedConfigCredentials.length === 0) { + return { disabled: [], strippedConfigCredentials: [] } + } + writeFileSync(`${configPath}.bak.${Date.now()}`, original, 'utf-8') + writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8') + return { disabled, strippedConfigCredentials } +} + +export interface SmartCloneCleanup { + /** 从 .env 中删除的 KEY 名列表 */ + strippedCredentials: string[] + /** 在 config.yaml 中被禁用的平台名列表 */ + disabledPlatforms: string[] + /** 在 config.yaml 中被清理的内嵌凭据字段路径(如 'weixin.extra.token') */ + strippedConfigCredentials: string[] +} + +/** + * 一站式:清理新 profile 的独占凭据 + 禁用 config.yaml 中的独占平台 + * + * @param profileName profile 名称('default' → ~/.hermes/,其他 → ~/.hermes/profiles//) + */ +export function smartCloneCleanup(profileName: string): SmartCloneCleanup { + const profileDir = profileName === 'default' + ? HERMES_BASE + : join(HERMES_BASE, 'profiles', profileName) + const configResult = disableExclusivePlatformsInConfig(join(profileDir, 'config.yaml')) + return { + strippedCredentials: stripExclusivePlatformCredentials(join(profileDir, '.env')), + disabledPlatforms: configResult.disabled, + strippedConfigCredentials: configResult.strippedConfigCredentials, + } +} diff --git a/packages/server/src/services/hermes/profile-list-parser.ts b/packages/server/src/services/hermes/profile-list-parser.ts new file mode 100644 index 0000000..5995271 --- /dev/null +++ b/packages/server/src/services/hermes/profile-list-parser.ts @@ -0,0 +1,81 @@ +export interface ProfileListRuntimeInfo { + active: boolean + gatewayStatus?: string + alias?: string +} + +const GATEWAY_STATUS_TOKENS = new Set([ + 'running', + 'stopped', + 'starting', + 'active', + 'stop', + '—', + '-', +]) + +function normalizeProfileLine(line: string): { active: boolean; body: string } | null { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('Profile') || trimmed.match(/^─/)) return null + const active = trimmed.startsWith('◆') + return { + active, + body: active ? trimmed.slice(1).trim() : trimmed, + } +} + +function matchProfileLine(body: string, profileNames: string[]): { profile: string; rest: string } | null { + for (const profile of profileNames) { + if (body === profile) return { profile, rest: '' } + if (body.startsWith(profile) && /\s/.test(body.charAt(profile.length))) { + return { profile, rest: body.slice(profile.length).trim() } + } + } + return null +} + +function extractGatewayInfo(rest: string): { gatewayStatus?: string; alias?: string } { + const parts = rest.split(/\s+/).filter(Boolean) + for (let i = 0; i < parts.length; i += 1) { + const token = parts[i] + if (GATEWAY_STATUS_TOKENS.has(token.toLowerCase())) { + const alias = parts[i + 1] + return { + gatewayStatus: token, + alias: alias && alias !== '—' && alias !== '-' ? alias : undefined, + } + } + } + return {} +} + +export function parseProfileListRuntimeInfo(stdout: string, profileNames: string[]): Map { + const result = new Map() + const sortedProfiles = [...new Set(profileNames.map(name => name.trim()).filter(Boolean))] + .sort((a, b) => b.length - a.length) + const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const lines = normalized.trim().split('\n').filter(Boolean) + + for (const line of lines) { + const parsed = normalizeProfileLine(line) + if (!parsed) continue + const matched = matchProfileLine(parsed.body, sortedProfiles) + if (!matched) continue + const gateway = extractGatewayInfo(matched.rest) + result.set(matched.profile, { + active: parsed.active, + ...gateway, + }) + } + + return result +} + +export function parseGatewayStatusesFromProfileList(stdout: string, profileNames: string[]): Map { + const runtimes = parseProfileListRuntimeInfo(stdout, profileNames) + const statuses = new Map() + for (const [profile, info] of runtimes) { + if (info.gatewayStatus) statuses.set(profile, info.gatewayStatus) + } + return statuses +} diff --git a/packages/server/src/services/hermes/run-chat/abort.ts b/packages/server/src/services/hermes/run-chat/abort.ts new file mode 100644 index 0000000..09a5be0 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/abort.ts @@ -0,0 +1,150 @@ +/** + * Abort handler — cancels in-progress runs (both API server and CLI bridge). + */ + +import type { Server, Socket } from 'socket.io' +import { updateSessionStats } from '../../../db/hermes/session-store' +import { logger } from '../../logger' +import { flushBridgePendingToDb } from './bridge-message' +import { flushResponseRunToDb } from './response-stream' +import { replaceState } from './compression' +import { calcAndUpdateUsage } from './usage' +import type { QueuedRun, SessionState } from './types' + +export async function handleAbort( + nsp: ReturnType, + socket: Socket, + sessionId: string, + sessionMap: Map, + bridge: any, + runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void, +) { + const state = sessionMap.get(sessionId) + if (!state?.isWorking || (!state.runId && !state.abortController)) { + logger.info({ sessionId }, '[chat-run-socket][abort] ignored: no active run') + if (state) { + state.isWorking = false + state.isAborting = false + state.abortController = undefined + state.runId = undefined + state.events = [] + } + emitToSession(nsp, socket, sessionId, 'abort.completed', { + event: 'abort.completed', + synced: false, + ignored: true, + }) + return + } + + const runId = state.runId + state.isAborting = true + replaceState(sessionMap, sessionId, 'abort.started', { + event: 'abort.started', + run_id: runId, + graceMs: 5000, + }) + emitToSession(nsp, socket, sessionId, 'abort.started', { + event: 'abort.started', + run_id: runId, + graceMs: 5000, + }) + logger.info({ sessionId, runId }, '[chat-run-socket][abort] started') + + // Flush in-memory assistant text to DB before aborting the stream. + if (state.source === 'cli') { + flushBridgePendingToDb(state, sessionId) + } else { + flushResponseRunToDb(state, sessionId) + } + + if (state.source === 'cli') { + try { + await bridge.interrupt(sessionId, 'Aborted by user', state.profile) + } catch (err) { + logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId) + } + try { + await bridge.goalPause?.(sessionId, 'user-interrupted', state.profile) + state.queue = state.queue.filter(item => !item.goalContinuation) + } catch (err) { + logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId) + } + } else if (state.abortController) { + state.abortController.abort() + } + + await markAbortCompleted(nsp, socket, sessionId, runId || 'response_stream', sessionMap, runQueuedItem) +} + +export async function markAbortCompleted( + nsp: ReturnType, + socket: Socket, + sessionId: string, + runId: string, + sessionMap: Map, + runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void, +) { + const state = sessionMap.get(sessionId) + if (!state) return + + const profile = state.profile + updateSessionStats(sessionId) + const emit = (event: string, payload: any) => { + nsp.to(`session:${sessionId}`).emit(event, { ...payload, session_id: sessionId }) + } + await calcAndUpdateUsage(sessionId, state, emit) + + state.isWorking = false + state.isAborting = false + state.profile = undefined + state.abortController = undefined + state.runId = undefined + state.responseRun = undefined + state.activeRunMarker = undefined + + // Process queued messages after abort completes + if (state.queue.length > 0) { + const next = state.queue.shift()! + state.isWorking = true + state.isAborting = false + state.profile = next.profile || profile + state.source = next.source + logger.info('[chat-run-socket][abort] dequeuing queued run for session %s (remaining: %d)', sessionId, state.queue.length) + replaceState(sessionMap, sessionId, 'abort.completed', { + event: 'abort.completed', + run_id: runId, + synced: true, + queue_length: state.queue.length + 1, + }) + emitToSession(nsp, socket, sessionId, 'abort.completed', { + event: 'abort.completed', + run_id: runId, + synced: true, + queue_length: state.queue.length + 1, + }) + emitToSession(nsp, socket, sessionId, 'run.queued', { + event: 'run.queued', + queue_length: state.queue.length, + }) + state.events = [] + runQueuedItem(socket, sessionId, next, profile || 'default') + return + } + + state.events = [] + emitToSession(nsp, socket, sessionId, 'abort.completed', { + event: 'abort.completed', + run_id: runId, + synced: true, + }) + logger.info({ sessionId, runId, synced: true }, '[chat-run-socket][abort] completed') +} + +function emitToSession(nsp: ReturnType, socket: Socket, sessionId: string, event: string, payload: any) { + const tagged = { ...payload, session_id: sessionId } + nsp.to(`session:${sessionId}`).emit(event, tagged) + if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { + socket.emit(event, tagged) + } +} diff --git a/packages/server/src/services/hermes/run-chat/bridge-delta.ts b/packages/server/src/services/hermes/run-chat/bridge-delta.ts new file mode 100644 index 0000000..652f8f2 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/bridge-delta.ts @@ -0,0 +1,116 @@ +export interface BridgeDeltaFilterState { + bridgePendingToolCallMarkup?: string +} + +const TOOL_CALL_MARKER = '[Calling tool:' +const MAX_PENDING_TOOL_MARKUP_LENGTH = 100_000 + +/** + * Flush any partial-prefix that was held back waiting to see if it would + * become a `[Calling tool: ...]` marker. Call this when the streaming + * context guarantees no marker can follow (e.g. on `tool.started`, + * `tool.completed`, run completion, run failure, abort). + * + * Without this, deltas that legitimately end with `[`, `[C`, `[Ca`, ..., + * `[Calling tool` are silently dropped from the user-visible stream + * because the filter expected the buffered chars to either complete the + * marker (and be discarded) or be released by a follow-up delta — but + * follow-up deltas don't always come for the SAME assistant message. + * + * Returns the buffered text so callers can forward it to the client as + * a regular `message.delta` payload. + */ +export function flushPendingToolCallMarkup(state: BridgeDeltaFilterState): string { + const pending = state.bridgePendingToolCallMarkup || '' + state.bridgePendingToolCallMarkup = '' + return pending +} + +function findToolMarkupEnd(text: string, start: number): number { + let depth = 0 + let inString = false + let escaped = false + + for (let i = start; i < text.length; i += 1) { + const ch = text[i] + if (inString) { + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } + continue + } + + if (ch === '"') { + inString = true + continue + } + if (ch === '[') { + depth += 1 + continue + } + if (ch === ']') { + depth -= 1 + if (depth === 0) return i + 1 + } + } + + return -1 +} + +function trailingMarkerPrefixLength(text: string): number { + const max = Math.min(text.length, TOOL_CALL_MARKER.length - 1) + for (let len = max; len > 0; len -= 1) { + if (TOOL_CALL_MARKER.startsWith(text.slice(text.length - len))) return len + } + return 0 +} + +export function filterBridgeToolCallMarkupDelta( + state: BridgeDeltaFilterState, + delta: string, +): string { + if (!delta) return '' + + const text = `${state.bridgePendingToolCallMarkup || ''}${delta}` + state.bridgePendingToolCallMarkup = '' + + let out = '' + let idx = 0 + while (idx < text.length) { + const markerIdx = text.indexOf(TOOL_CALL_MARKER, idx) + if (markerIdx < 0) { + const rest = text.slice(idx) + const pendingPrefixLength = trailingMarkerPrefixLength(rest) + if (pendingPrefixLength > 0) { + out += rest.slice(0, rest.length - pendingPrefixLength) + state.bridgePendingToolCallMarkup = rest.slice(rest.length - pendingPrefixLength) + } else { + out += rest + } + break + } + + out += text.slice(idx, markerIdx) + const end = findToolMarkupEnd(text, markerIdx) + if (end < 0) { + state.bridgePendingToolCallMarkup = text.slice(markerIdx) + if (state.bridgePendingToolCallMarkup.length > MAX_PENDING_TOOL_MARKUP_LENGTH) { + state.bridgePendingToolCallMarkup = '' + } + break + } + + idx = end + if (text[idx] === '\r' && text[idx + 1] === '\n') { + idx += 2 + } else if (text[idx] === '\n') { + idx += 1 + } + } + + return out +} diff --git a/packages/server/src/services/hermes/run-chat/bridge-message.ts b/packages/server/src/services/hermes/run-chat/bridge-message.ts new file mode 100644 index 0000000..7c3497d --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/bridge-message.ts @@ -0,0 +1,203 @@ +/** + * Bridge message management — flush pending content to DB, + * track tool calls, manage assistant message lifecycle. + */ + +import { addMessage } from '../../../db/hermes/session-store' +import { logger } from '../../logger' +import type { SessionMessage, SessionState } from './types' + +export function flushBridgePendingToDb(state: SessionState, sessionId: string, runMarker?: string) { + const content = state.bridgePendingAssistantContent || '' + const reasoning = state.bridgePendingReasoningContent || '' + if (!content.trim()) return + if (runMarker) { + const last = findOpenBridgeAssistantMessage(state, runMarker) + if (last) syncBridgeReasoningToMessage(last, reasoning) + } + addMessage({ + session_id: sessionId, + role: 'assistant', + content, + reasoning: reasoning || null, + reasoning_content: reasoning || null, + timestamp: Math.floor(Date.now() / 1000), + }) + state.bridgePendingAssistantContent = '' + state.bridgePendingReasoningContent = '' + if (runMarker) { + const last = findOpenBridgeAssistantMessage(state, runMarker) + if (last && last.finish_reason == null) last.finish_reason = 'stop' + } +} + +export function findOpenBridgeAssistantMessage(state: SessionState, runMarker: string): SessionMessage | undefined { + return [...state.messages] + .reverse() + .find(m => m.runMarker === runMarker && m.role === 'assistant' && m.finish_reason == null) +} + +export function ensureOpenBridgeAssistantMessage( + state: SessionState, + sessionId: string, + runMarker: string, +): SessionMessage { + const existing = findOpenBridgeAssistantMessage(state, runMarker) + if (existing) return existing + const message: SessionMessage = { + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: '', + timestamp: Math.floor(Date.now() / 1000), + } + state.messages.push(message) + return message +} + +export function syncBridgeReasoningToMessage(message: SessionMessage, reasoning?: string) { + if (!reasoning) return + message.reasoning = reasoning + message.reasoning_content = reasoning +} + +export function recordBridgeToolStarted( + state: SessionState, + sessionId: string, + runMarker: string, + toolName: string, + args: Record | undefined, + rawToolCallId: unknown, +): { id: string; name: string; arguments: string } { + const id = bridgeToolCallId(state, rawToolCallId, toolName) + const argsString = args ? JSON.stringify(args) : '{}' + const reasoning = state.bridgePendingReasoningContent || '' + const toolCall = { + id, + type: 'function', + function: { + name: toolName, + arguments: argsString, + }, + } + const timestamp = Math.floor(Date.now() / 1000) + + state.bridgePendingTools = state.bridgePendingTools || [] + state.bridgePendingTools.push({ + id, + name: toolName, + arguments: argsString, + startedAt: Date.now(), + }) + + const openMessage = findOpenBridgeAssistantMessage(state, runMarker) + if (openMessage && !openMessage.content && !openMessage.tool_calls?.length) { + openMessage.tool_calls = [toolCall] + openMessage.finish_reason = 'tool_calls' + openMessage.reasoning = reasoning || openMessage.reasoning || null + openMessage.reasoning_content = reasoning || openMessage.reasoning_content || null + openMessage.timestamp = timestamp + } else { + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: '', + tool_calls: [toolCall], + finish_reason: 'tool_calls', + reasoning: reasoning || null, + reasoning_content: reasoning || null, + timestamp, + }) + } + addMessage({ + session_id: sessionId, + role: 'assistant', + content: '', + tool_calls: [toolCall], + finish_reason: 'tool_calls', + reasoning: reasoning || null, + reasoning_content: reasoning || null, + timestamp, + }) + state.bridgePendingReasoningContent = '' + + return { id, name: toolName, arguments: argsString } +} + +export function recordBridgeToolCompleted( + state: SessionState, + sessionId: string, + runMarker: string, + toolName: string, + ev: Record, +): { id: string; output: string; duration?: number } { + state.bridgePendingTools = state.bridgePendingTools || [] + const rawId = ev.tool_call_id + let idx = rawId + ? state.bridgePendingTools.findIndex(tool => tool.id === String(rawId)) + : -1 + if (idx < 0 && toolName) { + idx = state.bridgePendingTools.findIndex(tool => tool.name === toolName) + } + if (idx < 0) { + idx = state.bridgePendingTools.length - 1 + } + const pending = idx >= 0 ? state.bridgePendingTools.splice(idx, 1)[0] : undefined + const id = pending?.id || bridgeToolCallId(state, rawId, toolName) + const output = bridgeToolOutput(ev) + const timestamp = Math.floor(Date.now() / 1000) + logger.info( + '[chat-run-socket][bridge] recording CLI tool result session=%s tool=%s tool_call_id=%s raw_tool_call_id=%s output_len=%d has_result=%s has_output=%s has_result_preview=%s has_preview=%s event_keys=%s', + sessionId, + toolName, + id, + String(rawId || ''), + output.length, + String(ev.result != null), + String(ev.output != null), + String(ev.result_preview != null), + String(ev.preview != null), + Object.keys(ev).join(','), + ) + + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'tool', + content: output, + tool_call_id: id, + tool_name: toolName || pending?.name || null, + timestamp, + }) + addMessage({ + session_id: sessionId, + role: 'tool', + content: output, + tool_call_id: id, + tool_name: toolName || pending?.name || null, + timestamp, + }) + + const duration = pending?.startedAt + ? Math.round((Date.now() - pending.startedAt) / 10) / 100 + : undefined + + return { id, output, duration } +} + +export function bridgeToolCallId(state: SessionState, rawToolCallId: unknown, toolName: string): string { + const raw = String(rawToolCallId || '').trim() + if (raw) return raw + state.bridgeToolCounter = (state.bridgeToolCounter || 0) + 1 + const safeName = (toolName || 'tool').replace(/[^a-zA-Z0-9_-]/g, '_') + return `cli_${safeName}_${state.bridgeToolCounter}` +} + +export function bridgeToolOutput(ev: Record): string { + const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? '' + return typeof value === 'string' ? value : JSON.stringify(value ?? '') +} diff --git a/packages/server/src/services/hermes/run-chat/compression.ts b/packages/server/src/services/hermes/run-chat/compression.ts new file mode 100644 index 0000000..29e383d --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/compression.ts @@ -0,0 +1,568 @@ +/** + * Context compression — build conversation history from DB, + * apply snapshot-aware compression and LLM summarization. + */ + +import { + getSessionDetail, + getSession, +} from '../../../db/hermes/session-store' +import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot' +import { ChatContextCompressor, SUMMARY_PREFIX } from '../../../lib/context-compressor' +import { getModelContextLength } from '../model-context' +import { readConfigYamlForProfile } from '../../config-helpers' +import { logger } from '../../logger' +import { bridgeLogger } from '../../logger' +import { calcAndUpdateUsage, estimateUsageTokensFromMessages, updateMessageContextTokenUsage } from './usage' +import { isAssistantMessageSendable } from './message-format' +import type { ChatMessage, CompressionConfig as CompressorConfig } from '../../../lib/context-compressor' +import type { SessionState, BridgeCompressionResult } from './types' + +interface RunChatCompressionConfig { + enabled: boolean + triggerTokens: number + compressor: Partial +} + +export class ContextWindowTooSmallError extends Error { + constructor(message: string) { + super(message) + this.name = 'ContextWindowTooSmallError' + } +} + +function isContextWindowTooSmallError(err: unknown): err is ContextWindowTooSmallError { + return err instanceof ContextWindowTooSmallError || (err instanceof Error && err.name === 'ContextWindowTooSmallError') +} + +function isSnapshotUsable( + snapshot: { lastMessageIndex: number } | null, + history: ChatMessage[], +): boolean { + return !!snapshot && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < history.length +} + +function buildSnapshotHistory( + snapshot: { summary: string; lastMessageIndex: number } | null, + history: ChatMessage[], + compressionConfig?: Partial, +): ChatMessage[] | null { + if (!snapshot) return null + const headCount = compressionConfig?.headMessageCount || 0 + const tailCount = compressionConfig?.tailMessageCount || 0 + const protectedHead = headCount > 0 ? history.slice(0, headCount) : [] + const summaryMessage = { role: 'user', content: SUMMARY_PREFIX + '\n\n' + snapshot.summary } as ChatMessage + + if (isSnapshotUsable(snapshot, history)) { + return [ + ...protectedHead, + summaryMessage, + ...history.slice(snapshot.lastMessageIndex + 1), + ] + } + + const tailStart = Math.max(protectedHead.length, history.length - tailCount) + return [ + ...protectedHead, + summaryMessage, + ...history.slice(tailStart), + ] +} + +export async function buildSnapshotAwareHistory( + sessionId: string, + profile: string, + history: ChatMessage[], + modelContext: { model?: string | null; provider?: string | null } = {}, +): Promise { + const snapshot = getCompressionSnapshot(sessionId) + if (!snapshot) return history + const contextLength = getModelContextLength({ + profile, + model: modelContext.model, + provider: modelContext.provider, + }) + const compressionConfig = await getRunChatCompressionConfig(profile, contextLength) + return buildSnapshotHistory(snapshot, history, compressionConfig.compressor) || history +} + +function clampRatio(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback + return Math.min(max, Math.max(min, n)) +} + +function clampInt(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback + return Math.min(max, Math.max(min, n)) +} + +async function getRunChatCompressionConfig(profile: string, contextLength: number): Promise { + let raw: Record = {} + try { + raw = (await readConfigYamlForProfile(profile))?.compression || {} + } catch (err) { + logger.warn(err, '[context-compress] failed to read compression config for profile %s, using defaults', profile) + } + + const threshold = clampRatio(raw.threshold, 0.5, 0.05, 0.95) + const targetRatio = clampRatio(raw.target_ratio, 0.2, 0.01, 0.8) + const protectLastN = clampInt(raw.protect_last_n, 20, 0, 500) + const protectFirstN = clampInt(raw.protect_first_n, 3, 0, 100) + + return { + enabled: raw.enabled !== false, + triggerTokens: Math.floor(contextLength * threshold), + compressor: { + triggerTokens: Math.floor(contextLength * threshold), + summaryBudget: Math.max(1_000, Math.floor(contextLength * targetRatio)), + headMessageCount: protectFirstN, + tailMessageCount: protectLastN, + }, + } +} + +/** + * Load conversation history from DB with full message structure (user/assistant/tool). + */ +export async function buildDbHistory( + sessionId: string, + options: { excludeLastUser?: boolean } = {}, +): Promise { + const detail = getSessionDetail(sessionId) + if (!detail?.messages?.length) return [] + + const validMessages = detail.messages.filter(m => + (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined, + ) + + const sourceMessages = options.excludeLastUser + ? (() => { + const lastUserMsgIndex = [...validMessages].reverse().findIndex(m => m.role === 'user') + return lastUserMsgIndex >= 0 + ? validMessages.slice(0, validMessages.length - lastUserMsgIndex - 1) + : validMessages + })() + : validMessages + + return sourceMessages.map((m, idx, arr) => { + const msg: any = { role: m.role, content: m.content || '' } + if (m.reasoning_content) msg.reasoning_content = m.reasoning_content + if (m.tool_calls?.length) { + const cleanedToolCalls = m.tool_calls + .filter((tc: any) => tc.id && tc.id.length > 0) + .map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function })) + if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls + } + if (m.role === 'tool') { + let callId = m.tool_call_id + if (!callId || callId.length === 0) { + const prevMsg = arr[idx - 1] + if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) { + const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name) + if (tc?.id) callId = tc.id + } + } + if (!callId || callId.length === 0) return null + msg.tool_call_id = callId + } + if (m.tool_name) msg.name = m.tool_name + if (m.role === 'assistant' && !isAssistantMessageSendable(msg)) { + logger.warn('[chat-run-socket] skipped empty assistant message while building history for session %s', sessionId) + return null + } + return msg + }).filter((m): m is ChatMessage => m !== null) +} + +export function estimateSnapshotAwareHistoryUsage( + sessionId: string, + history: ChatMessage[], +): { messageCount: number; tokenCount: number } { + const snapshot = getCompressionSnapshot(sessionId) + const messages = buildSnapshotHistory(snapshot, history) || history + const usage = estimateUsageTokensFromMessages(messages) + return { + messageCount: messages.length, + tokenCount: usage.inputTokens + usage.outputTokens, + } +} + +export async function buildCompressedHistory( + sessionId: string, + profile: string, + upstream: string, + apiKey: string | undefined, + emit: (event: string, payload: any) => void, + sessionMap: Map, + modelContext: { model?: string | null; provider?: string | null } = {}, + contextTokenEstimator?: (messages: ChatMessage[], messageTokens: number) => Promise, + currentInputTokens = 0, +): Promise { + try { + let history = await buildDbHistory(sessionId, { excludeLastUser: true }) + + const contextLength = getModelContextLength({ + profile, + model: modelContext.model, + provider: modelContext.provider, + }) + const compressionConfig = await getRunChatCompressionConfig(profile, contextLength) + const triggerTokens = compressionConfig.triggerTokens + if (!compressionConfig.enabled) { + logger.info('[context-compress] session=%s: compression disabled by config', sessionId) + return history + } + const cState = getOrCreateSession(sessionMap, sessionId) + const assembledTokens = await calcAndUpdateUsage(sessionId, cState, emit) + const currentRunInputTokens = typeof currentInputTokens === 'number' && Number.isFinite(currentInputTokens) && currentInputTokens > 0 + ? Math.floor(currentInputTokens) + : 0 + const estimateLocalContextTokens = async (messages: ChatMessage[], messageTokens: number) => { + const localMessageTokens = Math.max(0, Math.floor(messageTokens)) + try { + const estimate = await contextTokenEstimator?.(messages, localMessageTokens) + if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) return Math.floor(estimate) + } catch (err) { + logger.warn(err, '[context-compress] session=%s: fixed context token estimate failed; using message-only estimate', sessionId) + } + return localMessageTokens + } + const emitContextUsage = (contextTokens: number) => { + cState.contextTokens = contextTokens + emit('usage.updated', { + event: 'usage.updated', + session_id: sessionId, + inputTokens: cState.inputTokens ?? assembledTokens.inputTokens, + outputTokens: cState.outputTokens ?? assembledTokens.outputTokens, + contextTokens, + }) + } + const messageOnlyTotalTokens = assembledTokens.inputTokens + assembledTokens.outputTokens + let totalTokens = messageOnlyTotalTokens + + if (history.length === 0) { + totalTokens = await estimateLocalContextTokens([], Math.max(currentRunInputTokens, messageOnlyTotalTokens)) + if (totalTokens > triggerTokens) { + throw new ContextWindowTooSmallError( + `Context window is too small: fixed prompt/tool overhead plus the current input uses ~${totalTokens} tokens, exceeding compression threshold ${triggerTokens}. Increase model context length, raise compression.threshold, shorten the input, or disable some tools.`, + ) + } + if (totalTokens > 0) emitContextUsage(totalTokens) + return [] + } + + const canCompressHistory = history.length > 4 + const snapshot = getCompressionSnapshot(sessionId) + const staleSnapshot = snapshot && !isSnapshotUsable(snapshot, history) + if (staleSnapshot) { + logger.warn('[context-compress] session=%s: stale snapshot index %d for %d history messages; using summary plus safe tail', + sessionId, snapshot.lastMessageIndex, history.length) + const staleHistory = buildSnapshotHistory(snapshot, history, compressionConfig.compressor) || history + const staleUsage = estimateUsageTokensFromMessages(staleHistory) + const staleMessageTokens = staleUsage.inputTokens + staleUsage.outputTokens + const staleRunMessageTokens = Math.max(staleMessageTokens + currentRunInputTokens, messageOnlyTotalTokens) + totalTokens = await estimateLocalContextTokens(staleHistory, staleRunMessageTokens) + emitContextUsage(totalTokens) + logger.info({ + sessionId, + profile, + messages: staleHistory.length, + messageOnlyTokens: staleRunMessageTokens, + fullContextTokens: totalTokens, + triggerTokens, + decision: totalTokens > triggerTokens ? 'compress' : 'skip', + snapshot: 'stale', + }, '[context-compress] threshold check') + } + + if (snapshot && !staleSnapshot) { + const newMessages = history.slice(snapshot.lastMessageIndex + 1) + const snapshotHistory = buildSnapshotHistory(snapshot, history, compressionConfig.compressor) || history + const snapshotUsage = estimateUsageTokensFromMessages(snapshotHistory) + const snapshotMessageTokens = snapshotUsage.inputTokens + snapshotUsage.outputTokens + const snapshotRunMessageTokens = Math.max(snapshotMessageTokens + currentRunInputTokens, messageOnlyTotalTokens) + totalTokens = await estimateLocalContextTokens(snapshotHistory, snapshotRunMessageTokens) + emitContextUsage(totalTokens) + logger.info({ + sessionId, + profile, + messages: snapshotHistory.length, + messageOnlyTokens: snapshotRunMessageTokens, + fullContextTokens: totalTokens, + triggerTokens, + decision: totalTokens > triggerTokens ? 'compress' : 'skip', + snapshot: 'usable', + }, '[context-compress] threshold check') + logger.info('[context-compress] session=%s: snapshot at %d, %d new messages, assembled ~%d tokens (threshold %d)', + sessionId, snapshot.lastMessageIndex, newMessages.length, totalTokens, triggerTokens) + if (totalTokens <= triggerTokens) { + history = snapshotHistory + } else { + history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext, compressionConfig.compressor, currentRunInputTokens) + } + } else if (snapshot && staleSnapshot) { + if (totalTokens <= triggerTokens) { + history = buildSnapshotHistory(snapshot, history, compressionConfig.compressor) || history + } else { + history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext, compressionConfig.compressor, currentRunInputTokens) + } + } else { + const historyUsage = estimateUsageTokensFromMessages(history) + const historyMessageTokens = historyUsage.inputTokens + historyUsage.outputTokens + const runMessageTokens = Math.max(historyMessageTokens + currentRunInputTokens, messageOnlyTotalTokens) + totalTokens = await estimateLocalContextTokens(history, runMessageTokens) + emitContextUsage(totalTokens) + logger.info({ + sessionId, + profile, + messages: history.length, + messageOnlyTokens: runMessageTokens, + fullContextTokens: totalTokens, + triggerTokens, + decision: totalTokens > triggerTokens ? 'compress' : 'skip', + snapshot: 'none', + }, '[context-compress] threshold check') + if (!canCompressHistory && totalTokens > triggerTokens) { + throw new ContextWindowTooSmallError( + `Context window is too small: fixed prompt/tool overhead plus ${history.length} history messages uses ~${totalTokens} tokens, exceeding compression threshold ${triggerTokens}, and there is not enough history to compress. Increase model context length, raise compression.threshold, or disable some tools.`, + ) + } + if (totalTokens <= triggerTokens) { + logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', sessionId, history.length, totalTokens) + } else { + history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext, compressionConfig.compressor, currentRunInputTokens) + } + } + return history + } catch (err) { + if (isContextWindowTooSmallError(err)) throw err + logger.warn(err, '[chat-run-socket] failed to build compressed history for session %s', sessionId) + return [] + } +} + +export async function compressHistory( + history: ChatMessage[], + newMessagesOnly: ChatMessage[] | null, + sessionId: string, + upstream: string, + apiKey: string | undefined, + cState: SessionState, + totalTokens: number, + emit: (event: string, payload: any) => void, + sessionMap: Map, + modelContext: { model?: string | null; provider?: string | null } = {}, + compressionConfig?: Partial, + currentInputTokens = 0, +): Promise { + const msgCount = newMessagesOnly ? newMessagesOnly.length : history.length + const currentRunInputTokens = typeof currentInputTokens === 'number' && Number.isFinite(currentInputTokens) && currentInputTokens > 0 + ? Math.floor(currentInputTokens) + : 0 + pushState(sessionMap, sessionId, 'compression.started', { + event: 'compression.started', message_count: msgCount, token_count: totalTokens, + }) + emit('compression.started', { + event: 'compression.started', message_count: msgCount, token_count: totalTokens, + }) + + try { + const session = getSession(sessionId) + const summarizerProfile = session?.profile || 'default' + const compressor = new ChatContextCompressor({ config: compressionConfig }) + const result = await compressor.compress(history, upstream, apiKey, sessionId, { + profile: summarizerProfile, + model: modelContext.model || session?.model, + provider: modelContext.provider || session?.provider, + workerKey: `${summarizerProfile}:compression:${sessionId}`, + }) + const afterTokens = await calcAndUpdateUsage(sessionId, cState, emit) + const compressedAfterTokens = afterTokens.inputTokens + afterTokens.outputTokens + const resultUsage = estimateUsageTokensFromMessages(result.messages) + const resultMessageTokens = resultUsage.inputTokens + resultUsage.outputTokens + const compressedRunMessageTokens = Math.max( + compressedAfterTokens, + resultMessageTokens + currentRunInputTokens, + ) + const compressedMeta: any = { + event: 'compression.completed' as const, + compressed: result.meta.compressed, + llmCompressed: result.meta.llmCompressed, + totalMessages: result.meta.totalMessages, + resultMessages: result.messages.length, + beforeTokens: totalTokens, + afterTokens: compressedRunMessageTokens, + summaryTokens: result.meta.summaryTokenEstimate, + verbatimCount: result.meta.verbatimCount, + compressedStartIndex: result.meta.compressedStartIndex, + } + replaceState(sessionMap, sessionId, 'compression.completed', compressedMeta) + logger.info('[context-compress] AFTER session=%s: %d messages, ~%d tokens (was %d)', + sessionId, result.messages.length, compressedRunMessageTokens, totalTokens) + const compressedContextTokens = updateMessageContextTokenUsage(sessionId, cState, emit, compressedRunMessageTokens, afterTokens) + if (compressedContextTokens != null) { + compressedMeta.contextTokens = compressedContextTokens + } + emit('compression.completed', compressedMeta) + + const compressed = result.messages.map(m => { + const msg: any = { role: m.role, content: m.content, tool_call_id: m.tool_call_id, name: m.name } + if (m.reasoning_content) msg.reasoning_content = m.reasoning_content + if (m.tool_calls?.length) { + const cleanedToolCalls = m.tool_calls + .filter((tc: any) => tc.id && tc.id.length > 0) + .map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function })) + if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls + } + return msg + }) + await calcAndUpdateUsage(sessionId, cState, emit) + return compressed + } catch (err: any) { + const failedMeta = { + event: 'compression.completed' as const, + compressed: false, + totalMessages: msgCount, + resultMessages: msgCount, + beforeTokens: totalTokens, + afterTokens: totalTokens, + contextTokens: totalTokens, + summaryTokens: 0, + verbatimCount: msgCount, + compressedStartIndex: -1, + error: err.message, + } + replaceState(sessionMap, sessionId, 'compression.completed', failedMeta) + logger.warn(err, '[chat-run-socket] compression failed for session %s, using assembled context', sessionId) + emit('compression.completed', failedMeta) + return history + } +} + +export async function forceCompressBridgeHistory( + sessionId: string, + profile: string, + _messages: ChatMessage[], + beforeTokenOverride?: number | null, +): Promise { + const history = await buildDbHistory(sessionId, { excludeLastUser: true }) + + if (history.length === 0) { + return { + messages: [], + beforeMessages: 0, + resultMessages: 0, + beforeTokens: 0, + afterTokens: 0, + compressed: false, + llmCompressed: false, + summaryTokens: 0, + verbatimCount: 0, + compressedStartIndex: -1, + } + } + + const upstream = '' + const apiKey = undefined + const session = getSession(sessionId) + const contextLength = getModelContextLength({ profile, model: session?.model, provider: session?.provider }) + const compressionConfig = await getRunChatCompressionConfig(session?.profile || profile, contextLength) + const beforeUsage = estimateSnapshotAwareHistoryUsage(sessionId, history) + const totalTokens = typeof beforeTokenOverride === 'number' && Number.isFinite(beforeTokenOverride) && beforeTokenOverride > 0 + ? Math.floor(beforeTokenOverride) + : beforeUsage.tokenCount + bridgeLogger.info({ + sessionId, + profile, + historyMessages: history.length, + snapshotAwareMessages: beforeUsage.messageCount, + bridgeProvidedMessages: Array.isArray(_messages) ? _messages.length : 0, + tokenEstimate: totalTokens, + snapshotAware: true, + }, '[chat-run-socket] bridge forced compression started') + + const compressor = new ChatContextCompressor({ config: compressionConfig.compressor }) + const summarizerProfile = session?.profile || profile || 'default' + const result = await compressor.compress(history, upstream, apiKey, sessionId, { + profile: summarizerProfile, + model: session?.model, + provider: session?.provider, + workerKey: `${summarizerProfile}:compression:${sessionId}`, + }) + const compressedMessages = result.messages.map(m => { + const msg: any = { role: m.role, content: m.content } + if (m.reasoning_content) msg.reasoning_content = m.reasoning_content + if (m.tool_calls?.length) { + const cleanedToolCalls = m.tool_calls + .filter((tc: any) => tc.id && tc.id.length > 0) + .map((tc: any) => ({ id: tc.id, type: tc.type, function: tc.function })) + if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls + } + if (m.tool_call_id) msg.tool_call_id = m.tool_call_id + if (m.name) msg.name = m.name + return msg + }) + const afterUsage = estimateUsageTokensFromMessages(compressedMessages) + const afterTokens = afterUsage.inputTokens + afterUsage.outputTokens + bridgeLogger.info({ + sessionId, + profile, + beforeMessages: history.length, + resultMessages: result.messages.length, + beforeTokens: totalTokens, + afterTokens, + compressed: result.meta.compressed, + llmCompressed: result.meta.llmCompressed, + verbatimCount: result.meta.verbatimCount, + compressedStartIndex: result.meta.compressedStartIndex, + compressedHistory: result.messages.map((m) => ({ + role: m.role, + content: m.content, + reasoning_content: m.reasoning_content, + tool_calls: m.tool_calls, + tool_call_id: m.tool_call_id, + name: m.name, + })), + }, '[chat-run-socket] bridge forced compression completed') + + return { + messages: compressedMessages, + beforeMessages: history.length, + resultMessages: compressedMessages.length, + beforeTokens: totalTokens, + afterTokens, + compressed: result.meta.compressed, + llmCompressed: result.meta.llmCompressed, + summaryTokens: result.meta.summaryTokenEstimate, + verbatimCount: result.meta.verbatimCount, + compressedStartIndex: result.meta.compressedStartIndex, + } +} + +// --- Shared state helpers (used by compression) --- + +export function getOrCreateSession(sessionMap: Map, sessionId: string): SessionState { + let state = sessionMap.get(sessionId) + if (!state) { + state = { messages: [], isWorking: false, events: [], queue: [] } + sessionMap.set(sessionId, state) + } + return state +} + +export function pushState(sessionMap: Map, sessionId: string, event: string, data: any) { + const state = getOrCreateSession(sessionMap, sessionId) + state.events.push({ event, data }) +} + +export function replaceState(sessionMap: Map, sessionId: string, event: string, data: any) { + const state = sessionMap.get(sessionId) + if (state) { + const idx = state.events.findIndex(s => s.event === event) + if (idx >= 0) { + state.events[idx] = { event, data } + return + } + } + pushState(sessionMap, sessionId, event, data) +} diff --git a/packages/server/src/services/hermes/run-chat/content-blocks.ts b/packages/server/src/services/hermes/run-chat/content-blocks.ts new file mode 100644 index 0000000..18fa4fd --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/content-blocks.ts @@ -0,0 +1,97 @@ +import type { ContentBlock } from './types' + +type ResponseContentPart = { type: string; text?: string; image_url?: string } +type AgentContentPart = { type: string; text?: string; image_url?: { url: string } } + +/** + * Convert ContentBlock[] to string for display/storage + */ +export function contentBlocksToString(input: string | ContentBlock[]): string { + if (typeof input === 'string') return input + return JSON.stringify(input) +} + +/** + * Extract text content from ContentBlock[] for title preview + */ +export function extractTextForPreview(input: string | ContentBlock[]): string { + if (typeof input === 'string') return input + return input + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +/** + * Check if input is ContentBlock array + */ +export function isContentBlockArray(input: any): input is ContentBlock[] { + return Array.isArray(input) && input.length > 0 && ('type' in input[0]) +} + +/** + * Convert ContentBlock[] to multimodal format for /v1/responses API. + */ +export async function convertContentBlocks(blocks: ContentBlock[]): Promise { + const parts: ResponseContentPart[] = [] + for (const block of blocks) { + if (block.type === 'text') { + parts.push({ type: 'input_text', text: block.text }) + } else if (block.type === 'image') { + const dataUri = await imageBlockToDataUri(block) + if (dataUri) { + parts.push({ type: 'input_image', image_url: dataUri }) + } else { + parts.push({ type: 'input_text', text: `[Image: ${block.path}]` }) + } + } else if (block.type === 'file') { + parts.push({ type: 'input_text', text: `[File: ${block.name || block.path}]` }) + } + } + + return parts +} + +/** + * Convert ContentBlock[] to the normalized multimodal shape Hermes agent + * receives after /v1/responses input normalization. + */ +export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise { + const parts: AgentContentPart[] = [] + for (const block of blocks) { + if (block.type === 'text') { + parts.push({ type: 'text', text: block.text || '' }) + } else if (block.type === 'image') { + parts.push({ + type: 'text', + text: `[Attached image: ${block.name || block.path}]\nLocal image path for tools: ${block.path}`, + }) + const dataUri = await imageBlockToDataUri(block) + if (dataUri) { + parts.push({ type: 'image_url', image_url: { url: dataUri } }) + } + } else if (block.type === 'file') { + parts.push({ + type: 'text', + text: `[Attached file: ${block.name || block.path}]\nLocal file path for tools: ${block.path}`, + }) + } + } + return parts +} + +async function imageBlockToDataUri(block: Extract): Promise { + try { + const fs = await import('fs/promises') + const path = await import('path') + const buf = await fs.readFile(block.path) + const ext = path.extname(block.path).toLowerCase().replace('.', '') + const mimeFromExt = ext === 'jpg' ? 'jpeg' : ext || 'png' + const mime = block.media_type?.startsWith('image/') + ? block.media_type.slice('image/'.length) + : mimeFromExt + return `data:image/${mime};base64,${buf.toString('base64')}` + } catch { + return null + } +} diff --git a/packages/server/src/services/hermes/run-chat/handle-api-run.ts b/packages/server/src/services/hermes/run-chat/handle-api-run.ts new file mode 100644 index 0000000..5573001 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/handle-api-run.ts @@ -0,0 +1,417 @@ +/** + * API Server run handler — handles runs that stream from upstream /v1/responses. + */ + +import type { Server, Socket } from 'socket.io' +import { getSystemPrompt } from '../../../lib/llm-prompt' +import { + getSession, + createSession, + addMessage, + updateSessionStats, + getSessionDetailPaginated, +} from '../../../db/hermes/session-store' +import { updateUsage } from '../../../db/hermes/usage-store' +import { logger } from '../../logger' +import { contentBlocksToString, extractTextForPreview, isContentBlockArray, convertContentBlocks } from './content-blocks' +import { convertHistoryFormat } from './message-format' +import { readSseFrames } from './sse-utils' +import { extractResponseText } from './response-utils' +import { applyResponseStreamEvent, flushResponseRunToDb } from './response-stream' +import { buildCompressedHistory, buildDbHistory, buildSnapshotAwareHistory, getOrCreateSession } from './compression' +import { calcAndUpdateUsage, estimateUsageTokensFromMessages } from './usage' +import { handleMessage } from './message-format' +import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' +import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot' +import type { ContentBlock, SessionState, ChatRunSource } from './types' + +export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource { + return 'cli' +} + +export async function loadSessionStateFromDb(sid: string, _sessionMap: Map): Promise { + try { + const actualDetail = getSessionDetailPaginated(sid) + + const messages = actualDetail?.messages ? handleMessage(actualDetail.messages, sid) : [] + + let inputTokens: number + let outputTokens: number + let contextTokens: number | undefined + const snapshot = getCompressionSnapshot(sid) + if (snapshot && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < messages.length) { + const newMessages = messages.slice(snapshot.lastMessageIndex + 1) + const newUsage = estimateUsageTokensFromMessages(newMessages) + inputTokens = countTokens(SUMMARY_PREFIX + snapshot.summary) + + newUsage.inputTokens + outputTokens = newUsage.outputTokens + } else { + const usage = estimateUsageTokensFromMessages(messages) + inputTokens = usage.inputTokens + outputTokens = usage.outputTokens + } + try { + const session = getSession(sid) + const dbHistory = await buildDbHistory(sid, { excludeLastUser: false }) + const snapshotHistory = await buildSnapshotAwareHistory( + sid, + session?.profile || 'default', + dbHistory, + { model: session?.model, provider: session?.provider }, + ) + const contextUsage = estimateUsageTokensFromMessages(snapshotHistory) + contextTokens = contextUsage.inputTokens + contextUsage.outputTokens + } catch (err) { + logger.warn(err, '[chat-run-socket] failed to calculate snapshot-aware context tokens for session %s', sid) + } + + logger.info('[chat-run-socket] loaded session %s from DB (%d messages)', sid, messages.length) + return { + messages, + messageTotal: actualDetail?.total || messages.length, + messageLoadedCount: actualDetail?.messages.length || messages.length, + messagePageLimit: actualDetail?.limit, + hasMoreBefore: actualDetail?.hasMore || false, + isWorking: false, + events: [], + inputTokens, + outputTokens, + contextTokens, + queue: [], + } + } catch (err) { + logger.warn(err, '[chat-run-socket] failed to load session %s from DB', sid) + return { messages: [], isWorking: false, events: [], queue: [] } + } +} + +export async function handleApiRun( + nsp: ReturnType, + socket: Socket, + data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; instructions?: string; source?: string; queue_id?: string; peerExcludeSocketId?: string }, + profile: string, + sessionMap: Map, + skipUserMessage = false, + dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, +) { + const { input, session_id, model, provider, instructions } = data + + // Build full instructions with system prompt + workspace context + let fullInstructions = instructions + ? `${getSystemPrompt()}\n${instructions}` + : getSystemPrompt() + if (session_id) { + const sessionRow = getSession(session_id) + if (sessionRow?.workspace) { + const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]` + fullInstructions = `\n${workspaceCtx}\n${fullInstructions}` + } + } + + const upstream = '' + const apiKey = undefined + + const runMarker = session_id + ? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + : undefined + + const now = Math.floor(Date.now() / 1000) + if (session_id) { + let state = sessionMap.get(session_id) + if (!state) { + state = getSession(session_id) + ? await loadSessionStateFromDb(session_id, sessionMap) + : { messages: [], isWorking: false, events: [], queue: [] } + sessionMap.set(session_id, state) + } + state.isWorking = true + state.events = [] + state.profile = profile + state.source = 'api_server' + state.activeRunMarker = runMarker + + let peerUserMessage: { id?: number; role: 'user'; content: string; timestamp: number } | null = null + if (!skipUserMessage) { + const inputStr = contentBlocksToString(input) + state.messages.push({ + id: state.messages.length + 1, + session_id, + runMarker, + role: 'user', + content: inputStr, + timestamp: now, + }) + + if (!getSession(session_id)) { + const previewText = extractTextForPreview(input) + const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) + createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) + } + + const messageId = addMessage({ + session_id, + role: 'user', + content: inputStr, + timestamp: now, + }) + peerUserMessage = { id: data.queue_id ? undefined : messageId, role: 'user', content: inputStr, timestamp: now } + } else { + const inputStr = contentBlocksToString(input) + state.messages.push({ + id: state.messages.length + 1, + session_id, + runMarker, + role: 'user', + content: inputStr, + timestamp: now, + }) + if (!getSession(session_id)) { + const previewText = extractTextForPreview(input) + const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) + createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) + } + const messageId = addMessage({ + session_id, + role: 'user', + content: inputStr, + timestamp: now, + }) + peerUserMessage = { id: data.queue_id ? undefined : messageId, role: 'user', content: inputStr, timestamp: now } + } + + socket.join(`session:${session_id}`) + if (peerUserMessage) { + const target = data.peerExcludeSocketId + ? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId) + : socket.to(`session:${session_id}`) + target.emit('run.peer_user_message', { + event: 'run.peer_user_message', + session_id, + message: { + ...peerUserMessage, + id: data.queue_id || peerUserMessage.id, + }, + }) + } + } + + const emit = (event: string, payload: any) => { + const tagged = session_id ? { ...payload, session_id } : payload + if (session_id) { + nsp.to(`session:${session_id}`).emit(event, tagged) + } else if (socket.connected) { + socket.emit(event, tagged) + } + } + try { + const body: Record = { input } + if (model) body.model = model + body.instructions = fullInstructions + if (session_id) { + const sessionRow = getSession(session_id) + const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap, { + model: sessionRow?.model || model, + provider: sessionRow?.provider || provider, + }) + if (compressed.length > 0) { + body.conversation_history = compressed + } + } + + const headers: Record = { 'Content-Type': 'application/json' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + if (isContentBlockArray(input)) { + const parts = await convertContentBlocks(input) + body.input = [{ role: 'user', content: parts }] + } + + if (body.conversation_history && Array.isArray(body.conversation_history)) { + body.conversation_history = convertHistoryFormat(body.conversation_history) + } + body.stream = true + body.store = false + + const abortController = new AbortController() + if (session_id) { + const state = getOrCreateSession(sessionMap, session_id) + state.isWorking = true + state.runId = undefined + state.abortController = abortController + } + + const res = await fetch(`${upstream}/v1/responses`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: abortController.signal, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + const queueLen = session_id ? sessionMap.get(session_id)?.queue?.length ?? 0 : 0 + if (session_id) await markApiCompleted(nsp, socket, session_id, sessionMap, { event: 'run.failed' }) + emit('run.failed', { event: 'run.failed', error: `Upstream ${res.status}: ${text}`, queue_remaining: queueLen }) + if (session_id && queueLen > 0) dequeueNextQueuedRun(socket, session_id) + return + } + if (!res.body) { + const queueLen = session_id ? sessionMap.get(session_id)?.queue?.length ?? 0 : 0 + if (session_id) await markApiCompleted(nsp, socket, session_id, sessionMap, { event: 'run.failed' }) + emit('run.failed', { event: 'run.failed', error: 'Upstream response stream missing', queue_remaining: queueLen }) + if (session_id && queueLen > 0) dequeueNextQueuedRun(socket, session_id) + return + } + + let responseId: string | undefined + for await (const frame of readSseFrames(res.body)) { + let parsed: any + try { + parsed = JSON.parse(frame.data) + } catch { + continue + } + const upstreamEvent = parsed.type || frame.event || parsed.event + logger.info('[chat-run-socket] upstream response event: %s', upstreamEvent) + + if (session_id) { + const state = sessionMap.get(session_id) + if (state) { + const mapped = applyResponseStreamEvent(state, session_id, runMarker, upstreamEvent, parsed) + if (mapped) { + if (mapped.runId) { + responseId = mapped.runId + state.runId = responseId + } + emit(mapped.event, mapped.payload) + } + } + } + + if (upstreamEvent === 'response.completed' || upstreamEvent === 'response.failed') { + if (session_id && sessionMap.get(session_id)?.activeRunMarker !== runMarker) { + logger.info({ + sessionId: session_id, + runId: responseId, + event: upstreamEvent, + }, '[chat-run-socket] suppressing stale API terminal event') + return + } + if (session_id && sessionMap.get(session_id)?.isAborting) { + logger.info({ + sessionId: session_id, + runId: responseId, + event: upstreamEvent, + }, '[chat-run-socket][abort] suppressing upstream terminal event during abort') + return + } + const queueLen = session_id ? sessionMap.get(session_id)?.queue?.length ?? 0 : 0 + const nextQueuedRun = session_id && queueLen > 0 + ? sessionMap.get(session_id)?.queue?.[0] + : undefined + if (session_id) await markApiCompleted(nsp, socket, session_id, sessionMap, { + event: upstreamEvent === 'response.completed' ? 'run.completed' : 'run.failed', + run_id: responseId, + keepWorking: Boolean(nextQueuedRun), + nextSource: nextQueuedRun?.source, + }) + const finalOutput = parsed.response || parsed + const finalText = extractResponseText(finalOutput) + if (upstreamEvent === 'response.completed' && session_id) { + const usage = finalOutput.usage || {} + updateUsage(session_id, { + inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0, + outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0, + cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0, + cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0, + reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0, + model: finalOutput.model || '', + profile: sessionMap.get(session_id)?.profile, + }) + } + const eventName = upstreamEvent === 'response.completed' ? 'run.completed' : 'run.failed' + emit(eventName, { + event: eventName, + run_id: responseId || finalOutput.id, + response_id: responseId || finalOutput.id, + output: finalText, + usage: finalOutput.usage, + error: finalOutput.error || parsed.error, + queue_remaining: queueLen, + }) + if (session_id && queueLen > 0) dequeueNextQueuedRun(socket, session_id) + return + } + } + const queueLen = session_id ? sessionMap.get(session_id)?.queue?.length ?? 0 : 0 + if (session_id && sessionMap.get(session_id)?.activeRunMarker !== runMarker) { + logger.info({ + sessionId: session_id, + runId: responseId, + }, '[chat-run-socket] suppressing stale API stream end') + return + } + if (session_id) await markApiCompleted(nsp, socket, session_id, sessionMap, { event: 'run.failed', run_id: responseId }) + emit('run.failed', { + event: 'run.failed', + run_id: responseId, + response_id: responseId, + error: 'Response stream ended without a terminal event', + queue_remaining: queueLen, + }) + if (session_id && queueLen > 0) dequeueNextQueuedRun(socket, session_id) + } catch (err: any) { + const queueLen = session_id ? sessionMap.get(session_id)?.queue?.length ?? 0 : 0 + if (session_id) { + const state = sessionMap.get(session_id) + if (state?.activeRunMarker !== runMarker || err?.name === 'AbortError') { + logger.info({ + sessionId: session_id, + runMarker, + error: err?.message || String(err), + }, '[chat-run-socket] suppressing stale/aborted API stream error') + return + } + void markApiCompleted(nsp, socket, session_id, sessionMap, { event: 'run.failed' }).then(() => { + emit('run.failed', { event: 'run.failed', error: err.message, queue_remaining: queueLen }) + if (queueLen > 0) dequeueNextQueuedRun(socket, session_id) + }) + } else { + emit('run.failed', { event: 'run.failed', error: err.message }) + } + } +} + +async function markApiCompleted( + nsp: ReturnType, + _socket: Socket, + sessionId: string, + sessionMap: Map, + info: { event: string; run_id?: string; keepWorking?: boolean; nextSource?: ChatRunSource }, +) { + const state = sessionMap.get(sessionId) + if (state) { + if (state.isAborting) { + logger.info({ + sessionId, + runId: state.runId, + }, '[chat-run-socket][abort] terminal upstream event observed; abort handler will finish cleanup') + return + } + state.isWorking = Boolean(info.keepWorking) + state.abortController = undefined + state.runId = undefined + state.events = [] + flushResponseRunToDb(state, sessionId) + state.responseRun = undefined + state.activeRunMarker = undefined + if (info.keepWorking) { + state.source = info.nextSource + } else { + state.profile = undefined + } + updateSessionStats(sessionId) + const emit = (event: string, payload: any) => { + nsp.to(`session:${sessionId}`).emit(event, { ...payload, session_id: sessionId }) + } + await calcAndUpdateUsage(sessionId, state, emit) + } +} diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts new file mode 100644 index 0000000..24b04a9 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -0,0 +1,1136 @@ +/** + * CLI Bridge run handler — handles runs that use the agent bridge + * to communicate with Hermes CLI agent. + */ + +import type { Server, Socket } from 'socket.io' +import { getSystemPrompt } from '../../../lib/llm-prompt' +import { getSession, createSession, addMessage, updateSession, updateSessionStats } from '../../../db/hermes/session-store' +import { updateUsage } from '../../../db/hermes/usage-store' +import { logger, bridgeLogger } from '../../logger' +import { AgentBridgeClient, type AgentBridgeContextEstimate, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge' +import { contentBlocksToString, convertContentBlocksForAgent, extractTextForPreview, isContentBlockArray } from './content-blocks' +import { buildCompressedHistory, buildDbHistory, buildSnapshotAwareHistory, forceCompressBridgeHistory, pushState, replaceState } from './compression' +import { + calcAndUpdateUsage, + contextTokensWithCachedOverhead, + estimateUsageTokensFromMessages, + getCachedBridgeContextOverhead, + updateMessageContextTokenUsage, +} from './usage' +import { + flushBridgePendingToDb, + ensureOpenBridgeAssistantMessage, + syncBridgeReasoningToMessage, + recordBridgeToolStarted, + recordBridgeToolCompleted, +} from './bridge-message' +import { summarizeToolArguments } from './response-utils' +import type { ContentBlock, QueuedRun, SessionState } from './types' +import type { ChatMessage } from '../../../lib/context-compressor' +import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config' +import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } from './bridge-delta' + +const BRIDGE_USAGE_FLUSH_DELAY_MS = 200 + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function looksLikeAgentFailure(value: string): boolean { + return /\bAPI call failed after\b/i.test(value) + || /\bHTTP\s+(?:4\d\d|5\d\d)\b/i.test(value) + || /\b(?:401|403|429|500|502|503|504)\b/.test(value) && /\b(?:unauthorized|forbidden|rate limit|unavailable|failed|error)\b/i.test(value) +} + +export function bridgeTerminalError(chunk: Pick): string | null { + const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result) + ? chunk.result as Record + : null + const resultError = result + ? stringValue(result.error) + || stringValue(result.exception) + || stringValue(result.message) + : '' + const finalResponse = result ? stringValue(result.final_response) : '' + + if (chunk.status === 'error') { + return stringValue(chunk.error) || resultError || finalResponse || 'Agent run failed' + } + + if (result?.failed === true || result?.completed === false) { + return resultError || finalResponse || 'Agent reported failure' + } + + if (resultError) return resultError + if (finalResponse && looksLikeAgentFailure(finalResponse)) return finalResponse + + return null +} + +function findOpenAssistantMessage(state: SessionState, runMarker: string) { + for (let i = state.messages.length - 1; i >= 0; i -= 1) { + const message = state.messages[i] + if (message.runMarker === runMarker && message.role === 'assistant' && message.finish_reason == null) return message + } + return undefined +} + +function flushPendingToolMarkupToAssistant( + state: SessionState, + runMarker: string, + runId: string, + emit: (event: string, payload: any) => void, +): string { + const pendingMarkup = flushPendingToolCallMarkup(state) + if (!pendingMarkup) return '' + + state.bridgeOutput = (state.bridgeOutput || '') + pendingMarkup + state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + pendingMarkup + const last = findOpenAssistantMessage(state, runMarker) + if (last) { + last.content += pendingMarkup + } + emit('message.delta', { + event: 'message.delta', + run_id: runId, + delta: pendingMarkup, + output: state.bridgeOutput, + }) + return pendingMarkup +} + +function processBridgeTextDelta( + state: SessionState, + sessionId: string, + runMarker: string, + runId: string, + rawDelta: string, + emit: (event: string, payload: any) => void, +): void { + const delta = filterBridgeToolCallMarkupDelta(state, rawDelta) + if (!delta) return + state.bridgeOutput = (state.bridgeOutput || '') + delta + state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta + const last = [...state.messages].reverse().find(m => m.runMarker === runMarker) + if (last?.role === 'assistant' && last.finish_reason == null) { + last.content += delta + syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent) + } else { + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: delta, + reasoning: state.bridgePendingReasoningContent || null, + reasoning_content: state.bridgePendingReasoningContent || null, + timestamp: Math.floor(Date.now() / 1000), + }) + } + emit('message.delta', { + event: 'message.delta', + run_id: runId, + delta, + output: state.bridgeOutput, + }) +} + +function finiteToken(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined +} + +function cacheBridgeContext(state: SessionState, data: Record | AgentBridgeContextEstimate) { + const fixedContextTokens = finiteToken(data.fixed_context_tokens) + if (fixedContextTokens == null) return + state.bridgeContext = { + fixedContextTokens, + systemPromptTokens: finiteToken(data.system_prompt_tokens), + toolTokens: finiteToken(data.tool_tokens), + systemPromptChars: finiteToken(data.system_prompt_chars), + toolCount: finiteToken(data.tool_count), + toolNames: Array.isArray(data.tool_names) ? data.tool_names.map(String) : undefined, + profile: typeof data.profile === 'string' ? data.profile : state.bridgeContext?.profile, + model: typeof data.model === 'string' ? data.model : state.bridgeContext?.model, + provider: typeof data.provider === 'string' ? data.provider : state.bridgeContext?.provider, + } +} + +function bridgeContextMatches( + state: SessionState, + expected: { profile: string; model?: string | null; provider?: string | null }, +): boolean { + const context = state.bridgeContext + if (!context) return false + if (context.profile && context.profile !== expected.profile) return false + if (expected.model && context.model && context.model !== expected.model) return false + if (expected.provider && context.provider && context.provider !== expected.provider) return false + return true +} + +async function ensureBridgeFixedContext(args: { + sessionId: string + profile: string + model?: string | null + provider?: string | null + instructions: string + state: SessionState + bridge: AgentBridgeClient + refresh?: boolean +}): Promise { + const cached = bridgeContextMatches(args.state, args) + ? getCachedBridgeContextOverhead(args.state) + : undefined + if (!args.refresh && cached != null) return cached + + try { + const estimate = await args.bridge.contextEstimate( + args.sessionId, + [], + args.instructions, + args.profile, + { model: args.model ?? undefined, provider: args.provider ?? undefined }, + ) + cacheBridgeContext(args.state, estimate) + const fixedContextTokens = getCachedBridgeContextOverhead(args.state) + bridgeLogger.info({ + sessionId: args.sessionId, + profile: args.profile, + model: args.model, + provider: args.provider, + toolCount: estimate.tool_count, + systemPromptChars: estimate.system_prompt_chars, + fixedContextTokens, + }, '[chat-run-socket] fixed context estimate') + return fixedContextTokens + } catch (err) { + bridgeLogger.warn({ + err: err instanceof Error ? { message: err.message, name: err.name } : err, + sessionId: args.sessionId, + profile: args.profile, + model: args.model, + provider: args.provider, + cachedFixedContextTokens: cached, + }, '[chat-run-socket] fixed context estimate failed') + return cached + } +} + +export async function handleBridgeRun( + nsp: ReturnType, + socket: Socket, + data: { input: string | ContentBlock[]; display_input?: string | ContentBlock[] | null; display_role?: 'user' | 'command'; storage_message?: string; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string; queue_id?: string; peerExcludeSocketId?: string }, + profile: string, + sessionMap: Map, + bridge: AgentBridgeClient, + skipUserMessage = false, + loadSessionStateFromDbFn: (sid: string, sessionMap: Map) => Promise, + dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, +) { + const { input, session_id, instructions } = data + if (!session_id) { + socket.emit('run.failed', { event: 'run.failed', error: 'session_id is required for cli source' }) + return + } + + let fullInstructions = instructions + ? `${getSystemPrompt()}\n${instructions}` + : getSystemPrompt() + const sessionRow = getSession(session_id) + const sessionModel = sessionRow?.model || '' + const sessionProvider = sessionRow?.provider || '' + const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({ + profile, + sessionModel, + sessionProvider, + requestedModel: data.model, + requestedProvider: data.provider, + modelGroups: data.model_groups, + }) + if (sessionRow) { + const updates: { model?: string; provider?: string } = {} + if (resolvedModel && sessionRow.model !== resolvedModel) updates.model = resolvedModel + if (resolvedProvider && sessionRow.provider !== resolvedProvider) updates.provider = resolvedProvider + if (Object.keys(updates).length > 0) updateSession(session_id, updates) + } + const runContext = [ + `[Current Hermes profile: ${profile}]`, + sessionRow?.workspace ? `[Current working directory: ${sessionRow.workspace}]` : '', + 'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.', + ].filter(Boolean).join('\n') + fullInstructions = `\n${runContext}\n${fullInstructions}` + + const runMarker = `cli_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const now = Math.floor(Date.now() / 1000) + let state = sessionMap.get(session_id) + if (!state) { + state = getSession(session_id) + ? await loadSessionStateFromDbFn(session_id, sessionMap) + : { messages: [], isWorking: false, events: [], queue: [] } + sessionMap.set(session_id, state) + } + + state.isWorking = true + state.isAborting = false + state.events = [] + state.profile = profile + state.source = 'cli' + state.activeRunMarker = runMarker + state.runId = undefined + state.abortController = undefined + state.bridgeOutput = '' + state.bridgePendingAssistantContent = '' + state.bridgePendingReasoningContent = '' + state.bridgePendingToolCallMarkup = '' + state.bridgeToolCounter = 0 + state.bridgePendingTools = [] + state.responseRun = undefined + + const displayInput = data.display_input === undefined ? input : data.display_input + const inputStr = displayInput == null ? '' : contentBlocksToString(displayInput) + const actualInputStr = contentBlocksToString(input) + const currentInputUsage = estimateUsageTokensFromMessages([{ role: 'user', content: actualInputStr }]) + const currentInputTokens = currentInputUsage.inputTokens + const shouldPersistUserMessage = !skipUserMessage && displayInput !== null + const displayRole = data.display_role === 'command' ? 'command' : 'user' + let messageId: number | string | undefined + + if (shouldPersistUserMessage) { + state.messages.push({ + id: state.messages.length + 1, + session_id, + runMarker, + role: displayRole, + content: inputStr, + timestamp: now, + }) + + if (!getSession(session_id)) { + const previewText = extractTextForPreview(displayInput || input) + const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) + createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) + } + messageId = addMessage({ + session_id, + role: displayRole, + content: inputStr, + timestamp: now, + }) + } else if (!getSession(session_id)) { + const previewText = displayInput === null ? extractTextForPreview(input) : extractTextForPreview(displayInput || input) + const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) + createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) + } + + socket.join(`session:${session_id}`) + if (shouldPersistUserMessage) { + const peerTarget = data.peerExcludeSocketId + ? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId) + : socket.to(`session:${session_id}`) + peerTarget.emit('run.peer_user_message', { + event: 'run.peer_user_message', + session_id, + message: { + id: data.queue_id || messageId, + role: displayRole, + content: inputStr, + timestamp: now, + }, + }) + } + const emit = (event: string, payload: any) => { + const tagged = { ...payload, session_id } + nsp.to(`session:${session_id}`).emit(event, tagged) + if (!nsp.adapter.rooms.get(`session:${session_id}`)?.size && socket.connected) { + socket.emit(event, tagged) + } + } + + const history = await buildCompressedHistory( + session_id, profile, + '', + undefined, + emit, + sessionMap, + { model: resolvedModel, provider: resolvedProvider }, + async (_messages, localMessageTokens) => { + const fixedContextTokens = await ensureBridgeFixedContext({ + sessionId: session_id, + profile, + model: resolvedModel, + provider: resolvedProvider, + instructions: fullInstructions, + state, + bridge, + refresh: true, + }) + const contextTokens = fixedContextTokens == null + ? localMessageTokens + : fixedContextTokens + localMessageTokens + bridgeLogger.info({ + sessionId: session_id, + profile, + model: resolvedModel, + provider: resolvedProvider, + fixedContextTokens, + messageTokens: localMessageTokens, + contextTokens, + }, '[chat-run-socket] local context estimate') + return contextTokens + }, + currentInputTokens, + ) + const bridgeHistory = history + + try { + const bridgeInput = isContentBlockArray(input) + ? await convertContentBlocksForAgent(input) + : input + const bridgeStorageInput = data.storage_message !== undefined + ? data.storage_message + : isContentBlockArray(input) + ? inputStr + : undefined + logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id) + bridgeLogger.info({ + sessionId: session_id, + profile, + inputChars: inputStr.length, + historyMessages: history.length, + hasInstructions: Boolean(fullInstructions), + multimodalInput: isContentBlockArray(input), + }, '[chat-run-socket] starting CLI bridge run') + const started = await bridge.chat( + session_id, + bridgeInput as AgentBridgeMessage, + bridgeHistory, + fullInstructions, + profile, + { + ...(bridgeStorageInput !== undefined ? { storage_message: bridgeStorageInput } : {}), + ...(resolvedModel ? { model: resolvedModel } : {}), + ...(resolvedProvider ? { provider: resolvedProvider } : {}), + }, + ) + state.runId = started.run_id + bridgeLogger.info({ + sessionId: session_id, + runId: started.run_id, + status: started.status, + }, '[chat-run-socket] CLI bridge run started') + pushState(sessionMap, session_id, 'run.started', { + event: 'run.started', + run_id: started.run_id, + queue_length: state.queue.length || 0, + }) + emit('run.started', { + event: 'run.started', + run_id: started.run_id, + queue_length: state.queue.length || 0, + }) + + for await (const chunk of bridge.streamOutput(started.run_id)) { + await applyBridgeChunkAsync( + nsp, + socket, + state, + session_id, + runMarker, + chunk, + emit, + profile, + sessionMap, + bridge, + dequeueNextQueuedRun, + fullInstructions, + { model: resolvedModel, provider: resolvedProvider }, + currentInputTokens, + shouldPersistUserMessage && displayRole === 'user', + data.model_groups, + ) + if (chunk.done) break + } + } catch (err: any) { + if (state.activeRunMarker !== runMarker) return + if (!state.isWorking) return + const queueLen = state.queue?.length ?? 0 + state.isWorking = false + state.isAborting = false + state.profile = undefined + state.runId = undefined + state.activeRunMarker = undefined + state.events = [] + state.bridgePendingToolCallMarkup = undefined + flushBridgePendingToDb(state, session_id) + updateSessionStats(session_id) + const message = err instanceof Error ? err.message : String(err) + const errUsage = await calcAndUpdateUsage(session_id, state, emit) + const errContextTokens = await refreshFinalContextUsage({ + sessionId: session_id, + profile, + model: resolvedModel, + provider: resolvedProvider, + instructions: fullInstructions, + state, + usage: errUsage, + emit, + bridge, + }) + updateUsage(session_id, { + inputTokens: errUsage.inputTokens, + outputTokens: errUsage.outputTokens, + profile, + }) + emit('run.failed', { + event: 'run.failed', + error: message, + inputTokens: errUsage.inputTokens, + outputTokens: errUsage.outputTokens, + contextTokens: errContextTokens, + queue_remaining: queueLen, + }) + if (queueLen > 0) dequeueNextQueuedRun(socket, session_id) + } +} + +async function refreshFinalContextUsage(args: { + sessionId: string + profile: string + model?: string | null + provider?: string | null + instructions: string + state: SessionState + usage: { inputTokens: number; outputTokens: number } + emit: (event: string, payload: any) => void + bridge: AgentBridgeClient +}): Promise { + try { + const dbHistory = await buildDbHistory(args.sessionId, { excludeLastUser: false }) + const finalHistory = await buildSnapshotAwareHistory( + args.sessionId, + args.profile, + dbHistory, + { model: args.model, provider: args.provider }, + ) + const finalMessageUsage = estimateUsageTokensFromMessages(finalHistory) + const finalMessageTokens = finalMessageUsage.inputTokens + finalMessageUsage.outputTokens + await ensureBridgeFixedContext({ + sessionId: args.sessionId, + profile: args.profile, + model: args.model, + provider: args.provider, + instructions: args.instructions, + state: args.state, + bridge: args.bridge, + }) + const contextTokens = updateMessageContextTokenUsage( + args.sessionId, + args.state, + args.emit, + finalMessageTokens, + args.usage, + ) + bridgeLogger.info({ + sessionId: args.sessionId, + profile: args.profile, + model: args.model, + provider: args.provider, + messages: finalHistory.length, + fixedContextTokens: args.state.bridgeContext?.fixedContextTokens, + messageTokens: finalMessageTokens, + contextTokens, + }, '[chat-run-socket] final local context estimate') + return contextTokens + } catch (err) { + bridgeLogger.warn({ + err: err instanceof Error ? { message: err.message, name: err.name } : err, + sessionId: args.sessionId, + profile: args.profile, + }, '[chat-run-socket] final local context estimate failed') + return args.state.contextTokens + } +} + +async function estimateSnapshotAwareMessageTokens(args: { + sessionId: string + profile: string + model?: string | null + provider?: string | null + currentInputTokens?: number + currentInputIncludedInDb?: boolean +}): Promise<{ messageTokens: number; messages: number }> { + const dbHistory = await buildDbHistory(args.sessionId, { excludeLastUser: false }) + const snapshotHistory = await buildSnapshotAwareHistory( + args.sessionId, + args.profile, + dbHistory, + { model: args.model, provider: args.provider }, + ) + const usage = estimateUsageTokensFromMessages(snapshotHistory) + const extraInputTokens = args.currentInputIncludedInDb + ? 0 + : finiteToken(args.currentInputTokens) ?? 0 + return { + messageTokens: usage.inputTokens + usage.outputTokens + extraInputTokens, + messages: snapshotHistory.length, + } +} + +async function applyBridgeChunkAsync( + nsp: ReturnType, + socket: Socket, + state: SessionState, + sessionId: string, + runMarker: string, + chunk: AgentBridgeOutput, + emit: (event: string, payload: any) => void, + profile: string, + sessionMap: Map, + bridge: AgentBridgeClient, + dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, + instructions: string, + modelContext: { model?: string | null; provider?: string | null }, + currentInputTokens = 0, + currentInputIncludedInDb = true, + modelGroups?: RunModelGroup[], +): Promise { + if (state.activeRunMarker !== runMarker) { + bridgeLogger.info({ + sessionId, + runId: chunk.run_id, + runMarker, + activeRunMarker: state.activeRunMarker, + }, '[chat-run-socket] ignoring stale CLI bridge chunk') + return + } + + state.runId = chunk.run_id + + // When the bridge emits text as ordered `stream.delta` events (interleaved + // with tool.started/tool.completed in the SAME events list), we process the + // text here in true order and must NOT also process the aggregated + // `chunk.delta` below (that would duplicate the text). This flag tracks it. + let sawStreamDeltaEvent = false + + for (const ev of chunk.events || []) { + const evType = ev.event as string | undefined + if (evType === 'stream.delta') { + sawStreamDeltaEvent = true + processBridgeTextDelta(state, sessionId, runMarker, chunk.run_id, String((ev as any).delta || ''), emit) + continue + } + if (evType === 'bridge.context.ready') { + cacheBridgeContext(state, ev) + const usage = await calcAndUpdateUsage(sessionId, state, emit) + const snapshotAware = await estimateSnapshotAwareMessageTokens({ + sessionId, + profile, + model: modelContext.model, + provider: modelContext.provider, + currentInputTokens, + currentInputIncludedInDb, + }) + updateMessageContextTokenUsage( + sessionId, + state, + emit, + snapshotAware.messageTokens, + usage, + ) + } else if (evType === 'tool.started') { + // Flush any partial tool-call-marker prefix that was held back by + // the markup filter. Without this, deltas ending in `[`, `[C`, + // `[Ca`, etc. are silently dropped because no follow-up delta will + // come for this assistant message — the next chunk is the tool call + // itself. See bridge-delta.ts for full rationale. + flushPendingToolMarkupToAssistant(state, runMarker, chunk.run_id, emit) + flushBridgePendingToDb(state, sessionId, runMarker) + const toolName = (ev.tool_name as string) || '' + const args = ev.args as Record | undefined + const tool = recordBridgeToolStarted(state, sessionId, runMarker, toolName, args, ev.tool_call_id) + const payload = { + event: 'tool.started', + run_id: chunk.run_id, + tool_call_id: tool.id, + tool: toolName, + name: toolName, + arguments: tool.arguments, + preview: ev.preview || summarizeToolArguments(tool.arguments), + } + pushState(sessionMap, sessionId, 'tool.started', payload) + emit('tool.started', payload) + } else if (evType === 'tool.completed') { + const toolName = (ev.tool_name as string) || '' + const completed = recordBridgeToolCompleted(state, sessionId, runMarker, toolName, ev) + const payload = { + event: 'tool.completed', + run_id: chunk.run_id, + tool_call_id: completed.id, + tool: toolName, + name: toolName, + output: completed.output, + duration: completed.duration ?? ev.duration, + error: ev.is_error || undefined, + } + pushState(sessionMap, sessionId, 'tool.completed', payload) + emit('tool.completed', payload) + } else if (evType?.startsWith('subagent.')) { + const payload = { + event: evType, + run_id: chunk.run_id, + subagent_id: ev.subagent_id, + parent_id: ev.parent_id, + depth: ev.depth, + task_index: ev.task_index, + task_count: ev.task_count, + goal: ev.goal, + model: ev.model, + toolsets: ev.toolsets, + tool_count: ev.tool_count, + tool: ev.tool_name, + name: ev.tool_name, + preview: ev.text || ev.summary || ev.tool_preview || '', + text: ev.text || '', + status: ev.status, + summary: ev.summary, + duration: ev.duration_seconds, + duration_seconds: ev.duration_seconds, + input_tokens: ev.input_tokens, + output_tokens: ev.output_tokens, + reasoning_tokens: ev.reasoning_tokens, + api_calls: ev.api_calls, + cost_usd: ev.cost_usd, + files_read: ev.files_read, + files_written: ev.files_written, + output_tail: ev.output_tail, + } + pushState(sessionMap, sessionId, evType, payload) + emit(evType, payload) + } else if (evType === 'turn.boundary') { + flushBridgePendingToDb(state, sessionId, runMarker) + } else if (evType === 'reasoning.delta' || evType === 'thinking.delta') { + const text = String(ev.text || '') + if (text) { + state.bridgePendingReasoningContent = (state.bridgePendingReasoningContent || '') + text + const message = ensureOpenBridgeAssistantMessage(state, sessionId, runMarker) + message.reasoning = (message.reasoning || '') + text + message.reasoning_content = (message.reasoning_content || '') + text + } + emit(evType, { + event: evType, + run_id: chunk.run_id, + text, + }) + } else if (evType === 'reasoning.available') { + emit('reasoning.available', { + event: 'reasoning.available', + run_id: chunk.run_id, + }) + } else if (evType === 'approval.requested') { + const payload = { + event: 'approval.requested', + run_id: chunk.run_id, + approval_id: ev.approval_id, + command: ev.command, + description: ev.description, + choices: ev.choices, + allow_permanent: ev.allow_permanent, + timeout_ms: ev.timeout_ms, + } + replaceState(sessionMap, sessionId, 'approval.requested', payload) + emit('approval.requested', payload) + } else if (evType === 'approval.resolved') { + const payload = { + event: 'approval.resolved', + run_id: chunk.run_id, + approval_id: ev.approval_id, + choice: ev.choice, + } + replaceState(sessionMap, sessionId, 'approval.resolved', payload) + emit('approval.resolved', payload) + } else if (evType === 'clarify.requested') { + const payload = { + event: 'clarify.requested', + run_id: chunk.run_id, + clarify_id: ev.clarify_id, + question: ev.question, + choices: Array.isArray(ev.choices) ? ev.choices : null, + timeout_ms: ev.timeout_ms, + } + replaceState(sessionMap, sessionId, 'clarify.requested', payload) + emit('clarify.requested', payload) + } else if (evType === 'clarify.resolved') { + const payload = { + event: 'clarify.resolved', + run_id: chunk.run_id, + clarify_id: ev.clarify_id, + } + replaceState(sessionMap, sessionId, 'clarify.resolved', payload) + emit('clarify.resolved', payload) + } else if (evType === 'bridge.compression.requested') { + const bridgeHistory = await buildDbHistory(sessionId, { excludeLastUser: true }) + const bridgeUsage = estimateUsageTokensFromMessages(bridgeHistory) + const messageOnlyTokens = bridgeUsage.inputTokens + bridgeUsage.outputTokens + const runInputTokens = typeof currentInputTokens === 'number' && Number.isFinite(currentInputTokens) && currentInputTokens > 0 + ? Math.floor(currentInputTokens) + : 0 + const runMessageTokens = messageOnlyTokens + runInputTokens + const tokenCount = contextTokensWithCachedOverhead(state, runMessageTokens) + bridgeLogger.info({ + sessionId, + profile, + bridgeMessages: ev.message_count, + dbMessages: bridgeHistory.length, + messageOnlyTokens, + currentInputTokens: runInputTokens, + fixedContextTokens: state.bridgeContext?.fixedContextTokens, + contextTokens: tokenCount, + bridgeApproxTokens: ev.approx_tokens, + source: 'local', + }, '[chat-run-socket] bridge compression token estimate') + const payload = { + event: 'compression.started', + run_id: chunk.run_id, + request_id: ev.request_id, + message_count: bridgeHistory.length || ev.message_count, + token_count: tokenCount, + source: 'bridge', + } + replaceState(sessionMap, sessionId, 'compression.started', payload) + emit('compression.started', payload) + if (ev.request_id && Array.isArray(ev.messages)) { + try { + const compressed = await forceCompressBridgeHistory( + sessionId, + profile, + ev.messages as ChatMessage[], + tokenCount, + ) + state.bridgeCompressionResults = state.bridgeCompressionResults || {} + state.bridgeCompressionResults[String(ev.request_id)] = compressed + await bridge.compressionRespond(String(ev.request_id), { messages: compressed.messages }) + } catch (err: any) { + await bridge.compressionRespond(String(ev.request_id), { + error: err?.message || String(err), + }).catch(() => undefined) + } + } + } else if (evType === 'bridge.compression.completed') { + const compressionResult = ev.request_id + ? state.bridgeCompressionResults?.[String(ev.request_id)] + : undefined + const messageAfterTokens = finiteToken(compressionResult?.afterTokens) + const runInputTokens = typeof currentInputTokens === 'number' && Number.isFinite(currentInputTokens) && currentInputTokens > 0 + ? Math.floor(currentInputTokens) + : 0 + const messageAfterTokensWithInput = messageAfterTokens != null + ? messageAfterTokens + runInputTokens + : undefined + const afterContextTokens = messageAfterTokensWithInput != null + ? contextTokensWithCachedOverhead(state, messageAfterTokensWithInput) + : undefined + const payload = { + event: 'compression.completed', + run_id: chunk.run_id, + request_id: ev.request_id, + compressed: compressionResult?.compressed ?? ev.compressed !== false, + llmCompressed: compressionResult?.llmCompressed, + totalMessages: compressionResult?.beforeMessages ?? ev.message_count, + resultMessages: compressionResult?.resultMessages ?? ev.result_messages, + beforeTokens: compressionResult?.beforeTokens ?? ev.approx_tokens, + afterTokens: messageAfterTokensWithInput, + contextTokens: afterContextTokens, + summaryTokens: compressionResult?.summaryTokens, + verbatimCount: compressionResult?.verbatimCount, + compressedStartIndex: compressionResult?.compressedStartIndex, + source: 'bridge', + } + if (ev.request_id && state.bridgeCompressionResults) { + delete state.bridgeCompressionResults[String(ev.request_id)] + } + replaceState(sessionMap, sessionId, 'compression.completed', payload) + emit('compression.completed', payload) + const usage = await calcAndUpdateUsage(sessionId, state, emit) + if (messageAfterTokensWithInput != null) { + updateMessageContextTokenUsage(sessionId, state, emit, messageAfterTokensWithInput, usage) + } + } else if (evType === 'bridge.compression.failed') { + const payload = { + event: 'compression.completed', + run_id: chunk.run_id, + request_id: ev.request_id, + compressed: false, + totalMessages: ev.message_count, + resultMessages: ev.message_count, + beforeTokens: ev.approx_tokens, + error: ev.error, + source: 'bridge', + } + if (ev.request_id && state.bridgeCompressionResults) { + delete state.bridgeCompressionResults[String(ev.request_id)] + } + replaceState(sessionMap, sessionId, 'compression.completed', payload) + emit('compression.completed', payload) + } else if (evType === 'status') { + const payload = { + ...ev, + event: 'agent.event', + run_id: chunk.run_id, + } + replaceState(sessionMap, sessionId, 'agent.event', payload) + emit('agent.event', payload) + } + } + + // Only process the aggregated chunk.delta when the bridge did NOT emit + // ordered stream.delta events for this chunk. With ordered events, the text + // was already handled above in true interleaved order; processing it again + // here would duplicate it. + if (chunk.delta && !sawStreamDeltaEvent) { + const delta = filterBridgeToolCallMarkupDelta(state, chunk.delta) + if (delta) { + state.bridgeOutput = (state.bridgeOutput || '') + delta + state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta + const last = [...state.messages].reverse().find(m => m.runMarker === runMarker) + if (last?.role === 'assistant' && last.finish_reason == null) { + last.content += delta + syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent) + } else { + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: delta, + reasoning: state.bridgePendingReasoningContent || null, + reasoning_content: state.bridgePendingReasoningContent || null, + timestamp: Math.floor(Date.now() / 1000), + }) + } + emit('message.delta', { + event: 'message.delta', + run_id: chunk.run_id, + delta, + output: state.bridgeOutput, + }) + } + } + + if (!chunk.done) return + if (!state.isWorking) return + if (state.isAborting) { + bridgeLogger.info({ + sessionId, + runId: chunk.run_id, + status: chunk.status, + }, '[chat-run-socket][abort] suppressing CLI bridge terminal chunk during abort') + return + } + + // If the run terminated while we still had a partial tool-call-marker + // prefix buffered, flush it to the user-visible stream now. Discarding + // it (which the line below was doing implicitly) silently drops the + // final characters of the assistant message. + flushPendingToolMarkupToAssistant(state, runMarker, chunk.run_id, emit) + flushBridgePendingToDb(state, sessionId) + state.bridgePendingToolCallMarkup = undefined + updateSessionStats(sessionId) + await delay(BRIDGE_USAGE_FLUSH_DELAY_MS) + const usage = await calcAndUpdateUsage(sessionId, state, emit) + const contextTokens = await refreshFinalContextUsage({ + sessionId, + profile, + model: modelContext.model, + provider: modelContext.provider, + instructions, + state, + usage, + emit, + bridge, + }) + updateUsage(sessionId, { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + profile: state.profile, + }) + const terminalError = bridgeTerminalError(chunk) + const hadQueuedRunBeforeGoalEvaluation = state.queue.length > 0 + state.isWorking = hadQueuedRunBeforeGoalEvaluation + state.isAborting = false + state.profile = hadQueuedRunBeforeGoalEvaluation ? (state.queue[0]?.profile || profile) : undefined + state.source = hadQueuedRunBeforeGoalEvaluation ? state.queue[0]?.source : state.source + state.runId = undefined + state.activeRunMarker = undefined + state.events = [] + const eventName = terminalError ? 'run.failed' : 'run.completed' + const payload = { + event: eventName, + run_id: chunk.run_id, + output: chunk.output || state.bridgeOutput || '', + result: chunk.result, + error: terminalError || chunk.error, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + contextTokens, + queue_remaining: state.queue.length, + } + emit(eventName, payload) + + if (!terminalError) { + await maybeEnqueueGoalContinuation({ + nsp, + socket, + sessionId, + state, + bridge, + profile, + modelContext, + modelGroups, + instructions, + finalResponse: bridgeFinalResponse(chunk, state), + }) + } + + if (state.queue.length > 0 && !state.activeRunMarker) { + const nextQueuedRun = state.queue[0] + state.isWorking = true + state.profile = nextQueuedRun.profile || profile + state.source = nextQueuedRun.source + dequeueNextQueuedRun(socket, sessionId) + } else if (!state.activeRunMarker) { + state.isWorking = false + state.profile = undefined + } +} + +function bridgeFinalResponse(chunk: AgentBridgeOutput, state: SessionState): string { + const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result) + ? chunk.result as Record + : null + const finalResponse = result && typeof result.final_response === 'string' + ? result.final_response + : '' + return finalResponse || chunk.output || state.bridgeOutput || '' +} + +function hasRealQueuedRun(state: SessionState): boolean { + return state.queue.some(item => !item.goalContinuation) +} + +async function maybeEnqueueGoalContinuation(args: { + nsp: ReturnType + socket: Socket + sessionId: string + state: SessionState + bridge: AgentBridgeClient + profile: string + modelContext: { model?: string | null; provider?: string | null } + modelGroups?: RunModelGroup[] + instructions: string + finalResponse: string +}) { + const finalResponse = args.finalResponse || '' + if (!finalResponse.trim()) return + if (hasRealQueuedRun(args.state)) return + + let decision + try { + decision = await args.bridge.goalEvaluate(args.sessionId, finalResponse, args.profile) + } catch (err) { + logger.warn(err, '[chat-run-socket] /goal evaluation failed for session %s', args.sessionId) + return + } + + if (isGoalJudgeUnavailable(decision.reason)) { + emitGoalStatus( + args.nsp, + args.socket, + args.sessionId, + args.state, + 'judge_unavailable', + 'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.', + ) + return + } + + const message = typeof decision.message === 'string' ? decision.message.trim() : '' + if (message) emitGoalStatus(args.nsp, args.socket, args.sessionId, args.state, decision.verdict || 'goal', message) + + if (!decision.should_continue) return + if (hasRealQueuedRun(args.state)) return + + const prompt = typeof decision.continuation_prompt === 'string' + ? decision.continuation_prompt.trim() + : '' + if (!prompt) return + + const next: QueuedRun = { + queue_id: `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + input: prompt, + displayInput: null, + storageMessage: prompt, + model: args.modelContext.model || undefined, + provider: args.modelContext.provider || undefined, + model_groups: args.modelGroups, + instructions: undefined, + profile: args.profile, + source: 'cli', + goalContinuation: true, + } + args.state.queue.push(next) +} + +function isGoalJudgeUnavailable(reason?: string | null): boolean { + const value = String(reason || '').toLowerCase() + return value.includes('no auxiliary client configured') || value.includes('auxiliary client unavailable') +} + +function emitGoalStatus( + nsp: ReturnType, + socket: Socket, + sessionId: string, + state: SessionState, + action: string, + message: string, +) { + const now = Math.floor(Date.now() / 1000) + const id = addMessage({ + session_id: sessionId, + role: 'command', + content: message, + timestamp: now, + }) + state.messages.push({ + id: id || `goal_${now}_${state.messages.length}`, + session_id: sessionId, + role: 'command', + content: message, + timestamp: now, + }) + nsp.to(`session:${sessionId}`).emit('session.command', { + event: 'session.command', + session_id: sessionId, + command: 'goal', + ok: true, + action, + message, + terminal: false, + }) + if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { + socket.emit('session.command', { + event: 'session.command', + session_id: sessionId, + command: 'goal', + ok: true, + action, + message, + terminal: false, + }) + } +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts new file mode 100644 index 0000000..270d822 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -0,0 +1,443 @@ +/** + * ChatRunSocket — Socket.IO namespace /chat-run. + * + * Thin orchestrator that delegates to specialized modules: + * - handle-api-run.ts → upstream /v1/responses streaming + * - handle-bridge-run.ts → CLI bridge runs + * - abort.ts → run cancellation + * - compression.ts → context window management + */ + +import type { Server, Socket } from 'socket.io' +import { logger } from '../../logger' +import { getSystemPrompt } from '../../../lib/llm-prompt' +import { getSession } from '../../../db/hermes/session-store' +import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../hermes-profile' +import { AgentBridgeClient } from '../agent-bridge' +import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle-api-run' +import { handleBridgeRun } from './handle-bridge-run' +import { handleAbort } from './abort' +import { getOrCreateSession } from './compression' +import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command' +import { contentBlocksToString } from './content-blocks' +import type { ContentBlock, QueuedRun, SessionState } from './types' +import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' +import { userCanAccessProfile } from '../../../db/hermes/users-store' + +export type { ContentBlock } from './types' + +export class ChatRunSocket { + private nsp: ReturnType + private bridge = new AgentBridgeClient() + /** sessionId → session state (messages, working status, events, run tracking) */ + private sessionMap = new Map() + + constructor(io: Server) { + this.nsp = io.of('/chat-run') + } + + init() { + this.nsp.use(this.authMiddleware.bind(this)) + this.nsp.on('connection', this.onConnection.bind(this)) + logger.info('[chat-run-socket] Socket.IO ready at /chat-run') + } + + // --- Auth middleware --- + + private async authMiddleware(socket: Socket, next: (err?: Error) => void) { + const token = socket.handshake.auth?.token as string | undefined + if (!await isAuthEnabled()) { + next() + return + } + + const user = await authenticateUserToken(token || '') + if (!user) { + return next(new Error('Authentication failed')) + } + const socketProfile = String(socket.handshake.query?.profile || '').trim() + if (socketProfile && !this.canAccessProfile(user, socketProfile)) { + return next(new Error('Profile access denied')) + } + socket.data.user = user + next() + } + + // --- Connection handler --- + + private onConnection(socket: Socket) { + const socketUser = socket.data.user as AuthenticatedUser | undefined + const socketProfile = (socket.handshake.query?.profile as string) || 'default' + const currentProfile = () => socketProfile || getActiveProfileName() || 'default' + const profileExists = (profile: string) => { + if (!profile || profile === 'default') return true + return listProfileNamesFromDisk().includes(profile) + } + const resolveRunProfile = (sessionId?: string, requested?: string) => { + const requestedProfile = typeof requested === 'string' ? requested.trim() : '' + if (requestedProfile) { + if (!profileExists(requestedProfile)) throw new Error(`Profile "${requestedProfile}" does not exist`) + if (socketUser && !this.canAccessProfile(socketUser, requestedProfile)) { + throw new Error(`Profile "${requestedProfile}" is not available for this user`) + } + return requestedProfile + } + if (!sessionId) { + const profile = currentProfile() + if (socketUser && !this.canAccessProfile(socketUser, profile)) { + throw new Error(`Profile "${profile}" is not available for this user`) + } + return profile + } + const storedProfile = getSession(sessionId)?.profile || '' + const profile = storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile() + if (socketUser && !this.canAccessProfile(socketUser, profile)) { + throw new Error(`Profile "${profile}" is not available for this user`) + } + return profile + } + + socket.on('run', async (data: { + input: string | ContentBlock[] + display_input?: string | ContentBlock[] | null + display_role?: 'user' | 'command' + storage_message?: string + session_id?: string + model?: string + instructions?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + queue_id?: string + source?: string + profile?: string + }) => { + let runProfile: string + try { + runProfile = resolveRunProfile(data.session_id, data.profile) + } catch (err) { + socket.emit('run.failed', { + event: 'run.failed', + session_id: data.session_id, + error: err instanceof Error ? err.message : String(err), + }) + return + } + if (data.session_id) { + const state = getOrCreateSession(this.sessionMap, data.session_id) + const source = resolveRunSource(data.source, data.session_id) + const command = parseSessionCommand(data.input) + if (command && source === 'cli') { + try { + await handleSessionCommand(data.session_id, command, { + nsp: this.nsp, + socket, + sessionMap: this.sessionMap, + bridge: this.bridge, + profile: runProfile, + model: data.model, + provider: data.provider, + model_groups: data.model_groups, + instructions: data.instructions, + queueId: data.queue_id, + runQueuedItem: this.runQueuedItem.bind(this), + }) + } catch (err) { + this.emitToSession(socket, data.session_id, 'session.command', { + event: 'session.command', + command: command.rawName, + ok: false, + action: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } + return + } + if (state.isWorking) { + const queueId = data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + state.queue.push({ + queue_id: queueId, + input: data.input, + model: data.model, + provider: data.provider, + model_groups: data.model_groups, + instructions: data.instructions, + profile: runProfile, + source, + originSocketId: socket.id, + }) + this.nsp.to(`session:${data.session_id}`).emit('run.queued', { + event: 'run.queued', + session_id: data.session_id, + queue_length: state.queue.length, + queued_messages: this.serializeQueuedMessages(state.queue), + }) + logger.info('[chat-run-socket] queued run for session %s (queue: %d)', data.session_id, state.queue.length) + return + } + state.events = [] + state.isWorking = true + state.profile = runProfile + state.source = source + } + try { + await this.handleRun(socket, data, runProfile) + } catch (err) { + if (data.session_id) { + const state = this.sessionMap.get(data.session_id) + if (state && !state.runId && !state.abortController && !state.activeRunMarker) { + state.isWorking = false + state.profile = undefined + } + } + socket.emit('run.failed', { + event: 'run.failed', + session_id: data.session_id, + error: err instanceof Error ? err.message : String(err), + }) + } + }) + + socket.on('cancel_queued_run', (data: { session_id?: string; queue_id?: string }) => { + if (!data.session_id || !data.queue_id) return + const state = this.sessionMap.get(data.session_id) + if (!state?.queue.length) return + const before = state.queue.length + state.queue = state.queue.filter(item => item.queue_id !== data.queue_id) + if (state.queue.length === before) return + this.nsp.to(`session:${data.session_id}`).emit('run.queued', { + event: 'run.queued', + session_id: data.session_id, + queue_length: state.queue.length, + queued_messages: this.serializeQueuedMessages(state.queue), + }) + logger.info('[chat-run-socket] cancelled queued run %s for session %s (queue: %d)', + data.queue_id, data.session_id, state.queue.length) + }) + + socket.on('resume', async (data: { session_id?: string }) => { + if (!data.session_id) return + const sid = data.session_id + socket.join(`session:${sid}`) + this.resumeSession(socket, sid) + }) + + socket.on('abort', (data: { session_id?: string }) => { + if (data.session_id) { + void handleAbort(this.nsp, socket, data.session_id, this.sessionMap, this.bridge, this.runQueuedItem.bind(this)) + } + }) + + socket.on('approval.respond', async (data: { session_id?: string; approval_id?: string; choice?: string }) => { + if (!data.session_id || !data.approval_id) return + try { + const result = await this.bridge.approvalRespond(data.approval_id, data.choice || 'deny') + this.emitToSession(socket, data.session_id, 'approval.resolved', { + event: 'approval.resolved', + approval_id: data.approval_id, + choice: data.choice || 'deny', + resolved: Boolean(result.resolved), + }) + } catch (err) { + this.emitToSession(socket, data.session_id, 'approval.resolved', { + event: 'approval.resolved', + approval_id: data.approval_id, + choice: data.choice || 'deny', + resolved: false, + error: err instanceof Error ? err.message : String(err), + }) + } + }) + + socket.on('clarify.respond', async (data: { session_id?: string; clarify_id?: string; response?: string }) => { + if (!data.session_id || !data.clarify_id) return + this.clearClarifyEventState(data.session_id, data.clarify_id) + try { + const result = await this.bridge.clarifyRespond(data.clarify_id, data.response || '') + this.emitToSession(socket, data.session_id, 'clarify.resolved', { + event: 'clarify.resolved', + clarify_id: data.clarify_id, + resolved: Boolean((result as any)?.resolved), + }) + } catch (err) { + this.emitToSession(socket, data.session_id, 'clarify.resolved', { + event: 'clarify.resolved', + clarify_id: data.clarify_id, + resolved: false, + error: err instanceof Error ? err.message : String(err), + }) + } + }) + } + + // --- Run dispatcher --- + + private async handleRun( + socket: Socket, + data: { + input: string | ContentBlock[] + display_input?: string | ContentBlock[] | null + display_role?: 'user' | 'command' + storage_message?: string + session_id?: string + model?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + instructions?: string + source?: string + queue_id?: string + peerExcludeSocketId?: string + }, + profile: string, + skipUserMessage = false, + ) { + const source = resolveRunSource(data.source, data.session_id) + if (data.session_id && source === 'cli' && isSessionCommand(data.input)) return + + if (source === 'cli') { + let fullInstructions = data.instructions + ? `${getSystemPrompt()}\n${data.instructions}` + : getSystemPrompt() + if (data.session_id) { + const sessionRow = getSession(data.session_id) + if (sessionRow?.workspace) { + const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]` + fullInstructions = `\n${workspaceCtx}\n${fullInstructions}` + } + } + + await handleBridgeRun( + this.nsp, socket, { ...data, instructions: fullInstructions }, profile, + this.sessionMap, this.bridge, + skipUserMessage, + loadSessionStateFromDb, + this.dequeueNextQueuedRun.bind(this), + ) + return + } + + await handleApiRun( + this.nsp, socket, data, profile, + this.sessionMap, + skipUserMessage, + this.dequeueNextQueuedRun.bind(this), + ) + } + + // --- Resume --- + + private async resumeSession(socket: Socket, sid: string) { + let state = this.sessionMap.get(sid) + if (!state) { + state = await loadSessionStateFromDb(sid, this.sessionMap) + this.sessionMap.set(sid, state) + } + socket.emit('resumed', { + session_id: sid, + messages: state.messages, + messageTotal: state.messageTotal, + messageLoadedCount: state.messageLoadedCount, + messagePageLimit: state.messagePageLimit, + hasMoreBefore: state.hasMoreBefore, + isWorking: state.isWorking, + isAborting: state.isAborting || false, + events: state.isWorking ? state.events : [], + inputTokens: state.inputTokens, + outputTokens: state.outputTokens, + contextTokens: state.contextTokens, + queueLength: state.queue?.length || 0, + queueMessages: this.serializeQueuedMessages(state.queue || []), + }) + + logger.info('[chat-run-socket] socket %s resumed session %s (working: %s, messages: %d)', + socket.id, sid, state.isWorking, state.messages.length) + } + + // --- Queue --- + + private dequeueNextQueuedRun(socket: Socket, sessionId: string, fallbackProfile = 'default') { + const state = this.sessionMap.get(sessionId) + if (!state?.queue.length) return false + + const next = state.queue.shift()! + logger.info('[chat-run-socket] dequeuing queued run for session %s (remaining: %d)', sessionId, state.queue.length) + this.nsp.to(`session:${sessionId}`).emit('run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + dequeued_queue_id: next.queue_id, + queued_messages: this.serializeQueuedMessages(state.queue), + }) + this.runQueuedItem(socket, sessionId, next, fallbackProfile) + return true + } + + private runQueuedItem(socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile = 'default') { + const skipUserMessage = next.displayInput === null + void this.handleRun(socket, { + input: next.input, + display_input: next.displayInput, + display_role: next.displayRole, + storage_message: next.storageMessage, + session_id: sessionId, + model: next.model, + provider: next.provider, + model_groups: next.model_groups, + instructions: next.instructions, + source: next.source, + queue_id: next.queue_id, + peerExcludeSocketId: next.originSocketId, + }, next.profile || fallbackProfile, skipUserMessage) + } + + // --- Helpers --- + + private clearClarifyEventState(sessionId: string, clarifyId: string) { + const state = this.sessionMap.get(sessionId) + if (!state?.events.length) return + + const nextEvents = state.events.filter(({ event, data }) => { + if (event !== 'clarify.requested' && event !== 'clarify.resolved') return true + return data?.clarify_id !== clarifyId + }) + if (nextEvents.length !== state.events.length) { + state.events = nextEvents + } + } + + private emitToSession(socket: Socket, sessionId: string, event: string, payload: any) { + const tagged = { ...payload, session_id: sessionId } + this.nsp.to(`session:${sessionId}`).emit(event, tagged) + if (!this.nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { + socket.emit(event, tagged) + } + } + + private serializeQueuedMessages(queue: QueuedRun[]) { + return queue.filter(item => item.displayInput !== null).map(item => ({ + id: item.queue_id, + role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'), + content: contentBlocksToString(item.displayInput ?? item.input), + timestamp: Math.floor(Date.now() / 1000), + queued: true, + })) + } + + private canAccessProfile(user: AuthenticatedUser, profile: string): boolean { + return user.role === 'super_admin' || userCanAccessProfile(user.id, profile) + } + + /** Close all active upstream response streams */ + close() { + for (const [sessionId, state] of this.sessionMap.entries()) { + if (state.abortController) { + try { + state.abortController.abort() + } catch (e) { + logger.warn(e, '[chat-run-socket] failed to abort controller for session %s', sessionId) + } + } + } + this.sessionMap.clear() + logger.info('[chat-run-socket] closed all connections and cleared state') + } +} diff --git a/packages/server/src/services/hermes/run-chat/message-format.ts b/packages/server/src/services/hermes/run-chat/message-format.ts new file mode 100644 index 0000000..bff4bbd --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/message-format.ts @@ -0,0 +1,214 @@ +import { parseAnthropicContentArray } from '../../../lib/llm-json' +import { logger } from '../../logger' +import type { SessionMessage } from './types' + +function cleanToolCalls(toolCalls: any): any[] { + return Array.isArray(toolCalls) + ? toolCalls + .filter((tc: any) => tc?.id && String(tc.id).length > 0) + .map((tc: any) => ({ + id: tc.id, + type: tc.type, + function: tc.function, + })) + : [] +} + +function hasSendableContent(content: unknown): boolean { + if (typeof content === 'string') return content.trim().length > 0 + if (Array.isArray(content)) { + return content.some((block: any) => { + if (!block || typeof block !== 'object') return false + if (block.type === 'text') return typeof block.text === 'string' && block.text.trim().length > 0 + return Boolean(block.type && block.type !== 'thinking') + }) + } + return false +} + +function toolCallsToText(toolCalls: any[]): string { + return toolCalls + .map((tc: any) => { + const name = tc?.function?.name || 'unknown' + let args = typeof tc?.function?.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc?.function?.arguments ?? {}) + if (args.length > 4000) args = `${args.slice(0, 4000)}...` + return `[Calling tool: ${name} with arguments: ${args}]` + }) + .join('\n') +} + +export function isAssistantMessageSendable(message: { content?: unknown; tool_calls?: any }): boolean { + if (hasSendableContent(message.content)) return true + return cleanToolCalls(message.tool_calls).length > 0 +} + +/** + * Convert OpenAI format conversation history to Anthropic format. + */ +export function convertHistoryFormat(messages: any[]): any[] { + const result: any[] = [] + + for (const m of messages) { + const role = m.role + const content = m.content || '' + delete m.reasoning_content + if (role === 'tool') { + let pushItem = { ...m } + pushItem.role = 'user' + pushItem.content = `[Tool result: ${content}]` + result.push(pushItem) + continue + } + + if (role === 'user') { + if (typeof content === 'string') { + result.push({ role: 'user', content: content }) + } else if (Array.isArray(content)) { + const textParts = content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join('\n') + result.push({ role: 'user', content: textParts || JSON.stringify(content) }) + } + continue + } + if (role === 'assistant') { + const toolCalls = cleanToolCalls(m.tool_calls) + const item = { ...m } + delete item.reasoning_content + if (toolCalls.length > 0 && !hasSendableContent(item.content)) { + item.content = toolCallsToText(toolCalls) + } + delete item.tool_calls + if (!isAssistantMessageSendable(item)) { + logger.warn('[chat-run-socket] skipped empty assistant message in conversation history') + continue + } + result.push(item) + continue + } + } + return result +} + +/** + * Process raw DB messages into client-ready format. + * Parses Anthropic content blocks, reconstructs tool_call_ids, etc. + */ +export function handleMessage(messages: SessionMessage[], sid: string): any[] { + let _messages = [] + try { + _messages = messages + .filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'command') && m.content !== undefined) + .map((m, idx, arr) => { + const msg: any = { + id: m.id, + session_id: sid, + role: m.role, + content: m.content || '', + reasoning: m.reasoning || '', + timestamp: m.timestamp, + } + // Convert Anthropic format content to OpenAI format + if (m.role === 'assistant' && typeof m.content === 'string') { + let contentToParse = m.content + const trimmed = m.content.trim() + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { + contentToParse = trimmed.slice(1, -1) + logger.info('[chat-run-socket] resume message %s: double-serialized, removed outer quotes', m.id) + } + + if (contentToParse.startsWith('[') && contentToParse.endsWith(']')) { + try { + const parsedContent = parseAnthropicContentArray(contentToParse) + const textBlocks: string[] = [] + const toolCalls: any[] = [] + let reasoningContent: string | null = null + + for (const block of parsedContent) { + if (block.type === 'thinking') { + reasoningContent = block.thinking || null + } else if (block.type === 'text') { + textBlocks.push(block.text || '') + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: typeof block.input === 'object' ? JSON.stringify(block.input) : (block.input ?? '{}'), + }, + }) + } + } + + msg.content = textBlocks.join('') || '' + if (toolCalls.length > 0) msg.tool_calls = toolCalls + if (reasoningContent) msg.reasoning = reasoningContent + } catch (e) { + logger.warn(e, '[chat-run-socket] failed to parse array content for message %s, keeping original', m.id) + msg.content = m.content + } + } + } else if (Array.isArray(m.content)) { + const textBlocks: string[] = [] + const toolCalls: any[] = [] + let reasoningContent: string | null = null + + for (const block of m.content) { + if (block.type === 'thinking') { + reasoningContent = block.thinking + } else if (block.type === 'text') { + textBlocks.push(block.text) + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }) + } + } + + msg.content = textBlocks.join('') || '' + if (toolCalls.length > 0) msg.tool_calls = toolCalls + if (reasoningContent) msg.reasoning = reasoningContent + } + + if (m.tool_calls?.length) { + const cleanedToolCalls = cleanToolCalls(m.tool_calls) + if (cleanedToolCalls.length > 0) msg.tool_calls = cleanedToolCalls + } + + if (m.role === 'assistant' && !isAssistantMessageSendable(msg)) { + logger.warn('[chat-run-socket] skipped empty assistant message %s while loading session %s', m.id, sid) + return null + } + + // For tool messages, ensure tool_call_id exists + if (m.role === 'tool') { + let callId = m.tool_call_id + if (!callId || callId.length === 0) { + const prevMsg = arr[idx - 1] + if (prevMsg?.role === 'assistant' && prevMsg.tool_calls?.length) { + const tc = prevMsg.tool_calls.find((t: any) => t.function?.name === m.tool_name) + if (tc?.id) callId = tc.id + } + } + if (!callId || callId.length === 0) return null + msg.tool_call_id = callId + } + + if (m.tool_name) msg.tool_name = m.tool_name + if (m.reasoning) msg.reasoning = m.reasoning + return msg + }) + .filter(m => m !== null) + } catch (error) { + } + return _messages +} diff --git a/packages/server/src/services/hermes/run-chat/model-config.ts b/packages/server/src/services/hermes/run-chat/model-config.ts new file mode 100644 index 0000000..9aa896c --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/model-config.ts @@ -0,0 +1,47 @@ +import { readConfigYamlForProfile } from '../../config-helpers' + +export type RunModelGroup = { provider: string; models: string[] } + +async function resolveDefaultModelConfig(profile: string): Promise<{ model: string; provider: string }> { + try { + const config = await readConfigYamlForProfile(profile) + const modelConfig = config?.model + const model = typeof modelConfig === 'string' + ? modelConfig.trim() + : String(modelConfig?.default || '').trim() + const provider = typeof modelConfig === 'object' + ? String(modelConfig?.provider || '').trim() + : '' + return { model, provider } + } catch { + return { model: '', provider: '' } + } +} + +function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean { + if (!groups?.length || !provider || !model) return false + const group = groups.find(item => item.provider === provider) + return Array.isArray(group?.models) && group.models.includes(model) +} + +export async function resolveBridgeRunModelConfig(options: { + profile: string + sessionModel?: string | null + sessionProvider?: string | null + requestedModel?: string | null + requestedProvider?: string | null + modelGroups?: RunModelGroup[] +}): Promise<{ model: string; provider: string }> { + const sessionModel = String(options.sessionModel || '').trim() + const sessionProvider = String(options.sessionProvider || '').trim() + const requestedModel = String(options.requestedModel || '').trim() + const requestedProvider = String(options.requestedProvider || '').trim() + const candidateModel = sessionModel || requestedModel + const candidateProvider = sessionProvider || requestedProvider + const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0 + const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel) + const shouldUseDefault = !candidateModel || !candidateProvider || (hasGroups && !candidateAvailable) + return shouldUseDefault + ? resolveDefaultModelConfig(options.profile) + : { model: candidateModel, provider: candidateProvider } +} diff --git a/packages/server/src/services/hermes/run-chat/response-stream.ts b/packages/server/src/services/hermes/run-chat/response-stream.ts new file mode 100644 index 0000000..2ed1ad4 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/response-stream.ts @@ -0,0 +1,210 @@ +/** + * Response stream event handling — maps upstream /v1/responses events + * to client-facing events and updates in-memory session state. + */ + +import { addMessage } from '../../../db/hermes/session-store' +import { logger } from '../../logger' +import { summarizeToolArguments, responseFunctionCallToToolCall } from './response-utils' +import type { SessionState, ResponseRunState } from './types' + +export function applyResponseStreamEvent( + state: SessionState, + sessionId: string, + runMarker: string | undefined, + eventType: string, + parsed: any, +): { event: string; payload: any; runId?: string } | null { + const run = getResponseRunState(state, runMarker) + const now = () => Math.floor(Date.now() / 1000) + + if (eventType === 'response.created') { + const response = parsed.response || parsed + run.responseId = response.id || run.responseId + return { + event: 'run.started', + runId: run.responseId, + payload: { + event: 'run.started', + run_id: run.responseId, + response_id: run.responseId, + status: response.status || 'in_progress', + queue_length: state.queue.length || 0, + }, + } + } + + if (eventType === 'response.output_text.delta') { + const deltaText = parsed.delta || parsed.text || '' + if (!deltaText) return null + + const last = [...state.messages].reverse().find(m => m.runMarker === runMarker) + if (last?.role === 'assistant' && last.finish_reason == null && !last.tool_calls?.length) { + last.content += deltaText + } else { + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: deltaText, + timestamp: now(), + }) + } + return { + event: 'message.delta', + payload: { + event: 'message.delta', + run_id: run.responseId, + response_id: run.responseId, + delta: deltaText, + }, + } + } + + if (eventType === 'response.output_text.done') { + const last = [...state.messages].reverse().find(m => m.runMarker === runMarker) + if (last?.role === 'assistant' && last.finish_reason == null) { + last.finish_reason = 'stop' + } + return null + } + + if (eventType === 'response.output_item.added') { + const item = parsed.item || parsed.output_item || parsed + if (item.type !== 'function_call') return null + const callId = item.call_id || item.id + if (!callId) return null + const toolCall = responseFunctionCallToToolCall(item) + run.toolCalls.set(callId, { ...toolCall, startedAt: Date.now() }) + return { + event: 'tool.started', + payload: { + event: 'tool.started', + run_id: run.responseId, + response_id: run.responseId, + tool_call_id: callId, + tool: toolCall.function.name, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + preview: summarizeToolArguments(toolCall.function.arguments), + }, + } + } + + if (eventType === 'response.output_item.done') { + const item = parsed.item || parsed.output_item || parsed + if (item.type === 'function_call') { + const callId = item.call_id || item.id + if (!callId) return null + const toolCall = responseFunctionCallToToolCall(item) + const existing = run.toolCalls.get(callId) + run.toolCalls.set(callId, { ...toolCall, startedAt: existing?.startedAt || Date.now() }) + + const key = `assistant:${callId}` + if (!run.insertedKeys.has(key)) { + run.insertedKeys.add(key) + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'assistant', + content: '', + tool_calls: [toolCall], + finish_reason: 'tool_calls', + timestamp: now(), + }) + } + return null + } + + if (item.type === 'function_call_output') { + const callId = item.call_id || item.id + if (!callId) return null + const key = `tool:${callId}` + const output = typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? '') + const toolCallEntry = run.toolCalls.get(callId) + const toolName = toolCallEntry?.function?.name || null + const startedAt = toolCallEntry?.startedAt + const duration = startedAt ? Math.round((Date.now() - startedAt) / 10) / 100 : undefined + const hasError = typeof item.output === 'string' && item.output.startsWith('Error') + if (!run.insertedKeys.has(key)) { + run.insertedKeys.add(key) + state.messages.push({ + id: state.messages.length + 1, + session_id: sessionId, + runMarker, + role: 'tool', + content: output, + tool_call_id: callId, + tool_name: toolName, + timestamp: now(), + }) + } + return { + event: 'tool.completed', + payload: { + event: 'tool.completed', + run_id: run.responseId, + response_id: run.responseId, + tool_call_id: callId, + tool: toolName, + name: toolName, + output, + duration, + error: hasError || undefined, + }, + } + } + } + + if (eventType === 'response.completed') { + const response = parsed.response || parsed + run.responseId = response.id || run.responseId + const output = Array.isArray(response.output) ? response.output : [] + for (const item of output) { + if (item.type === 'function_call') { + applyResponseStreamEvent(state, sessionId, runMarker, 'response.output_item.added', { item }) + applyResponseStreamEvent(state, sessionId, runMarker, 'response.output_item.done', { item }) + } else if (item.type === 'function_call_output') { + applyResponseStreamEvent(state, sessionId, runMarker, 'response.output_item.done', { item }) + } + } + } + + return null +} + +export function getResponseRunState(state: SessionState, runMarker?: string): ResponseRunState { + if (!state.responseRun || state.responseRun.runMarker !== runMarker) { + state.responseRun = { + runMarker, + insertedKeys: new Set(), + toolCalls: new Map(), + } + } + return state.responseRun +} + +/** Flush all non-user messages for this run to DB in order. */ +export function flushResponseRunToDb(state: SessionState, sessionId: string) { + const run = state.responseRun + if (!run?.runMarker) return + let flushed = 0 + for (const msg of state.messages) { + if (msg.runMarker !== run.runMarker) continue + if (msg.role === 'user') continue + addMessage({ + session_id: sessionId, + role: msg.role, + content: msg.content || '', + tool_call_id: msg.tool_call_id ?? null, + tool_calls: msg.tool_calls ?? null, + tool_name: msg.tool_name ?? null, + finish_reason: msg.finish_reason ?? null, + timestamp: msg.timestamp, + }) + flushed++ + } + logger.info('[chat-run-socket] flushResponseRunToDb: flushed %d messages for session %s', flushed, sessionId) +} diff --git a/packages/server/src/services/hermes/run-chat/response-utils.ts b/packages/server/src/services/hermes/run-chat/response-utils.ts new file mode 100644 index 0000000..25ddca1 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/response-utils.ts @@ -0,0 +1,56 @@ +/** + * Response utility functions for processing upstream API responses. + */ + +export function responseFunctionCallToToolCall(item: any): any { + const callId = item.call_id || item.id || '' + const name = item.name || item.function?.name || '' + let args = item.arguments ?? item.function?.arguments ?? '{}' + if (typeof args !== 'string') { + args = JSON.stringify(args ?? {}) + } + return { + id: callId, + type: 'function', + function: { + name, + arguments: args || '{}', + }, + } +} + +export function summarizeToolArguments(args: string): string | undefined { + if (!args) return undefined + try { + const parsed = JSON.parse(args) + if (!parsed || typeof parsed !== 'object') return args.slice(0, 120) + const preferredKeys = ['cmd', 'command', 'code', 'query', 'path', 'url', 'prompt'] + for (const key of preferredKeys) { + const value = parsed[key] + if (typeof value === 'string' && value.trim()) { + return value.replace(/\s+/g, ' ').slice(0, 160) + } + } + const first = Object.entries(parsed).find(([, value]) => typeof value === 'string' && value.trim()) + if (first) return String(first[1]).replace(/\s+/g, ' ').slice(0, 160) + return JSON.stringify(parsed).slice(0, 160) + } catch { + return args.replace(/\s+/g, ' ').slice(0, 160) + } +} + +export function extractResponseText(response: any): string { + const output = Array.isArray(response?.output) ? response.output : [] + const parts: string[] = [] + for (const item of output) { + if (item.type !== 'message') continue + const content = Array.isArray(item.content) ? item.content : [] + for (const part of content) { + if (part.type === 'output_text' || part.type === 'text') { + parts.push(part.text || '') + } + } + } + if (parts.length > 0) return parts.join('') + return typeof response?.output_text === 'string' ? response.output_text : '' +} diff --git a/packages/server/src/services/hermes/run-chat/session-command.ts b/packages/server/src/services/hermes/run-chat/session-command.ts new file mode 100644 index 0000000..83d3d55 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/session-command.ts @@ -0,0 +1,688 @@ +import type { Server, Socket } from 'socket.io' +import { addMessage, clearSessionMessages, createSession, getSession, renameSession, updateSessionStats } from '../../../db/hermes/session-store' +import { logger } from '../../logger' +import type { AgentBridgeClient } from '../agent-bridge' +import { flushBridgePendingToDb } from './bridge-message' +import { buildDbHistory, estimateSnapshotAwareHistoryUsage, forceCompressBridgeHistory, getOrCreateSession, replaceState } from './compression' +import { handleAbort } from './abort' +import { calcAndUpdateUsage, contextTokensWithCachedOverhead, updateMessageContextTokenUsage } from './usage' +import { contentBlocksToString } from './content-blocks' +import type { ContentBlock, QueuedRun, SessionState } from './types' + +type CommandName = + | 'usage' + | 'status' + | 'abort' + | 'queue' + | 'plan' + | 'goal' + | 'subgoal' + | 'clear' + | 'title' + | 'compress' + | 'steer' + | 'destroy' + | 'reload-mcp' + +interface ParsedSessionCommand { + name: CommandName + rawName: string + args: string +} + +interface SessionCommandContext { + nsp: ReturnType + socket: Socket + sessionMap: Map + bridge: AgentBridgeClient + profile: string + model?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + instructions?: string + queueId?: string + runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void +} + +const COMMAND_ALIASES: Record = { + usage: 'usage', + status: 'status', + abort: 'abort', + queue: 'queue', + plan: 'plan', + goal: 'goal', + subgoal: 'subgoal', + clear: 'clear', + title: 'title', + compress: 'compress', + steer: 'steer', + destroy: 'destroy', + destory: 'destroy', + 'reload-mcp': 'reload-mcp', +} + +export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null { + if (typeof input !== 'string') return null + const trimmed = input.trim() + if (!trimmed.startsWith('/')) return null + const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/) + if (!match) return null + const rawName = match[1].toLowerCase() + const name = COMMAND_ALIASES[rawName] + if (!name) return { name: 'status', rawName, args: match[2]?.trim() || '' } + return { name, rawName, args: match[2]?.trim() || '' } +} + +export function isSessionCommand(input: string | ContentBlock[]): boolean { + return parseSessionCommand(input) !== null +} + +export async function handleSessionCommand( + sessionId: string, + command: ParsedSessionCommand, + ctx: SessionCommandContext, +): Promise { + const state = getOrCreateSession(ctx.sessionMap, sessionId) + ctx.socket.join(`session:${sessionId}`) + ensureCommandSession(sessionId, ctx) + if (command.name !== 'plan') { + persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`) + } + + const emitCommand = (payload: Record) => { + const message = typeof payload.message === 'string' ? payload.message : '' + if (message) persistCommandMessage(sessionId, state, message) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'session.command', { + event: 'session.command', + session_id: sessionId, + command: command.rawName, + ok: true, + ...payload, + }) + } + + if (!COMMAND_ALIASES[command.rawName]) { + emitCommand({ + ok: false, + action: 'error', + terminal: !state.isWorking, + message: `Unknown bridge command: /${command.rawName}`, + }) + return + } + + switch (command.name) { + case 'usage': { + const usage = await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + emitCommand({ + action: 'usage', + terminal: !state.isWorking, + message: `Usage: input ${usage.inputTokens}, output ${usage.outputTokens}, total ${usage.inputTokens + usage.outputTokens} tokens.`, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + }) + return + } + + case 'status': { + const row = getSession(sessionId) + const bridgeStatus = await getBridgeSessionStatus(ctx, sessionId) + const bridgeRunning = bridgeStatus?.running === true + const isWorking = state.isWorking || bridgeRunning + const runId = state.runId || state.activeRunMarker || bridgeStatus?.currentRunId || null + emitCommand({ + action: 'status', + terminal: !isWorking, + message: [ + `Status: ${isWorking ? 'running' : 'idle'}`, + `source: ${state.source || row?.source || 'cli'}`, + `profile: ${state.profile || ctx.profile || row?.profile || 'default'}`, + `model: ${ctx.model || row?.model || '-'}`, + `queue: ${state.queue.length}`, + `run: ${runId || '-'}`, + bridgeStatus ? `bridge: ${bridgeRunning ? 'running' : 'idle'}` : null, + ].filter(Boolean).join(', '), + isWorking, + isAborting: Boolean(state.isAborting), + queueLength: state.queue.length, + source: state.source || row?.source || 'cli', + profile: state.profile || ctx.profile || row?.profile || 'default', + model: ctx.model || row?.model || null, + runId, + bridgeStatus, + }) + return + } + + case 'abort': + await handleAbort(ctx.nsp, ctx.socket, sessionId, ctx.sessionMap, ctx.bridge, ctx.runQueuedItem) + emitCommand({ action: 'abort', message: 'Abort requested.' }) + return + + case 'queue': { + if (!command.args) { + emitCommand({ ok: false, action: 'queue', terminal: !state.isWorking, message: 'Usage: /queue ' }) + return + } + if (!state.isWorking) { + emitCommand({ ok: false, action: 'queue', message: 'Session is idle. Send the message normally instead.' }) + return + } + const queueId = `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + state.queue.push({ + queue_id: queueId, + input: command.args, + model: ctx.model, + provider: ctx.provider, + model_groups: ctx.model_groups, + instructions: ctx.instructions, + profile: ctx.profile, + source: 'cli', + originSocketId: ctx.socket.id, + }) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + queued_messages: serializeVisibleQueuedMessages(state.queue), + }) + emitCommand({ + action: 'queue', + terminal: false, + message: `Queued message. Queue length: ${state.queue.length}.`, + queueLength: state.queue.length, + }) + return + } + + case 'plan': { + const bridgeCommand = `plan${command.args ? ` ${command.args}` : ''}` + let result + try { + result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile) + } catch (err) { + emitCommand({ + ok: false, + action: 'plan', + terminal: !state.isWorking, + message: `Plan command failed: ${err instanceof Error ? err.message : String(err)}`, + }) + return + } + + if (!result.handled || !result.message) { + emitCommand({ + ok: false, + action: 'plan', + terminal: !state.isWorking, + message: result.message || 'Plan command is not available.', + }) + return + } + + const queueId = ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const displayCommand = `/${bridgeCommand}` + const next: QueuedRun = { + queue_id: queueId, + input: result.message, + displayInput: displayCommand, + displayRole: 'command', + storageMessage: displayCommand, + model: ctx.model, + provider: ctx.provider, + model_groups: ctx.model_groups, + instructions: ctx.instructions, + profile: ctx.profile, + source: 'cli', + originSocketId: ctx.socket.id, + } + + if (state.isWorking) { + state.queue.push(next) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + queued_messages: serializeVisibleQueuedMessages(state.queue), + }) + return + } + + emitCommand({ + action: 'plan', + terminal: false, + started: true, + }) + ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile) + return + } + + case 'goal': + case 'subgoal': { + const isGoalSet = command.name === 'goal' + && Boolean(command.args) + && !['status', 'pause', 'resume', 'clear', 'stop', 'done'].includes(command.args.toLowerCase()) + if (state.isWorking && isGoalSet) { + emitCommand({ + ok: false, + action: 'goal', + terminal: false, + message: 'Agent is running. Use /goal status, /goal pause, or /goal clear mid-run, or /abort before setting a new goal.', + }) + return + } + + const bridgeCommand = `${command.name}${command.args ? ` ${command.args}` : ''}` + let result + try { + result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile) + } catch (err) { + emitCommand({ + ok: false, + action: command.name, + terminal: !state.isWorking, + message: `Goal command failed: ${err instanceof Error ? err.message : String(err)}`, + }) + return + } + + if (result.clear_goal_continuations) { + const removed = removeGoalContinuationRuns(state) + if (removed > 0) emitQueuedState(ctx, sessionId, state) + } + + const kickoffPrompt = typeof result.kickoff_prompt === 'string' ? result.kickoff_prompt.trim() : '' + + const bridgeStatus = result.action === 'goal_status' || result.action === 'status' + ? await getBridgeSessionStatus(ctx, sessionId) + : null + const message = formatGoalStatusMessage(String(result.message || ''), bridgeStatus) + + const resultAction = String(result.action || command.name) + const action = (command.name === 'goal' || command.name === 'subgoal') && resultAction === 'clear' + ? `${command.name}_clear` + : resultAction + + emitCommand({ + action, + terminal: !state.isWorking && !kickoffPrompt, + started: Boolean(kickoffPrompt), + message, + type: result.type || 'goal', + maxTurns: result.max_turns, + bridgeStatus, + }) + + if (!kickoffPrompt) return + + const next: QueuedRun = { + queue_id: ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + input: kickoffPrompt, + displayInput: null, + storageMessage: kickoffPrompt, + model: ctx.model, + provider: ctx.provider, + model_groups: ctx.model_groups, + instructions: ctx.instructions, + profile: ctx.profile, + source: 'cli', + originSocketId: ctx.socket.id, + } + + if (state.isWorking) { + state.queue.push(next) + emitQueuedState(ctx, sessionId, state) + return + } + + ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile) + return + } + + case 'clear': { + if (command.args === '--history') { + if (state.isWorking) { + emitCommand({ + ok: false, + action: 'clear', + terminal: false, + message: 'Cannot clear history while the bridge run is active. Abort or destroy it first.', + }) + return + } + const deleted = clearSessionMessages(sessionId) + state.messages = [] + clearTransientRunState(state) + await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + emitCommand({ + action: 'clear', + clearHistory: true, + message: `Cleared ${deleted} history messages from the database.`, + }) + return + } + emitCommand({ + action: 'clear', + message: 'Cleared the current display. History in the database was not deleted.', + }) + return + } + + case 'title': { + if (!command.args) { + emitCommand({ ok: false, action: 'title', terminal: !state.isWorking, message: 'Usage: /title ' }) + return + } + const title = command.args.slice(0, 120) + if (!getSession(sessionId)) { + createSession({ id: sessionId, profile: ctx.profile, source: 'cli', model: ctx.model, title }) + } + const updated = renameSession(sessionId, title) + emitCommand({ + ok: updated, + action: 'title', + title, + message: updated ? `Title updated: ${title}` : 'Session was not found in the database.', + }) + return + } + + case 'compress': { + if (state.isWorking) { + emitCommand({ ok: false, action: 'compress', terminal: false, message: 'Compression can only run while the session is idle.' }) + return + } + clearTransientRunState(state) + const emit = (event: string, payload: any) => emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + try { + const history = await buildDbHistory(sessionId, { excludeLastUser: true }) + const usageEstimate = estimateSnapshotAwareHistoryUsage(sessionId, history) + const beforeContextTokens = contextTokensWithCachedOverhead(state, usageEstimate.tokenCount) + emit('compression.started', { + event: 'compression.started', + message_count: usageEstimate.messageCount, + token_count: beforeContextTokens, + source: 'command', + }) + const result = await forceCompressBridgeHistory( + sessionId, + ctx.profile, + [], + ) + state.bridgeCompressionResults = state.bridgeCompressionResults || {} + const usage = await calcAndUpdateUsage(sessionId, state, emit) + const afterContextTokens = contextTokensWithCachedOverhead(state, result.afterTokens) + emit('compression.completed', { + event: 'compression.completed', + compressed: result.compressed, + llmCompressed: result.llmCompressed, + totalMessages: result.beforeMessages, + resultMessages: result.resultMessages, + beforeTokens: beforeContextTokens, + afterTokens: result.afterTokens, + summaryTokens: result.summaryTokens, + verbatimCount: result.verbatimCount, + compressedStartIndex: result.compressedStartIndex, + contextTokens: afterContextTokens, + source: 'command', + }) + updateMessageContextTokenUsage(sessionId, state, emit, result.afterTokens, usage) + emitCommand({ + action: 'compress', + message: `Compression completed: ${result.beforeMessages} -> ${result.resultMessages} messages, ${beforeContextTokens} -> ${afterContextTokens} tokens.`, + beforeMessages: result.beforeMessages, + resultMessages: result.resultMessages, + beforeTokens: beforeContextTokens, + afterTokens: afterContextTokens, + messageBeforeTokens: result.beforeTokens, + messageAfterTokens: result.afterTokens, + compressed: result.compressed, + }) + } catch (err) { + logger.warn(err, '[chat-run-socket] /compress failed for session %s', sessionId) + emit('compression.completed', { + event: 'compression.completed', + compressed: false, + totalMessages: 0, + resultMessages: 0, + beforeTokens: 0, + afterTokens: 0, + error: err instanceof Error ? err.message : String(err), + source: 'command', + }) + emitCommand({ + ok: false, + action: 'compress', + message: `Compression failed: ${err instanceof Error ? err.message : String(err)}`, + }) + } + return + } + + case 'steer': { + if (!command.args) { + emitCommand({ ok: false, action: 'steer', terminal: !state.isWorking, message: 'Usage: /steer ' }) + return + } + if (!state.isWorking) { + emitCommand({ ok: false, action: 'steer', message: 'No active bridge run to steer.' }) + return + } + await ctx.bridge.steer(sessionId, command.args) + emitCommand({ action: 'steer', terminal: false, message: 'Steer instruction sent.' }) + return + } + + case 'reload-mcp': { + if (state.isWorking) { + emitCommand({ + ok: false, + action: 'reload-mcp', + terminal: false, + message: 'MCP reload can only run while the session is idle. Wait for the current run to finish or abort it first.', + }) + return + } + try { + const server = command.args || undefined + const result = await ctx.bridge.mcpReload(server, ctx.profile) + emitCommand({ + action: 'reload-mcp', + message: `MCP reloaded successfully.${server ? ` Server: ${server}` : ' All servers.'}`, + result, + }) + } catch (err) { + emitCommand({ + ok: false, + action: 'reload-mcp', + terminal: !state.isWorking, + message: `MCP reload failed: ${err instanceof Error ? err.message : String(err)}`, + }) + } + return + } + + case 'destroy': { + const wasWorking = state.isWorking + let bridgeReachable = true + let bridgeError: string | null = null + try { + if (wasWorking) { + flushBridgePendingToDb(state, sessionId) + await ctx.bridge.interrupt(sessionId, 'Destroyed by user', state.profile).catch((err) => { + logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId) + }) + } + await ctx.bridge.destroy(sessionId, state.profile).catch((err) => { + bridgeReachable = false + bridgeError = err instanceof Error ? err.message : String(err) + logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId) + }) + } finally { + updateSessionStats(sessionId) + await calcAndUpdateUsage(sessionId, state, (event, payload) => { + emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload) + }) + state.isWorking = false + state.isAborting = false + state.profile = undefined + state.abortController = undefined + state.runId = undefined + state.responseRun = undefined + state.activeRunMarker = undefined + state.events = [] + state.queue = [] + state.bridgePendingAssistantContent = undefined + state.bridgePendingReasoningContent = undefined + state.bridgePendingToolCallMarkup = undefined + state.bridgeOutput = undefined + state.bridgePendingTools = undefined + state.bridgeCompressionResults = undefined + replaceState(ctx.sessionMap, sessionId, 'session.command', { + event: 'session.command', + action: 'destroy', + }) + } + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: 0, + }) + emitCommand({ + action: 'destroy', + message: bridgeReachable + ? (wasWorking ? 'Destroyed bridge agent and stopped the active run.' : 'Destroyed bridge agent.') + : `Bridge agent was not reachable; cleared local session state.${bridgeError ? ` (${bridgeError})` : ''}`, + destroyed: true, + bridgeReachable, + }) + return + } + } +} + +function clearTransientRunState(state: SessionState) { + state.events = [] + state.bridgePendingTools = undefined + state.bridgePendingToolCallMarkup = undefined + state.bridgeCompressionResults = undefined + state.responseRun = undefined + state.activeRunMarker = undefined + state.runId = undefined + state.abortController = undefined + state.isAborting = false +} + +function removeGoalContinuationRuns(state: SessionState): number { + const before = state.queue.length + state.queue = state.queue.filter(item => !item.goalContinuation) + return before - state.queue.length +} + +function emitQueuedState(ctx: SessionCommandContext, sessionId: string, state: SessionState) { + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + queued_messages: serializeVisibleQueuedMessages(state.queue), + }) +} + +function serializeVisibleQueuedMessages(queue: QueuedRun[]) { + return queue.filter(item => item.displayInput !== null).map(item => ({ + id: item.queue_id, + role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'), + content: contentBlocksToString(item.displayInput ?? item.input), + timestamp: Math.floor(Date.now() / 1000), + queued: true, + })) +} + +type BridgeSessionStatus = { + exists: boolean + running: boolean + currentRunId: string | null + messageCount: number +} + +async function getBridgeSessionStatus(ctx: SessionCommandContext, sessionId: string): Promise { + try { + const raw = await ctx.bridge.status(sessionId, ctx.profile) as Record + return { + exists: raw.exists === true, + running: raw.running === true, + currentRunId: typeof raw.current_run_id === 'string' && raw.current_run_id.trim() + ? raw.current_run_id + : null, + messageCount: typeof raw.message_count === 'number' && Number.isFinite(raw.message_count) + ? raw.message_count + : 0, + } + } catch (err) { + logger.debug({ err, sessionId }, '[chat-run-socket] bridge status lookup failed') + return null + } +} + +function formatGoalStatusMessage(message: string, bridgeStatus: BridgeSessionStatus | null): string { + if (!bridgeStatus) return message + const lines = [message] + if (bridgeStatus.running) { + const progress = parseGoalTurnProgress(message) + lines.push(progress + ? `Current turn: ${Math.min(progress.used + 1, progress.max)}/${progress.max} running (completed turns: ${progress.used}/${progress.max}; count updates after the judge).` + : 'Current turn: running (turn count updates after the judge).') + } + lines.push(`Run: ${bridgeStatus.running ? 'running' : 'idle'}${bridgeStatus.currentRunId ? ` (${bridgeStatus.currentRunId})` : ''}`) + return lines.filter(Boolean).join('\n') +} + +function parseGoalTurnProgress(message: string): { used: number; max: number } | null { + const match = message.match(/\b(\d+)\s*\/\s*(\d+)\s+turns\b/i) + if (!match) return null + const used = Number(match[1]) + const max = Number(match[2]) + if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return null + return { used, max } +} + +function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) { + if (getSession(sessionId)) return + createSession({ + id: sessionId, + profile: ctx.profile, + source: 'cli', + model: ctx.model, + title: 'Bridge command', + }) +} + +function persistCommandMessage(sessionId: string, state: SessionState, content: string) { + const now = Math.floor(Date.now() / 1000) + const id = addMessage({ + session_id: sessionId, + role: 'command', + content, + timestamp: now, + }) + state.messages.push({ + id: id || `command_${now}_${state.messages.length}`, + session_id: sessionId, + role: 'command', + content, + timestamp: now, + }) + updateSessionStats(sessionId) +} + +function emitToSession(nsp: ReturnType, socket: Socket, sessionId: string, event: string, payload: any) { + const tagged = { ...payload, session_id: sessionId } + nsp.to(`session:${sessionId}`).emit(event, tagged) + if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { + socket.emit(event, tagged) + } +} diff --git a/packages/server/src/services/hermes/run-chat/sse-utils.ts b/packages/server/src/services/hermes/run-chat/sse-utils.ts new file mode 100644 index 0000000..763afc9 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/sse-utils.ts @@ -0,0 +1,47 @@ +/** + * SSE frame reading utilities for parsing upstream streaming responses. + */ + +export async function* readSseFrames(stream: ReadableStream): AsyncGenerator<{ event?: string; data: string }> { + const decoder = new TextDecoder() + const reader = stream.getReader() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let boundary = buffer.indexOf('\n\n') + while (boundary >= 0) { + const raw = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + const frame = parseSseFrame(raw) + if (frame?.data) yield frame + boundary = buffer.indexOf('\n\n') + } + } + + buffer += decoder.decode() + const frame = parseSseFrame(buffer) + if (frame?.data) yield frame + } finally { + reader.releaseLock() + } +} + +export function parseSseFrame(raw: string): { event?: string; data: string } | null { + let event: string | undefined + const data: string[] = [] + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith(':')) continue + if (line.startsWith('event:')) { + event = line.slice(6).trim() + } else if (line.startsWith('data:')) { + data.push(line.slice(5).trimStart()) + } + } + if (data.length === 0) return null + return { event, data: data.join('\n') } +} diff --git a/packages/server/src/services/hermes/run-chat/types.ts b/packages/server/src/services/hermes/run-chat/types.ts new file mode 100644 index 0000000..e210849 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/types.ts @@ -0,0 +1,110 @@ +import type { ChatMessage } from '../../../lib/context-compressor' + +/** + * Content block types for Anthropic-compatible message format + */ +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; name: string; path: string; media_type: string } + | { type: 'file'; name: string; path: string; media_type?: string } + +export interface SessionMessage { + id: number | string + session_id: string + role: string + content: string + runMarker?: string + tool_call_id?: string | null + tool_calls?: any[] | null + tool_name?: string | null + timestamp: number + token_count?: number | null + finish_reason?: string | null + reasoning?: string | null + reasoning_details?: string | null + reasoning_content?: string | null +} + +export interface QueuedRun { + queue_id: string + input: string | ContentBlock[] + displayInput?: string | ContentBlock[] | null + displayRole?: 'user' | 'command' + storageMessage?: string + model?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + instructions?: string + profile: string + source?: ChatRunSource + originSocketId?: string + goalContinuation?: boolean +} + +export interface SessionState { + messages: SessionMessage[] + messageTotal?: number + messageLoadedCount?: number + messagePageLimit?: number + hasMoreBefore?: boolean + isWorking: boolean + events: Array<{ event: string; data: any }> + abortController?: AbortController + runId?: string + activeRunMarker?: string + profile?: string + inputTokens?: number + outputTokens?: number + contextTokens?: number + bridgeContext?: BridgeContextState + isAborting?: boolean + queue: QueuedRun[] + responseRun?: ResponseRunState + source?: ChatRunSource + bridgePendingAssistantContent?: string + bridgePendingReasoningContent?: string + bridgePendingToolCallMarkup?: string + bridgeOutput?: string + bridgeToolCounter?: number + bridgePendingTools?: Array<{ + id: string + name: string + arguments: string + startedAt: number + }> + bridgeCompressionResults?: Record +} + +export interface ResponseRunState { + runMarker?: string + responseId?: string + insertedKeys: Set + toolCalls: Map +} + +export interface BridgeContextState { + fixedContextTokens?: number + systemPromptTokens?: number + toolTokens?: number + systemPromptChars?: number + toolCount?: number + toolNames?: string[] + profile?: string + model?: string + provider?: string +} + +export type ChatRunSource = 'api_server' | 'cli' + +export interface BridgeCompressionResult { + messages: ChatMessage[] + beforeMessages: number + resultMessages: number + beforeTokens: number + afterTokens: number + compressed: boolean + llmCompressed: boolean + summaryTokens: number + verbatimCount: number + compressedStartIndex: number +} diff --git a/packages/server/src/services/hermes/run-chat/usage.ts b/packages/server/src/services/hermes/run-chat/usage.ts new file mode 100644 index 0000000..65222d8 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/usage.ts @@ -0,0 +1,137 @@ +/** + * Usage calculation — token counting from DB messages, + * snapshot-aware computation, client notification. + */ + +import { + getSessionDetail, +} from '../../../db/hermes/session-store' +import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot' +import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' +import { logger } from '../../logger' +import type { SessionState } from './types' + +type UsageTokenMessage = { + role?: string + content?: unknown + tool_calls?: unknown +} + +function contentToUsageText(content: unknown): string { + if (typeof content === 'string') return content + if (!content) return '' + if (Array.isArray(content)) { + return content.map((block: any) => { + if (typeof block?.text === 'string') return block.text + if (typeof block?.type === 'string') return `[${block.type}]` + return String(block || '') + }).join('\n') + } + return String(content) +} + +export function estimateUsageTokensFromMessages(messages: UsageTokenMessage[]): { inputTokens: number; outputTokens: number } { + const inputTokens = messages + .filter(m => m.role === 'user') + .reduce((sum, m) => sum + countTokens(contentToUsageText(m.content)), 0) + const outputTokens = messages + .filter(m => m.role === 'assistant' || m.role === 'tool') + .reduce((sum, m) => sum + countTokens(contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0) + return { inputTokens, outputTokens } +} + +export async function calcAndUpdateUsage( + sid: string, + state: SessionState, + emit: (event: string, payload: any) => void, +): Promise<{ inputTokens: number; outputTokens: number }> { + try { + const detail = getSessionDetail(sid) + const msgs = detail?.messages + ?.filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') || [] + + const snapshot = getCompressionSnapshot(sid) + let inputTokens: number + let outputTokens: number + if (snapshot && msgs.length && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < msgs.length) { + const newMessages = msgs.slice(snapshot.lastMessageIndex + 1) + const newUsage = estimateUsageTokensFromMessages(newMessages) + inputTokens = countTokens(SUMMARY_PREFIX + snapshot.summary) + + newUsage.inputTokens + outputTokens = newUsage.outputTokens + } else { + const usage = estimateUsageTokensFromMessages(msgs) + inputTokens = usage.inputTokens + outputTokens = usage.outputTokens + } + state.inputTokens = inputTokens + state.outputTokens = outputTokens + emit('usage.updated', { + event: 'usage.updated', + session_id: sid, + inputTokens, + outputTokens, + }) + return { inputTokens, outputTokens } + } catch (err: any) { + logger.warn(err, '[chat-run-socket] failed to calculate usage for session %s', sid) + return { inputTokens: 0, outputTokens: 0 } + } +} + +export function updateContextTokenUsage( + sid: string, + state: SessionState, + emit: (event: string, payload: any) => void, + contextTokens: number | null | undefined, + usage?: { inputTokens: number; outputTokens: number }, +): number | undefined { + if (typeof contextTokens !== 'number' || !Number.isFinite(contextTokens) || contextTokens < 0) { + return state.contextTokens + } + const normalizedContextTokens = Math.floor(contextTokens) + state.contextTokens = normalizedContextTokens + emit('usage.updated', { + event: 'usage.updated', + session_id: sid, + inputTokens: usage?.inputTokens ?? state.inputTokens ?? 0, + outputTokens: usage?.outputTokens ?? state.outputTokens ?? 0, + contextTokens: normalizedContextTokens, + }) + return normalizedContextTokens +} + +export function getCachedBridgeContextOverhead(state: SessionState): number | undefined { + const fixedContextTokens = state.bridgeContext?.fixedContextTokens + if (typeof fixedContextTokens !== 'number' || !Number.isFinite(fixedContextTokens) || fixedContextTokens < 0) { + return undefined + } + return Math.floor(fixedContextTokens) +} + +export function contextTokensWithCachedOverhead(state: SessionState, messageTokens: number): number { + const normalizedMessageTokens = Math.max(0, Math.floor(messageTokens)) + const fixedContextTokens = getCachedBridgeContextOverhead(state) + return fixedContextTokens == null + ? normalizedMessageTokens + : fixedContextTokens + normalizedMessageTokens +} + +export function updateMessageContextTokenUsage( + sid: string, + state: SessionState, + emit: (event: string, payload: any) => void, + messageTokens: number | null | undefined, + usage?: { inputTokens: number; outputTokens: number }, +): number | undefined { + if (typeof messageTokens !== 'number' || !Number.isFinite(messageTokens) || messageTokens < 0) { + return state.contextTokens + } + return updateContextTokenUsage( + sid, + state, + emit, + contextTokensWithCachedOverhead(state, messageTokens), + usage, + ) +} diff --git a/packages/server/src/services/hermes/session-deleter.ts b/packages/server/src/services/hermes/session-deleter.ts new file mode 100644 index 0000000..8d36261 --- /dev/null +++ b/packages/server/src/services/hermes/session-deleter.ts @@ -0,0 +1,109 @@ +/** + * Session Deleter — periodically drains pending session deletes. + * + * Reads from gc_pending_session_deletes table, executes deletion via + * Hermes CLI, tracks failures (max 3 attempts), and auto-drains on + * a timer + profile switch. + */ +import { getDb } from '../../db/index' +import { deleteSession as hermesDeleteSession } from './hermes-cli' +import { logger } from '../logger' + +const MAX_ATTEMPTS = 3 +const DRAIN_INTERVAL_MS = 300_000 + +export class SessionDeleter { + private static _instance: SessionDeleter | null = null + private timer: ReturnType | null = null + private currentProfile: string = 'default' + + static getInstance(): SessionDeleter { + if (!SessionDeleter._instance) { + SessionDeleter._instance = new SessionDeleter() + } + return SessionDeleter._instance + } + + /** Start periodic drain for the given profile */ + start(profile: string): void { + this.currentProfile = profile + this.stop() + logger.info('[SessionDeleter] started, profile=%s, interval=%dms', profile, DRAIN_INTERVAL_MS) + // Drain immediately on start, then on interval + this.drain(profile).catch(() => {}) + this.timer = setInterval(() => { + this.drain(profile).catch(() => {}) + }, DRAIN_INTERVAL_MS) + } + + /** Switch to a new profile, stop old timer and start new one */ + switchProfile(newProfile: string): void { + if (newProfile !== this.currentProfile) { + logger.info('[SessionDeleter] switching profile %s -> %s', this.currentProfile, newProfile) + this.start(newProfile) + } + } + + /** Stop periodic drain */ + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Drain pending deletes for a specific profile (called on profile switch or manually) */ + async drain(profile: string): Promise<{ deleted: string[]; skipped: string[]; failed: string[] }> { + const db = getDb() + if (!db) return { deleted: [], skipped: [], failed: [] } + + const now = Date.now() + const rows = db.prepare(` + SELECT session_id, profile_name, status, attempt_count, last_error + FROM gc_pending_session_deletes + WHERE profile_name = ? AND status = 'pending' AND attempt_count < ? AND next_attempt_at <= ? + ORDER BY created_at ASC + LIMIT 50 + `).all(profile, MAX_ATTEMPTS, now) as Array<{ + session_id: string + profile_name: string + status: string + attempt_count: number + last_error: string | null + }> + + if (rows.length === 0) return { deleted: [], skipped: [], failed: [] } + + const deleted: string[] = [] + const skipped: string[] = [] + const failed: string[] = [] + + for (const row of rows) { + try { + const ok = await hermesDeleteSession(row.session_id) + if (ok) { + db.prepare('DELETE FROM gc_pending_session_deletes WHERE session_id = ?').run(row.session_id) + db.prepare('DELETE FROM gc_session_profiles WHERE session_id = ?').run(row.session_id) + deleted.push(row.session_id) + } else { + skipped.push(row.session_id) + } + } catch (err: any) { + const msg = err?.message || 'Unknown error' + db.prepare( + `UPDATE gc_pending_session_deletes + SET status = 'pending', attempt_count = attempt_count + 1, last_error = ?, updated_at = ?, next_attempt_at = ? + WHERE session_id = ?`, + ).run(msg, now, now + 60_000, row.session_id) + failed.push(row.session_id) + logger.warn('[SessionDeleter] failed to delete %s (attempt %d): %s', row.session_id, row.attempt_count + 1, msg) + } + } + + if (deleted.length || failed.length) { + logger.info('[SessionDeleter] profile=%s: deleted=%d, failed=%d', profile, deleted.length, failed.length) + } + + return { deleted, skipped, failed } + } +} diff --git a/packages/server/src/services/hermes/session-sync.ts b/packages/server/src/services/hermes/session-sync.ts new file mode 100644 index 0000000..a357300 --- /dev/null +++ b/packages/server/src/services/hermes/session-sync.ts @@ -0,0 +1,13 @@ +/** + * Hermes session import is intentionally disabled. + * + * Hermes state.db remains a read-only source for Hermes-specific history APIs. + * The web-ui local sessions/messages tables must not be populated from Hermes + * on startup, because that can mix ownership and make data-loss incidents much + * harder to reason about. + */ +import { logger } from '../logger' + +export async function syncAllHermesSessionsOnStartup(): Promise { + logger.info('[session-sync] Hermes session import is disabled') +} diff --git a/packages/server/src/services/hermes/skill-injector.ts b/packages/server/src/services/hermes/skill-injector.ts new file mode 100644 index 0000000..bc09489 --- /dev/null +++ b/packages/server/src/services/hermes/skill-injector.ts @@ -0,0 +1,188 @@ +import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises' +import { existsSync, readdirSync } from 'fs' +import { join, resolve } from 'path' +import { detectHermesRootHome } from './hermes-path' +import { logger } from '../logger' + +export interface SkillInjectionTargetResult { + profile?: string + targetDir: string + injected: string[] + updated: string[] + skipped: string[] +} + +export interface SkillInjectionResult extends SkillInjectionTargetResult { + sourceDir: string + targets: SkillInjectionTargetResult[] +} + +export class HermesSkillInjector { + private readonly targetDirs: string[] + + constructor( + private readonly sourceDir = HermesSkillInjector.resolveSourceDir(), + targetDirOrDirs: string | string[] = HermesSkillInjector.resolveTargetDirs(), + ) { + const targetDirs = Array.isArray(targetDirOrDirs) ? targetDirOrDirs : [targetDirOrDirs] + this.targetDirs = [...new Set(targetDirs.map(targetDir => resolve(targetDir)))] + } + + static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string { + const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim() + if (override) return resolve(override) + + const candidates = [ + // Production bundle: dist/server/index.js with dist/skills copied by build. + resolve(baseDir, '../skills'), + // Development/test: packages/server/src/services/hermes -> packages/skills. + resolve(baseDir, '../../../../skills'), + // Running from repository root without bundling. + resolve(process.cwd(), 'packages/skills'), + ] + + return candidates.find(candidate => existsSync(candidate)) || candidates[0] + } + + static resolveTargetDirs(rootDir = detectHermesRootHome()): string[] { + const root = resolve(rootDir) + const targetDirs = [join(root, 'skills')] + const profilesDir = join(root, 'profiles') + + try { + const entries = readdirSync(profilesDir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) + for (const entry of entries) { + if (entry.isDirectory() && entry.name.trim() && !entry.name.startsWith('.')) { + targetDirs.push(join(profilesDir, entry.name, 'skills')) + } + } + } catch { /* no named profiles */ } + + return [...new Set(targetDirs.map(targetDir => resolve(targetDir)))] + } + + static resolveTargetDirForProfile(profile: string, rootDir = detectHermesRootHome()): string { + const name = String(profile || '').trim() + const root = resolve(rootDir) + if (!name || name === 'default') return join(root, 'skills') + return join(root, 'profiles', name, 'skills') + } + + private static profileForTargetDir(targetDir: string, rootDir = detectHermesRootHome()): string { + const root = resolve(rootDir) + const target = resolve(targetDir) + if (target === resolve(join(root, 'skills'))) return 'default' + + const profilesRoot = resolve(join(root, 'profiles')) + const relativeToProfiles = target.startsWith(profilesRoot) + ? target.slice(profilesRoot.length).replace(/^[/\\]+/, '') + : '' + const [profileName, skillsSegment] = relativeToProfiles.split(/[/\\]+/) + return profileName && skillsSegment === 'skills' ? profileName : 'unknown' + } + + async injectMissingSkills(): Promise { + const result: SkillInjectionResult = { + sourceDir: this.sourceDir, + targetDir: this.targetDirs[0] || '', + injected: [], + updated: [], + skipped: [], + targets: [], + } + + if (!await this.isDirectory(this.sourceDir)) { + logger.debug('[skill-injector] no bundled skills directory at %s', this.sourceDir) + return result + } + + const entries = await readdir(this.sourceDir, { withFileTypes: true }) + const bundledSkillNames = entries + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')) + .map(entry => entry.name) + + logger.info({ + sourceDir: this.sourceDir, + targetDirs: this.targetDirs, + targetCount: this.targetDirs.length, + bundledSkillNames, + }, '[skill-injector] syncing bundled skills across profiles') + + for (const targetDir of this.targetDirs) { + const targetResult = await this.injectIntoTarget(targetDir, entries) + result.targets.push(targetResult) + result.injected.push(...targetResult.injected) + result.updated.push(...targetResult.updated) + result.skipped.push(...targetResult.skipped) + } + + logger.info({ + sourceDir: this.sourceDir, + targetCount: result.targets.length, + injected: [...new Set(result.injected)], + updated: [...new Set(result.updated)], + skipped: [...new Set(result.skipped)], + targets: result.targets, + }, '[skill-injector] completed bundled skills sync') + + return result + } + + private async injectIntoTarget(targetDir: string, entries: import('fs').Dirent[]): Promise { + const profile = HermesSkillInjector.profileForTargetDir(targetDir) + const result: SkillInjectionTargetResult = { + profile, + targetDir, + injected: [], + updated: [], + skipped: [], + } + + await mkdir(targetDir, { recursive: true }) + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const sourceSkillDir = join(this.sourceDir, entry.name) + const targetSkillDir = join(targetDir, entry.name) + const existed = existsSync(targetSkillDir) + if (existsSync(targetSkillDir)) { + await rm(targetSkillDir, { recursive: true, force: true }) + } + await this.copyDir(sourceSkillDir, targetSkillDir) + if (existed) result.updated.push(entry.name) + else result.injected.push(entry.name) + } + + if (result.injected.length > 0 || result.updated.length > 0) { + logger.info({ + profile, + injected: result.injected, + updated: result.updated, + targetDir, + }, '[skill-injector] synced bundled skills') + } + return result + } + + private async isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch { + return false + } + } + + private async copyDir(sourceDir: string, targetDir: string): Promise { + await mkdir(targetDir, { recursive: true }) + const entries = await readdir(sourceDir, { withFileTypes: true }) + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name) + const targetPath = join(targetDir, entry.name) + if (entry.isDirectory()) { + await this.copyDir(sourcePath, targetPath) + } else if (entry.isFile()) { + await copyFile(sourcePath, targetPath) + } + } + } +} diff --git a/packages/server/src/services/hermes/tts.ts b/packages/server/src/services/hermes/tts.ts new file mode 100644 index 0000000..14c9db5 --- /dev/null +++ b/packages/server/src/services/hermes/tts.ts @@ -0,0 +1,80 @@ +import { EdgeTTS } from 'node-edge-tts' +import { tmpdir } from 'os' +import { join } from 'path' +import { readFile, unlink } from 'fs/promises' +import { randomUUID } from 'crypto' +import { logger } from '../logger' + +const FIXED_VOICE = 'zh-CN-XiaoxiaoNeural' +const FIXED_RATE = '+4%' +const FIXED_PITCH = '+12Hz' + +export interface TtsOptions { + text: string + lang?: string + voice?: string + rate?: string + pitch?: string +} + +export async function edgeTts(opts: TtsOptions): Promise { + const id = randomUUID() + const tmpFile = join(tmpdir(), `tts-${id}.mp3`) + + try { + const tts = new EdgeTTS({ + voice: opts.voice || FIXED_VOICE, + rate: opts.rate || FIXED_RATE, + pitch: opts.pitch || FIXED_PITCH, + timeout: 15000, + }) + + await tts.ttsPromise(opts.text, tmpFile) + const buf = await readFile(tmpFile) + return buf + } finally { + unlink(tmpFile).catch(() => {}) + } +} + +export async function textToSpeech(opts: TtsOptions): Promise<{ audio: Buffer; engine: string }> { + const voice = opts.voice || FIXED_VOICE + const rate = opts.rate || FIXED_RATE + const pitch = opts.pitch || FIXED_PITCH + const audio = await edgeTts(opts) + logger.debug({ engine: 'edge', voice, rate, pitch }, 'TTS generated via Edge') + return { audio, engine: 'edge' } +} + +/** + * Convert speed multiplier (0.5-2.0) to Edge TTS rate string. + * Edge TTS rate format: "+/-NN%" + */ +export function speedToEdgeRate(speed: number): string { + const percent = Math.round((speed - 1) * 100) + return percent >= 0 ? `+${percent}%` : `${percent}%` +} + +/** + * Convert OpenAI TTS request to internal TtsOptions. + * OpenAI format: { model, input, voice, speed } + */ +export interface OpenaiTtsRequest { + model?: string + input: string + voice?: string + speed?: number + rate?: string // Edge TTS rate format, e.g. "+20%". Takes priority over speed. + pitch?: string // Edge TTS pitch format, e.g. "-8Hz" +} + +export async function openaiCompatibleTts( + body: OpenaiTtsRequest, +): Promise<{ audio: Buffer; engine: string }> { + return textToSpeech({ + text: body.input, + voice: body.voice || FIXED_VOICE, + rate: body.rate || (body.speed ? speedToEdgeRate(body.speed) : FIXED_RATE), + pitch: body.pitch || FIXED_PITCH, + }) +} diff --git a/packages/server/src/services/hermes/upload-paths.ts b/packages/server/src/services/hermes/upload-paths.ts new file mode 100644 index 0000000..3136b04 --- /dev/null +++ b/packages/server/src/services/hermes/upload-paths.ts @@ -0,0 +1,19 @@ +import { join, resolve } from 'path' +import { config } from '../../config' +import { isPathWithin } from './hermes-path' + +function safeProfileSegment(profile: string): string { + const name = (profile || 'default').trim() || 'default' + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + throw Object.assign(new Error('Invalid profile name'), { code: 'invalid_profile' }) + } + return name +} + +export function getProfileUploadDir(profile: string): string { + return resolve(join(config.uploadDir, safeProfileSegment(profile))) +} + +export function isInProfileUploadDir(filePath: string, profile: string): boolean { + return isPathWithin(filePath, getProfileUploadDir(profile)) +} diff --git a/packages/server/src/services/logger.ts b/packages/server/src/services/logger.ts new file mode 100644 index 0000000..061158c --- /dev/null +++ b/packages/server/src/services/logger.ts @@ -0,0 +1,57 @@ +import pino from 'pino' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { mkdirSync, statSync, truncateSync, openSync, readSync, closeSync, writeFileSync } from 'fs' +import { config } from '../config' + +const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB +const CHECK_INTERVAL = 60_000 // Check every minute + +const logDir = process.env.VITEST + ? resolve(tmpdir(), 'hermes-web-ui-test-logs', String(process.pid)) + : resolve(config.appHome, 'logs') +mkdirSync(logDir, { recursive: true }) + +const logFile = resolve(logDir, 'server.log') +const bridgeLogFile = resolve(logDir, 'bridge.log') + +function rotateFileIfNeeded(file: string) { + try { + const stat = statSync(file) + if (stat.size > MAX_LOG_SIZE) { + const keepSize = Math.floor(MAX_LOG_SIZE / 2) + const fd = openSync(file, 'r') + const buf = Buffer.alloc(keepSize) + readSync(fd, buf, 0, keepSize, stat.size - keepSize) + closeSync(fd) + truncateSync(file, 0) + writeFileSync(file, buf) + } + } catch { } +} + +function rotateIfNeeded() { + rotateFileIfNeeded(logFile) + rotateFileIfNeeded(bridgeLogFile) +} + +// Rotate on startup +rotateIfNeeded() + +// Periodic rotation check — prevents unbounded log growth +setInterval(rotateIfNeeded, CHECK_INTERVAL) + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', +}, pino.destination({ + dest: logFile, + sync: true, +})) + +export const bridgeLogger = pino({ + level: process.env.BRIDGE_LOG_LEVEL || process.env.LOG_LEVEL || 'info', + name: 'bridge', +}, pino.destination({ + dest: bridgeLogFile, + sync: true, +})) diff --git a/packages/server/src/services/login-limiter.ts b/packages/server/src/services/login-limiter.ts new file mode 100644 index 0000000..3f6d239 --- /dev/null +++ b/packages/server/src/services/login-limiter.ts @@ -0,0 +1,337 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { writeFileSync } from 'fs' +import { join } from 'path' +import { config } from '../config' + +const APP_HOME = config.appHome +const LOCK_FILE = join(APP_HOME, '.login-lock.json') + +// Per-IP settings +const IP_MAX_FAILURES = 10 +const IP_FAILURE_WINDOW_MS = 15 * 60_000 // 15 minutes +const IP_LOCK_DURATION_MS = 60 * 60_000 // 1 hour +const IP_MAP_MAX_SIZE = 10000 + +// Global safety net (against distributed attacks) +const GLOBAL_WINDOW_MS = 60_000 +const GLOBAL_MAX_REQUESTS_PER_WINDOW = 100 +const GLOBAL_MAX_TOTAL_FAILURES = 50 +const GLOBAL_LOCK_DURATION_MS = 30 * 60_000 // 30 minutes + +interface IpEntry { + failures: number + lockedUntil: number + firstFailureAt?: number +} + +interface LimiterState { + passwordIpMap: Record + tokenIpMap: Record + globalMinuteCount: number + globalMinuteWindow: number + globalTotalFailures: number + globalLockedUntil: number +} + +let state: LimiterState = { + passwordIpMap: {}, + tokenIpMap: {}, + globalMinuteCount: 0, + globalMinuteWindow: 0, + globalTotalFailures: 0, + globalLockedUntil: 0, +} + +let dirty = false +let persistTimer: ReturnType | null = null + +function now(): number { + return Date.now() +} + +function extractIp(ctx: any): string { + return ctx?.ip || ctx?.request?.ip || 'unknown' +} + +function pruneIpMap(map: Record): void { + const keys = Object.keys(map) + if (keys.length <= IP_MAP_MAX_SIZE) return + const t = now() + for (const key of keys) { + if (map[key].lockedUntil > 0 && t >= map[key].lockedUntil) { + delete map[key] + } + } + const remaining = Object.keys(map) + if (remaining.length <= IP_MAP_MAX_SIZE) return + remaining.sort((a, b) => (map[a].lockedUntil || 0) - (map[b].lockedUntil || 0)) + for (let i = 0; i < remaining.length - IP_MAP_MAX_SIZE; i++) { + delete map[remaining[i]] + } +} + +async function loadState(): Promise { + try { + const raw = await readFile(LOCK_FILE, 'utf-8') + const parsed = JSON.parse(raw) + state = { + passwordIpMap: parsed.passwordIpMap || {}, + tokenIpMap: parsed.tokenIpMap || {}, + globalMinuteCount: parsed.globalMinuteCount || 0, + globalMinuteWindow: parsed.globalMinuteWindow || 0, + globalTotalFailures: parsed.globalTotalFailures || 0, + globalLockedUntil: parsed.globalLockedUntil || 0, + } + } catch { + // use defaults + } +} + +async function persistState(): Promise { + try { + await mkdir(APP_HOME, { recursive: true }) + await writeFile(LOCK_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }) + dirty = false + } catch { + // best effort + } +} + +function persistStateSync(): void { + try { + writeFileSync(LOCK_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }) + dirty = false + } catch { + // best effort + } +} + +function schedulePersist(): void { + if (persistTimer) return + persistTimer = setTimeout(() => { + persistTimer = null + if (dirty) persistState().catch(() => {}) + }, 2000) +} + +export type CheckResult = + | { allowed: true } + | { allowed: false; status: 429 | 503 } + +function checkGlobalLimits(): CheckResult | null { + const t = now() + if (state.globalLockedUntil > 0 && t < state.globalLockedUntil) { + return { allowed: false, status: 503 } + } + if (state.globalLockedUntil > 0 && t >= state.globalLockedUntil) { + state.globalLockedUntil = 0 + state.globalTotalFailures = 0 + dirty = true + } + if (t - state.globalMinuteWindow >= GLOBAL_WINDOW_MS) { + state.globalMinuteWindow = t + state.globalMinuteCount = 0 + } + if (state.globalMinuteCount >= GLOBAL_MAX_REQUESTS_PER_WINDOW) { + return { allowed: false, status: 429 } + } + return null +} + +function checkIpLock(ip: string, map: Record): CheckResult | null { + const t = now() + const entry = map[ip] + if (entry && entry.lockedUntil > 0 && t < entry.lockedUntil) { + return { allowed: false, status: 429 } + } + if (entry && entry.lockedUntil > 0 && t >= entry.lockedUntil) { + delete map[ip] + dirty = true + } + return null +} + +function recordIpFailure(map: Record, ip: string): IpEntry { + const t = now() + let entry = map[ip] + if (!entry) { + entry = { failures: 0, lockedUntil: 0, firstFailureAt: t } + map[ip] = entry + } + + const firstFailureAt = entry.firstFailureAt || t + if (entry.lockedUntil <= 0 && t - firstFailureAt > IP_FAILURE_WINDOW_MS) { + entry.failures = 0 + entry.firstFailureAt = t + } else if (!entry.firstFailureAt) { + entry.firstFailureAt = firstFailureAt + } + + entry.failures++ + return entry +} + +export function checkPassword(ip: string): CheckResult { + const global = checkGlobalLimits() + if (global) return global + + // Check both maps — IP locked by either password or token = blocked + const ipLock = checkIpLock(ip, state.passwordIpMap) || checkIpLock(ip, state.tokenIpMap) + if (ipLock) return ipLock + + state.globalMinuteCount++ + dirty = true + schedulePersist() + return { allowed: true } +} + +export function checkToken(ip: string): CheckResult { + const global = checkGlobalLimits() + if (global) return global + + // Check both maps — IP locked by either password or token = blocked + const ipLock = checkIpLock(ip, state.tokenIpMap) || checkIpLock(ip, state.passwordIpMap) + if (ipLock) return ipLock + + state.globalMinuteCount++ + dirty = true + schedulePersist() + return { allowed: true } +} + +export function recordPasswordFailure(ip: string): void { + const entry = recordIpFailure(state.passwordIpMap, ip) + state.globalTotalFailures++ + dirty = true + + if (entry.failures >= IP_MAX_FAILURES) { + entry.lockedUntil = now() + IP_LOCK_DURATION_MS + persistStateSync() + return + } + if (state.globalTotalFailures >= GLOBAL_MAX_TOTAL_FAILURES) { + state.globalLockedUntil = now() + GLOBAL_LOCK_DURATION_MS + persistStateSync() + return + } + pruneIpMap(state.passwordIpMap) + schedulePersist() +} + +export function recordTokenFailure(ip: string): void { + const entry = recordIpFailure(state.tokenIpMap, ip) + state.globalTotalFailures++ + dirty = true + + if (entry.failures >= IP_MAX_FAILURES) { + entry.lockedUntil = now() + IP_LOCK_DURATION_MS + persistStateSync() + return + } + if (state.globalTotalFailures >= GLOBAL_MAX_TOTAL_FAILURES) { + state.globalLockedUntil = now() + GLOBAL_LOCK_DURATION_MS + persistStateSync() + return + } + pruneIpMap(state.tokenIpMap) + schedulePersist() +} + +export function recordPasswordSuccess(ip: string): void { + if (state.passwordIpMap[ip]) { + delete state.passwordIpMap[ip] + state.globalTotalFailures = 0 + dirty = true + schedulePersist() + } +} + +export function reset(): void { + state = { + passwordIpMap: {}, tokenIpMap: {}, + globalMinuteCount: 0, globalMinuteWindow: 0, + globalTotalFailures: 0, globalLockedUntil: 0, + } + dirty = true + schedulePersist() +} + +export interface LockedIpInfo { + ip: string + type: 'password' | 'token' + failures: number + lockedUntil: number +} + +export function getLockedIps(): LockedIpInfo[] { + const t = now() + const result: LockedIpInfo[] = [] + for (const [ip, entry] of Object.entries(state.passwordIpMap)) { + if (entry.lockedUntil > 0 && t < entry.lockedUntil) { + result.push({ ip, type: 'password', failures: entry.failures, lockedUntil: entry.lockedUntil }) + } + } + for (const [ip, entry] of Object.entries(state.tokenIpMap)) { + if (entry.lockedUntil > 0 && t < entry.lockedUntil) { + result.push({ ip, type: 'token', failures: entry.failures, lockedUntil: entry.lockedUntil }) + } + } + return result +} + +export function unlockIp(ip: string): boolean { + let found = false + if (state.passwordIpMap[ip]) { + delete state.passwordIpMap[ip] + found = true + } + if (state.tokenIpMap[ip]) { + delete state.tokenIpMap[ip] + found = true + } + if (found) { + dirty = true + persistStateSync() + } + return found +} + +export function unlockAll(): number { + const count = getLockedIps().length + state.passwordIpMap = {} + state.tokenIpMap = {} + state.globalTotalFailures = 0 + state.globalLockedUntil = 0 + dirty = true + persistStateSync() + return count +} + +export { extractIp } + +export async function initLoginLimiter(): Promise { + await loadState() + const t = now() + let changed = false + for (const [ip, entry] of Object.entries(state.passwordIpMap)) { + if (entry.lockedUntil > 0 && t >= entry.lockedUntil) { + delete state.passwordIpMap[ip] + changed = true + } + } + for (const [ip, entry] of Object.entries(state.tokenIpMap)) { + if (entry.lockedUntil > 0 && t >= entry.lockedUntil) { + delete state.tokenIpMap[ip] + changed = true + } + } + if (state.globalLockedUntil > 0 && t >= state.globalLockedUntil) { + state.globalLockedUntil = 0 + state.globalTotalFailures = 0 + changed = true + } + if (changed) { + dirty = true + await persistState() + } +} diff --git a/packages/server/src/services/safe-file-store.ts b/packages/server/src/services/safe-file-store.ts new file mode 100644 index 0000000..825e808 --- /dev/null +++ b/packages/server/src/services/safe-file-store.ts @@ -0,0 +1,144 @@ +import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' +import { dirname, resolve } from 'path' +import { randomUUID } from 'crypto' +import YAML, { type DumpOptions } from 'js-yaml' + +type TextUpdater = (current: string) => string | { content: string; result: T } | Promise +type YamlUpdateResult = { data: Record; result: T; write?: boolean } +type YamlUpdater = (current: Record) => Record | YamlUpdateResult | Promise | YamlUpdateResult> + +export interface SafeWriteOptions { + backup?: boolean + backupPath?: string +} + +export interface SafeYamlOptions extends SafeWriteOptions { + dumpOptions?: DumpOptions +} + +function isTextUpdateResult(value: unknown): value is { content: string; result: T } { + return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'content') +} + +function isYamlUpdateResult(value: unknown): value is YamlUpdateResult { + return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'data') +} + +export class SafeFileStore { + private queues = new Map>() + + private normalizePath(filePath: string): string { + return resolve(filePath) + } + + private async withLock(filePath: string, task: () => Promise): Promise { + const key = this.normalizePath(filePath) + const previous = this.queues.get(key) || Promise.resolve() + let release!: () => void + const current = new Promise(resolve => { release = resolve }) + const next = previous.then(() => current, () => current) + this.queues.set(key, next) + + await previous.catch(() => undefined) + try { + return await task() + } finally { + release() + if (this.queues.get(key) === next) this.queues.delete(key) + } + } + + async readText(filePath: string): Promise { + return readFile(this.normalizePath(filePath), 'utf-8') + } + + async writeText(filePath: string, content: string, options: SafeWriteOptions = {}): Promise { + await this.withLock(filePath, () => this.writeTextUnlocked(filePath, content, options)) + } + + async updateText(filePath: string, updater: TextUpdater, options: SafeWriteOptions = {}): Promise { + return this.withLock(filePath, async () => { + let current = '' + try { + current = await this.readText(filePath) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + + const updated = await updater(current) + const content = isTextUpdateResult(updated) ? updated.content : updated + await this.writeTextUnlocked(filePath, content, options) + return isTextUpdateResult(updated) ? updated.result : undefined + }) + } + + async readYaml(filePath: string): Promise> { + try { + const raw = await this.readText(filePath) + return (YAML.load(raw, { json: true }) as Record) || {} + } catch (err: any) { + if (err?.code === 'ENOENT') return {} + throw err + } + } + + async writeYaml(filePath: string, data: Record, options: SafeYamlOptions = {}): Promise { + const yamlStr = YAML.dump(data, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + ...(options.dumpOptions || {}), + }) + await this.writeText(filePath, yamlStr, options) + } + + async updateYaml(filePath: string, updater: YamlUpdater, options: SafeYamlOptions = {}): Promise { + return this.withLock(filePath, async () => { + let raw = '' + try { + raw = await this.readText(filePath) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + const current = raw ? ((YAML.load(raw, { json: true }) as Record) || {}) : {} + const updated = await updater(current) + const data = isYamlUpdateResult(updated) ? updated.data : updated + if (isYamlUpdateResult(updated) && updated.write === false) { + return updated.result + } + const yamlStr = YAML.dump(data, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + ...(options.dumpOptions || {}), + }) + await this.writeTextUnlocked(filePath, yamlStr, options) + return isYamlUpdateResult(updated) ? updated.result : undefined + }) + } + + private async writeTextUnlocked(filePath: string, content: string, options: SafeWriteOptions): Promise { + const target = this.normalizePath(filePath) + const dir = dirname(target) + const temp = `${target}.tmp.${process.pid}.${Date.now()}.${randomUUID()}` + + await mkdir(dir, { recursive: true }) + if (options.backup) { + try { + await copyFile(target, options.backupPath || `${target}.bak`) + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + } + } + + try { + await writeFile(temp, content, 'utf-8') + await rename(temp, target) + } catch (err) { + await rm(temp, { force: true }).catch(() => undefined) + throw err + } + } +} + +export const safeFileStore = new SafeFileStore() diff --git a/packages/server/src/services/shutdown.ts b/packages/server/src/services/shutdown.ts new file mode 100644 index 0000000..38f9328 --- /dev/null +++ b/packages/server/src/services/shutdown.ts @@ -0,0 +1,70 @@ +import { logger } from './logger' +import { closeDb } from '../db' +import { stopPreviewRuntime } from '../controllers/update' + +export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void { + let isShuttingDown = false + + const shutdown = async (signal: string) => { + if (isShuttingDown) return + isShuttingDown = true + + // Force exit after 3s no matter what + setTimeout(() => process.exit(0), 3000) + + logger.info('Shutting down (%s)...', signal) + console.log(`[shutdown] Received signal: ${signal}`) + + try { + try { + await stopPreviewRuntime() + logger.info('Preview runtime stopped') + } catch (err) { + logger.warn(err, 'Failed to stop preview runtime (non-fatal)') + } + + if (agentBridgeManager) { + try { + await agentBridgeManager.stop() + logger.info('Agent bridge stopped') + } catch (err) { + logger.warn(err, 'Failed to stop agent bridge (non-fatal)') + } + } + + // Close ChatRunSocket first to abort all active runs and close EventSource connections + if (chatRunServer) { + chatRunServer.close() + logger.info('ChatRunSocket closed') + } + + // Disconnect Socket.IO before HTTP server to prevent hanging + if (groupChatServer) { + groupChatServer.agentClients.disconnectAll() + groupChatServer.getIO().close() + logger.info('Socket.IO closed') + } + + const servers = Array.isArray(server) ? server : [server].filter(Boolean) + if (servers.length) { + await Promise.all(servers.map((httpServer) => ( + new Promise((resolve) => { + httpServer.close(() => { + logger.info('HTTP server closed') + resolve() + }) + }) + ))) + } + } catch (err) { + logger.error(err, 'Shutdown error') + } + + closeDb() + process.exit(0) + } + + process.once('SIGUSR2', shutdown) + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) +} diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts new file mode 100644 index 0000000..0b16fc6 --- /dev/null +++ b/packages/server/src/shared/providers.ts @@ -0,0 +1,576 @@ +/** + * Provider registry — single source of truth for both frontend and backend. + * Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS. + */ + +export interface ProviderPreset { + label: string + value: string + base_url: string + models: string[] + builtin: boolean + api_mode?: 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server' +} + +export const PROVIDER_PRESETS: ProviderPreset[] = [ + { + label: 'Codex-apikey.fun', + value: 'fun-codex', + builtin: true, + base_url: 'https://api.apikey.fun/v1', + api_mode: 'codex_responses', + models: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + ], + }, + { + label: 'Claude-apikey.fun', + value: 'fun-claude', + builtin: true, + base_url: 'https://api.apikey.fun', + api_mode: "anthropic_messages", + models: [ + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-haiku-4-5' + ], + }, + { + label: 'LM Studio', + value: 'lmstudio', + builtin: true, + base_url: 'http://127.0.0.1:1234/v1', + api_mode: 'chat_completions', + models: [], + }, + { + label: 'Anthropic', + value: 'anthropic', + builtin: true, + base_url: 'https://api.anthropic.com', + models: [ + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-opus-4-5-20251101', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-haiku-4-5-20251001', + ], + }, + { + label: 'Google AI Studio', + value: 'gemini', + builtin: true, + base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', + models: [ + 'gemini-3.1-pro-preview', + 'gemini-3-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite-preview', + ], + }, + { + label: 'DeepSeek', + value: 'deepseek', + builtin: true, + base_url: 'https://api.deepseek.com', + models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'], + }, + { + label: 'Z.AI / GLM', + value: 'zai', + builtin: true, + base_url: 'https://api.z.ai/api/paas/v4', + models: [ + 'glm-5.1', + 'glm-5', + 'glm-5v-turbo', + 'glm-5-turbo', + 'glm-4.7', + 'glm-4.5', + 'glm-4.5-flash', + ], + }, + { + label: 'GLM-Coding-Plan', + value: 'glm-coding-plan', + builtin: true, + base_url: 'https://api.z.ai/api/anthropic', + models: [ + 'glm-5.1', + 'glm-4.5-air', + 'glm-5-turbo', + 'glm-4.7', + 'glm-5v-turbo', + ], + }, + { + label: 'Kimi for Coding', + value: 'kimi-coding', + builtin: true, + base_url: 'https://api.kimi.com/coding/v1', + models: [ + 'kimi-k2.6', + 'kimi-k2.5', + 'kimi-for-coding', + 'kimi-k2-thinking', + 'kimi-k2-thinking-turbo', + 'kimi-k2-turbo-preview', + 'kimi-k2-0905-preview', + ], + }, + { + label: 'Kimi for Coding China', + value: 'kimi-coding-cn', + builtin: true, + base_url: 'https://api.kimi.cn/coding/v1', + models: [ + 'kimi-k2.6', + 'kimi-k2.5', + 'kimi-k2-thinking', + 'kimi-k2-turbo-preview', + 'kimi-k2-0905-preview', + ], + }, + { + label: 'xAI', + value: 'xai', + builtin: true, + base_url: 'https://api.x.ai/v1', + models: [ + 'grok-4.3', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-0309-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-build-0.1', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-video', + ], + }, + { + label: 'xAI Grok OAuth (SuperGrok Subscription)', + value: 'xai-oauth', + builtin: true, + base_url: 'https://api.x.ai/v1', + models: [ + 'grok-4.3', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-0309-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-build-0.1', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-video', + ], + }, + { + label: 'MiniMax', + value: 'minimax', + builtin: true, + base_url: 'https://api.minimax.io/anthropic/v1', + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'], + }, + { + label: 'MiniMax (China)', + value: 'minimax-cn', + builtin: true, + base_url: 'https://api.minimaxi.com/anthropic/v1', + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'], + }, + { + label: 'Alibaba Cloud', + value: 'alibaba', + builtin: true, + base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + models: [ + 'qwen3.7-max', + 'qwen3.6-plus', + 'kimi-k2.5', + 'qwen3.5-plus', + 'qwen3-coder-plus', + 'qwen3-coder-next', + 'glm-5', + 'glm-4.7', + 'MiniMax-M2.5', + ], + }, + { + label: 'Alibaba Cloud (Coding Plan)', + value: 'alibaba-coding-plan', + builtin: true, + // NOTE: This is the international (intl) DashScope endpoint, matching upstream + // hermes-agent (auth.py:255). Mainland China DashScope accounts (sk-sp-* keys + // issued by dashscope.aliyun.com) must override via ALIBABA_CODING_PLAN_BASE_URL= + // https://coding.dashscope.aliyuncs.com/v1 (no -intl), since the -intl endpoint + // returns HTTP 401 for those keys. + base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1', + models: [ + 'qwen3.7-max', + 'qwen3.6-plus', + 'qwen3.5-plus', + 'qwen3-coder-plus', + 'qwen3-coder-next', + 'kimi-k2.5', + 'glm-5', + 'glm-4.7', + 'MiniMax-M2.5', + ], + }, + { + label: 'Hugging Face', + value: 'huggingface', + builtin: true, + base_url: 'https://router.huggingface.co/v1', + models: [ + 'moonshotai/Kimi-K2.5', + 'Qwen/Qwen3.5-397B-A17B', + 'Qwen/Qwen3.5-35B-A3B', + 'deepseek-ai/DeepSeek-V3.2', + 'MiniMaxAI/MiniMax-M2.5', + 'zai-org/GLM-5', + 'XiaomiMiMo/MiMo-V2-Flash', + 'moonshotai/Kimi-K2-Thinking', + 'moonshotai/Kimi-K2.6', + ], + }, + { + label: 'NVIDIA', + value: 'nvidia', + builtin: true, + base_url: 'https://integrate.api.nvidia.com/v1', + models: [ + 'nvidia/nemotron-3-super-120b-a12b', + 'nvidia/nemotron-3-nano-30b-a3b', + 'nvidia/llama-3.3-nemotron-super-49b-v1.5', + 'qwen/qwen3.5-397b-a17b', + 'deepseek-ai/deepseek-v3.2', + 'moonshotai/kimi-k2.6', + 'minimaxai/minimax-m2.5', + 'z-ai/glm5', + 'openai/gpt-oss-120b', + ], + }, + { + label: 'NovitaAI', + value: 'novita', + builtin: true, + base_url: 'https://api.novita.ai/openai', + models: [ + 'moonshotai/kimi-k2.5', + 'minimax/minimax-m2.7', + 'zai-org/glm-5', + 'deepseek/deepseek-v3-0324', + 'deepseek/deepseek-r1-0528', + 'qwen/qwen3-235b-a22b-fp8', + ], + }, + { + label: 'GMI Cloud', + value: 'gmi', + builtin: true, + base_url: 'https://api.gmi-serving.com/v1', + models: [ + 'zai-org/GLM-5.1-FP8', + 'deepseek-ai/DeepSeek-V3.2', + 'moonshotai/Kimi-K2.5', + 'google/gemini-3.1-flash-lite-preview', + 'anthropic/claude-sonnet-4.6', + 'openai/gpt-5.4', + ], + }, + { + label: 'Xiaomi Token Plan', + value: 'xiaomi-token-plan', + builtin: true, + base_url: 'https://token-plan-sgp.xiaomimimo.com/v1', + models: [ + 'mimo-v2-omni', + 'mimo-v2-pro', + 'mimo-v2-tts', + 'mimo-v2.5', + 'mimo-v2.5-pro', + 'mimo-v2.5-tts', + 'mimo-v2.5-tts-voiceclone', + 'mimo-v2.5-tts-voicedesign', + ], + }, + { + label: 'Xiaomi MiMo', + value: 'xiaomi', + builtin: true, + base_url: 'https://api.xiaomimimo.com/v1', + models: [ + 'mimo-v2.5-pro', + 'mimo-v2.5', + 'mimo-v2-pro', + 'mimo-v2-omni', + 'mimo-v2-flash', + ], + }, + { + label: 'Kilo Code', + value: 'kilocode', + builtin: true, + base_url: 'https://api.kilo.ai/api/gateway', + models: [ + 'anthropic/claude-opus-4.6', + 'anthropic/claude-sonnet-4.6', + 'openai/gpt-5.4', + 'google/gemini-3-pro-preview', + 'google/gemini-3-flash-preview', + ], + }, + { + label: 'Tencent TokenHub', + value: 'tencent-tokenhub', + builtin: true, + base_url: 'https://tokenhub.tencentmaas.com/v1', + models: ['hy3-preview'], + }, + { + label: 'Vercel AI Gateway', + value: 'ai-gateway', + builtin: true, + base_url: 'https://ai-gateway.vercel.sh/v1', + models: [ + 'moonshotai/kimi-k2.6', + 'alibaba/qwen3.6-plus', + 'zai/glm-5.1', + 'minimax/minimax-m2.7', + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-opus-4.7', + 'anthropic/claude-opus-4.6', + 'anthropic/claude-haiku-4.5', + 'openai/gpt-5.4', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.3-codex', + 'google/gemini-3.1-pro-preview', + 'google/gemini-3-flash', + 'google/gemini-3.1-flash-lite-preview', + 'xai/grok-4.20-reasoning', + ], + }, + { + label: 'CLIProxyAPI', + value: 'cliproxyapi', + builtin: true, + base_url: 'http://127.0.0.1:8317/v1', + models: [ + 'gpt-5.5', + 'gpt-5-codex', + 'claude-sonnet-4-6', + 'claude-sonnet-4-5-20250929', + 'gemini-3.1-pro-preview', + 'gemini-2.5-pro', + ], + }, + { + label: 'OpenCode Zen', + value: 'opencode-zen', + builtin: true, + base_url: 'https://opencode.ai/zen/v1', + models: [ + 'kimi-k2.5', + 'gpt-5.4-pro', + 'gpt-5.4', + 'gpt-5.3-codex', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-nano', + 'claude-opus-4-6', + 'claude-opus-4-5', + 'claude-opus-4-1', + 'claude-sonnet-4-6', + 'claude-sonnet-4-5', + 'claude-sonnet-4', + 'claude-haiku-4-5', + 'claude-3-5-haiku', + 'gemini-3.1-pro', + 'gemini-3-pro', + 'gemini-3-flash', + 'minimax-m2.7', + 'minimax-m2.5', + 'minimax-m2.5-free', + 'minimax-m2.1', + 'glm-5', + 'glm-4.7', + 'glm-4.6', + 'kimi-k2-thinking', + 'kimi-k2', + 'qwen3-coder', + 'big-pickle', + ], + }, + { + label: 'OpenCode Go', + value: 'opencode-go', + builtin: true, + base_url: 'https://opencode.ai/zen/go/v1', + models: [ + 'glm-5.1', + 'glm-5', + 'kimi-k2.5', + 'kimi-k2.6', + 'mimo-v2.5-pro', + 'mimo-v2.5', + 'mimo-v2-pro', + 'mimo-v2-omni', + 'minimax-m2.7', + 'minimax-m2.5', + 'qwen3.7-max', + 'qwen3.6-plus', + 'qwen3.5-plus' + ], + }, + { + label: 'LongCat', + value: 'longcat', + builtin: true, + base_url: 'https://api.longcat.chat/openai', + models: ['LongCat-Flash-Lite', 'LongCat-2.0-Preview'], + }, + { + label: 'OpenAI Codex', + value: 'openai-codex', + builtin: true, + base_url: 'https://chatgpt.com/backend-api/codex', + models: ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark'], + }, + { + label: 'OpenAI API', + value: 'openai-api', + builtin: true, + base_url: 'https://api.openai.com/v1', + api_mode: 'codex_responses', + models: [ + 'gpt-5.5', + 'gpt-5.5-pro', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.4-nano', + 'gpt-5-mini', + 'gpt-5.3-codex', + 'gpt-4.1', + 'gpt-4o', + 'gpt-4o-mini', + ], + }, + { + label: 'Arcee AI', + value: 'arcee', + builtin: true, + base_url: 'https://api.arcee.ai/api/v1', + models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'], + }, + { + label: 'Nous Portal', + value: 'nous', + builtin: true, + base_url: 'https://inference-api.nousresearch.com/v1', + // Synced from: + // - https://hermes-agent.nousresearch.com/docs/api/model-catalog.json + // - https://portal.nousresearch.com/api/nous/recommended-models + models: [ + 'anthropic/claude-opus-4.8', + 'anthropic/claude-opus-4.7', + 'anthropic/claude-opus-4.6', + 'anthropic/claude-sonnet-4.6', + 'moonshotai/kimi-k2.6', + 'qwen/qwen3.7-max', + 'anthropic/claude-haiku-4.5', + 'openai/gpt-5.5', + 'openai/gpt-5.5-pro', + 'openai/gpt-5.4-mini', + 'openai/gpt-5.4-nano', + 'openai/gpt-5.3-codex', + 'xiaomi/mimo-v2.5-pro', + 'tencent/hy3-preview', + 'google/gemini-3-pro-preview', + 'google/gemini-3-flash-preview', + 'google/gemini-3.1-pro-preview', + 'google/gemini-3.1-flash-lite-preview', + 'qwen/qwen3.6-35b-a3b', + 'stepfun/step-3.5-flash', + 'minimax/minimax-m2.7', + 'z-ai/glm-5.1', + 'x-ai/grok-4.3', + 'nvidia/nemotron-3-super-120b-a12b', + 'deepseek/deepseek-v4-pro', + ], + }, + { + label: 'StepFun', + value: 'stepfun', + builtin: true, + base_url: 'https://api.stepfun.ai/step_plan/v1', + models: ['step-3.7-flash', 'step-3.5-flash', 'step-3.5-flash-2603'], + }, + { + label: 'Ollama Cloud', + value: 'ollama-cloud', + builtin: true, + base_url: 'https://ollama.com/v1', + models: [], + }, + { + label: 'OpenRouter', + value: 'openrouter', + builtin: true, + base_url: 'https://openrouter.ai/api/v1', + models: [], + }, + { + label: 'GitHub Copilot', + value: 'copilot', + builtin: true, + base_url: 'https://api.githubcopilot.com', + models: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.4-nano', + 'gpt-5-mini', + 'gpt-5.3-codex', + 'claude-opus-4.8', + 'claude-opus-4.7', + 'claude-opus-4.6', + 'claude-opus-4.6-fast', + 'claude-opus-4.5', + 'claude-haiku-4.5', + 'claude-sonnet-4.6', + 'claude-sonnet-4.5', + 'gemini-2.5-pro', + 'gemini-3-flash', + 'gemini-3.1-pro', + 'gemini-3.5-flash', + 'raptor-mini', + ], + }, +] + +/** Build a Record for backend lookup */ +export function buildProviderModelMap(): Record { + const map: Record = {} + for (const p of PROVIDER_PRESETS) { + if (p.models.length > 0) { + map[p.value] = p.models + } + } + return map +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..ddc0adb --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/skills/apikey-image-gen/SKILL.md b/packages/skills/apikey-image-gen/SKILL.md new file mode 100644 index 0000000..856ad8a --- /dev/null +++ b/packages/skills/apikey-image-gen/SKILL.md @@ -0,0 +1,182 @@ +--- +name: apikey-image-gen +description: "Generate or edit images through Hermes Web UI using the selected/requested profile's fun-codex provider from config.yaml." +version: 1.0.0 +author: Ekko +license: MIT +platforms: [linux, macos, windows, termux] +metadata: + hermes: + tags: [api.apikey.fun, image-generation, image-editing, media] +prerequisites: + commands: [curl] +--- + +# APIKEY Image Generation + +Use this skill when the user wants to generate an image, generate an image from a reference image, or edit an existing image. + +Always call Hermes Web UI's media endpoint. Do not call `api.apikey.fun` directly, and do not ask the user for an API key. The server reads the selected/requested profile's `config.yaml` and uses the `custom_providers` entry named `fun-codex`: + +Do not use any built-in image generation tool as a fallback. If the Hermes Web UI endpoint returns `401`, `403`, connection failure, or any other error, stop and report the Hermes Web UI error to the user. + +```yaml +custom_providers: + - name: fun-codex + base_url: https://api.apikey.fun/v1 + api_key: ... + model: gpt-5.5 + api_mode: codex_responses +``` + +Endpoint: + +```bash +POST /api/hermes/media/apikey-image-generate +``` + +Resolve the Hermes Web UI base URL in this order: + +1. `HERMES_WEB_UI_URL` environment variable, if set. +2. `http://127.0.0.1:${PORT}`, if `PORT` is set. +3. `http://127.0.0.1:8648` for local development. + +When Hermes Web UI is running from Docker Compose, the default external URL is `http://127.0.0.1:6060`. + +Authentication: + +Send the Hermes Web UI server bearer token. This token is accepted only by Hermes Web UI media generation endpoints for agent skills; it is not a general Web UI login token. + +Resolve the token in this order: + +1. `AUTH_TOKEN` environment variable, if set. +2. `${HERMES_WEB_UI_HOME}/.token`, if `HERMES_WEB_UI_HOME` is set. +3. `${HERMES_WEBUI_STATE_DIR}/.token`, if `HERMES_WEBUI_STATE_DIR` is set. +4. `~/.hermes-web-ui/.token`. + +Profile selection: + +Use the current Hermes profile from the run instructions by sending `X-Hermes-Profile`. + +If the run instructions include `[Current Hermes profile: ]`, include: + +```bash +-H "X-Hermes-Profile: " +``` + +Replace `` with the exact profile name from the run instructions. Never send a placeholder value such as `` or ``. + +If no current profile is provided, omit the header and let the server fall back to the current Hermes active profile. + +## Modes + +### Text To Image + +Use when there is no input image. + +```json +{ + "mode": "text", + "prompt": "A high quality product image of a matte black mechanical keyboard on a clean desk", + "size": "1024x1024", + "output_path": "/absolute/path/to/output.png" +} +``` + +The server calls `POST /v1/images/generations` against the `fun-codex` base URL. + +### Image To Image + +Use when the user provides a reference image and wants a new image based on it. + +```json +{ + "mode": "image", + "prompt": "Use this reference composition and generate a refined technology brand poster", + "image_path": "/absolute/path/to/reference.png", + "size": "1024x1024", + "output_path": "/absolute/path/to/output.png" +} +``` + +The server calls `POST /v1/responses` against the `fun-codex` base URL. + +### Image Edit + +Use when the user wants to modify an existing image while preserving parts of it. + +```json +{ + "mode": "edit", + "prompt": "Change the background to blue and keep the subject unchanged", + "image_path": "/absolute/path/to/source.png", + "size": "1024x1024", + "output_path": "/absolute/path/to/edited.png" +} +``` + +The server calls `POST /v1/images/edits` against the `fun-codex` base URL. + +## Request Fields + +- `mode`: `text`, `image`, or `edit`. +- `prompt`: required. +- `image_path`: local png, jpeg, or webp path. Required for `image` and `edit` unless using `image_url` or `image_base64`. +- `image_url`: optional alternative image input. +- `image_base64`: optional alternative image input. If it is not a data URI, include `mime_type`. +- `n`: number of images. Defaults to `1`. +- `size`: defaults to `1024x1024`. Common values: `1024x1024`, `1536x1024`, `1024x1536`, `2048x2048`, `3840x2160`, `2160x3840`, `auto`. +- `quality`: defaults to `auto`. +- `model`: optional override. Text/edit default to `gpt-image-2`; image mode defaults to the `fun-codex` model in `config.yaml`. +- `image_model`: optional image tool model for image mode. Defaults to `gpt-image-2`. +- `output_path`: optional absolute output file path. If omitted, the server saves to `${HERMES_WEB_UI_HOME:-~/.hermes-web-ui}/media/*.png`. +- `timeout_ms`: defaults to `600000`. + +## Curl Template + +```bash +TOKEN="${AUTH_TOKEN:-}" +if [ -z "$TOKEN" ] && [ -n "${HERMES_WEB_UI_HOME:-}" ] && [ -f "$HERMES_WEB_UI_HOME/.token" ]; then + TOKEN="$(cat "$HERMES_WEB_UI_HOME/.token")" +fi +if [ -z "$TOKEN" ] && [ -n "${HERMES_WEBUI_STATE_DIR:-}" ] && [ -f "$HERMES_WEBUI_STATE_DIR/.token" ]; then + TOKEN="$(cat "$HERMES_WEBUI_STATE_DIR/.token")" +fi +if [ -z "$TOKEN" ] && [ -f "$HOME/.hermes-web-ui/.token" ]; then + TOKEN="$(cat "$HOME/.hermes-web-ui/.token")" +fi +if [ -z "$TOKEN" ]; then + echo "Missing Hermes Web UI token. Check AUTH_TOKEN, HERMES_WEB_UI_HOME, HERMES_WEBUI_STATE_DIR, or ~/.hermes-web-ui/.token." >&2 + exit 1 +fi + +BASE_URL="${HERMES_WEB_UI_URL:-}" +if [ -z "$BASE_URL" ]; then + BASE_URL="http://127.0.0.1:${PORT:-8648}" +fi +BASE_URL="${BASE_URL%/}" + +curl -sS -X POST "$BASE_URL/api/hermes/media/apikey-image-generate" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "mode": "text", + "prompt": "A cinematic 4K photo of a silver robot hand holding a small glowing cube", + "size": "3840x2160", + "output_path": "/absolute/path/to/output.png" + }' +``` + +Successful responses include: + +```json +{ + "ok": true, + "mode": "text", + "output_paths": ["/absolute/path/to/output.png"], + "provider": "fun-codex", + "base_url": "https://api.apikey.fun/v1" +} +``` + +If the response code is `missing_fun_codex_provider`, tell the user to configure `fun-codex` in the selected/requested profile's `config.yaml`. diff --git a/packages/skills/grok-image-to-video/SKILL.md b/packages/skills/grok-image-to-video/SKILL.md new file mode 100644 index 0000000..5b55ed6 --- /dev/null +++ b/packages/skills/grok-image-to-video/SKILL.md @@ -0,0 +1,112 @@ +--- +name: grok-image-to-video +description: "Animate a local image into a short mp4 video through Hermes Web UI using xAI Grok Imagine." +version: 1.0.0 +author: Ekko +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [xAI, Grok, image-to-video, video-generation, media] +prerequisites: + commands: [curl] +--- + +# Grok Image To Video + +Use this skill when the user wants to animate a local image into a short video with xAI Grok Imagine. + +Do not use any built-in image or video generation tool as a fallback. If the Hermes Web UI endpoint returns `401`, `403`, connection failure, or any other error, stop and report the Hermes Web UI error to the user. + +## Workflow + +Call the local Hermes Web UI media endpoint. Pass a local image path; the server will check for xAI credentials, read the file, convert it to a base64 data URI, call xAI, poll until completion, and optionally save the generated mp4. + +Endpoint: + +```bash +POST /api/hermes/media/grok-image-to-video +``` + +Resolve the Hermes Web UI base URL in this order: + +1. `HERMES_WEB_UI_URL` environment variable, if set. +2. `http://127.0.0.1:${PORT}`, if `PORT` is set. +3. `http://127.0.0.1:8648` for local development. + +When Hermes Web UI is running from the provided Docker Compose setup, the default external URL is `http://127.0.0.1:6060`. + +Authentication: + +The endpoint is protected by Hermes Web UI auth. Always send the Hermes Web UI server bearer token. This token is accepted only by Hermes Web UI media generation endpoints for agent skills; it is not a general Web UI login token. + +Resolve the token in this order: + +1. `AUTH_TOKEN` environment variable, if set. +2. `${HERMES_WEB_UI_HOME}/.token`, if `HERMES_WEB_UI_HOME` is set. +3. `${HERMES_WEBUI_STATE_DIR}/.token`, if `HERMES_WEBUI_STATE_DIR` is set. +4. `~/.hermes-web-ui/.token`. + +Profile selection: + +Use the current Hermes profile from the run instructions by sending `X-Hermes-Profile`. + +If the run instructions include `[Current Hermes profile: ]`, include: + +```bash +-H "X-Hermes-Profile: " +``` + +Replace `` with the exact profile name from the run instructions. Never send a placeholder value such as `` or ``. + +If no current profile is provided, omit the header and let the server fall back to the current Hermes active profile. + +Required JSON fields: + +- `image_path`: local path to a png, jpeg, or webp image. +- `prompt`: motion and style instructions for the generated video. + +Optional JSON fields: + +- `duration`: seconds, 1 to 15. Defaults to 8. +- `output_path`: local path where the server should save the mp4. If omitted, the server saves to `${HERMES_WEB_UI_HOME:-~/.hermes-web-ui}/media/.mp4` and creates the `media` directory if needed. +- `timeout_ms`: maximum wait time. Defaults to 600000. + +Example: + +```bash +TOKEN="${AUTH_TOKEN:-}" +if [ -z "$TOKEN" ] && [ -n "${HERMES_WEB_UI_HOME:-}" ] && [ -f "$HERMES_WEB_UI_HOME/.token" ]; then + TOKEN="$(cat "$HERMES_WEB_UI_HOME/.token")" +fi +if [ -z "$TOKEN" ] && [ -n "${HERMES_WEBUI_STATE_DIR:-}" ] && [ -f "$HERMES_WEBUI_STATE_DIR/.token" ]; then + TOKEN="$(cat "$HERMES_WEBUI_STATE_DIR/.token")" +fi +if [ -z "$TOKEN" ] && [ -f "$HOME/.hermes-web-ui/.token" ]; then + TOKEN="$(cat "$HOME/.hermes-web-ui/.token")" +fi +if [ -z "$TOKEN" ]; then + echo "Missing Hermes Web UI token. Check AUTH_TOKEN, HERMES_WEB_UI_HOME, HERMES_WEBUI_STATE_DIR, or ~/.hermes-web-ui/.token." >&2 + exit 1 +fi + +BASE_URL="${HERMES_WEB_UI_URL:-}" +if [ -z "$BASE_URL" ]; then + BASE_URL="http://127.0.0.1:${PORT:-8648}" +fi +BASE_URL="${BASE_URL%/}" + +curl -sS -X POST "$BASE_URL/api/hermes/media/grok-image-to-video" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "image_path": "/absolute/path/to/input.png", + "prompt": "Animate the subject with a slow cinematic push-in and subtle natural motion.", + "duration": 8, + "output_path": "/absolute/path/to/output.mp4" + }' +``` + +If the response has `code: "missing_xai_token"`, tell the user to set `XAI_API_KEY` or complete xAI OAuth login in Hermes Web UI before retrying. + +Return the generated `output_path`. diff --git a/packages/skills/hyperframes/SKILL.md b/packages/skills/hyperframes/SKILL.md new file mode 100644 index 0000000..a218803 --- /dev/null +++ b/packages/skills/hyperframes/SKILL.md @@ -0,0 +1,87 @@ +--- +name: hyperframes +description: "Create AI videos with HyperFrames in Hermes using HTML, CSS, and JavaScript compositions, then validate and render them to MP4. Use for short video intros, cinematic trailers, product promos, subtitle animations, HUD/tech visuals, web-to-video work, and motion graphics." +version: 1.0.0 +author: Ekko +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [hyperframes, ai-video, html-video, animation, motion-graphics, mp4] +prerequisites: + commands: [node, npx] +--- + +# HyperFrames + +Use this skill when the user asks Hermes to make a video with HyperFrames, such as a 30-second vertical video, a short intro, a cinematic micro-trailer, a product promo, animated captions, HUD-style tech visuals, a website-to-video piece, or an HTML/CSS/JS motion graphics render. + +HyperFrames treats HTML as the video source of truth. Build video scenes as HTML compositions with CSS layout and JavaScript animation, validate the layout, then render the result to MP4. + +## Setup + +If HyperFrames is not installed or the official skill is missing, install it first: + +```bash +hermes skills install official/creative/hyperframes +``` + +Use `npx hyperframes` for project operations. HyperFrames requires Node.js and FFmpeg. If rendering or preview fails, run: + +```bash +npx hyperframes doctor +``` + +## Workflow + +1. Convert the user's request into a short production brief: duration, aspect ratio, target platform, language, style, music or voiceover needs, and final output path. +2. For incomplete briefs, make reasonable defaults. Use 1080x1920 for vertical short video, 1920x1080 for horizontal video, 30 fps, and MP4 output. +3. Create or reuse a HyperFrames project: + +```bash +npx hyperframes init my-video --non-interactive +``` + +4. Write the composition in HTML/CSS/JS. Make the static hero frame layout correct before adding animation. +5. Validate before rendering: + +```bash +npx hyperframes lint +npx hyperframes inspect --samples 15 +``` + +6. Preview when useful: + +```bash +npx hyperframes preview +``` + +7. Render the final video: + +```bash +npx hyperframes render --output final.mp4 --quality standard +``` + +Use `--quality draft` for fast iteration and `--quality high` for final delivery when the user asks for a polished export. + +## Composition Rules + +- Use a root element with `data-composition-id`, `data-width`, and `data-height`. +- Use `data-start`, `data-duration`, and `data-track-index` for timed clips. +- Register GSAP timelines synchronously on `window.__timelines`. +- Use CSS as the final layout state, then animate from or to that state. +- Keep media playback under the HyperFrames runtime. Do not manually call `play()`, `pause()`, or seek media. +- Avoid nondeterministic animation logic such as `Math.random()` or `Date.now()` unless using a seeded generator. +- Do not use infinite repeats. Calculate finite repeat counts from the composition duration. +- Check that text, captions, UI panels, and HUD elements stay inside the frame on every inspected timestamp. + +## Delivery + +When finished, tell the user: + +- the rendered MP4 path; +- the preview URL if a preview server is running; +- any assumptions made about duration, aspect ratio, style, narration, or music; +- any validation issues that remain unresolved. + +Do not stop after writing HTML. A HyperFrames task is only complete after the composition has been checked with `lint` and `inspect`, and rendered to an MP4 unless the user explicitly asks for source files only. diff --git a/packages/skills/markdown-viewer/SKILL.md b/packages/skills/markdown-viewer/SKILL.md new file mode 100644 index 0000000..6a14cd8 --- /dev/null +++ b/packages/skills/markdown-viewer/SKILL.md @@ -0,0 +1,86 @@ +--- +name: markdown-viewer +description: "Create rich diagrams, data visualizations, technical architecture views, and editorial content cards directly in Markdown using the Markdown Viewer Agent Skills pack. Use for Mermaid-like diagram requests, PlantUML architecture diagrams, Vega charts, JSON Canvas maps, infographics, UML, cloud/network/security/data/IoT diagrams, and polished Markdown documentation visuals." +version: 1.0.0 +author: Ekko +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + source: markdown-viewer/skills + tags: [markdown-viewer, diagrams, visualization, plantuml, vega, infographic, documentation] +prerequisites: + commands: [node, npx] +--- + +# Markdown Viewer + +Use this skill when the user wants a diagram, visualization, architecture view, data chart, technical documentation graphic, infographic, mind map, or editorial-quality content card directly inside Markdown. + +Markdown Viewer Agent Skills is an opinionated skill pack for AI coding agents. It covers diagram generation, data visualization, and technical documentation using multiple Markdown-rendered engines, including PlantUML, Vega/Vega-Lite, JSON Canvas, infographic blocks, and direct HTML/CSS embeds. + +## Setup + +If the upstream skill pack is not installed, install it first: + +```bash +npx skills add markdown-viewer/skills +``` + +After installation, prefer reading the specific upstream skill for the requested output type before writing complex diagrams. The pack includes detailed syntax rules, examples, and common pitfalls for each renderer. + +## Skill Selection + +Choose the smallest renderer that fits the user's goal: + +| User goal | Use | +| --- | --- | +| Bar, line, scatter, heatmap, area, radar, word cloud, or data-driven chart | `vega` / `vega-lite` | +| KPI card, roadmap, timeline, SWOT, funnel, org chart, or structured visual summary | `infographic` | +| Free-position mind map, concept map, knowledge graph, or planning board | `canvas` | +| System layers, microservices, app/data/infrastructure layers | `architecture` | +| Editorial knowledge card, event card, data highlight, or polished content tile | `infocard` | +| UML class, sequence, activity, state, component, deployment, package, or use-case diagram | `uml` | +| AWS, Azure, GCP, Alibaba Cloud, Kubernetes, serverless, or multi-cloud diagram | `cloud` | +| LAN/WAN, data center, enterprise network, or device topology | `network` | +| Threat model, zero-trust, IAM, firewall, encryption, or compliance view | `security` | +| Enterprise architecture with business/application/technology layers | `archimate` | +| BPMN workflow, swim lanes, integration pattern, or value stream map | `bpmn` | +| ETL/ELT, warehouse, lakehouse, ML pipeline, or analytics workflow | `data-analytics` | +| Sensors, edge computing, smart factory/home, fleet, or digital twin view | `iot` | +| Hierarchical brainstorm tree or study outline | `mindmap` | + +## Output Rules + +- Write the result in Markdown unless the user asks for a separate file. +- Use the correct code fence for the chosen renderer: + - `vega-lite` or `vega` for data charts. + - `infographic` for infographic YAML blocks. + - `canvas` for JSON Canvas maps. + - `plantuml` or `puml` for UML, cloud, network, security, ArchiMate, BPMN, data analytics, IoT, and PlantUML mind maps. +- For `architecture` and `infocard`, embed the HTML/CSS directly in Markdown when that renderer expects raw HTML instead of a code fence. +- Keep diagrams focused. Prefer a clear, accurate first version over decorative complexity. +- Label nodes and edges with domain language the user already used. +- For technical diagrams, include enough structure to be useful in docs: boundaries, data flow, dependencies, trust zones, layers, or ownership where relevant. +- For data visualizations, include explicit sample data or use the data the user supplied. Do not invent real metrics without marking them as placeholders. +- For security or compliance diagrams, avoid implying guarantees. Show controls, boundaries, and risks factually. + +## Workflow + +1. Identify the user's artifact type: chart, diagram, architecture, process, mind map, infographic, or card. +2. Select the renderer from the guide above. +3. If the pack is installed locally, read the corresponding upstream `SKILL.md` for exact syntax and pitfalls. +4. Draft the Markdown artifact with the correct code fence or raw HTML/CSS style. +5. Check syntax before delivery: matching fences, valid JSON/YAML where required, PlantUML starts and ends correctly, and labels are readable. +6. If the user needs a file, save it as `.md` and include only the final artifact plus concise notes. + +## Delivery + +When finished, tell the user: + +- which renderer or sub-skill you used; +- where the Markdown file is, if one was created; +- any placeholder data or assumptions; +- any viewer requirement, such as needing a Markdown Viewer extension or compatible renderer. + +Do not use static screenshots when the user asked for Markdown-native visuals. The value of this skill is that the output stays editable, reviewable, and renderable from Markdown. diff --git a/packages/skills/remotion/SKILL.md b/packages/skills/remotion/SKILL.md new file mode 100644 index 0000000..66ab804 --- /dev/null +++ b/packages/skills/remotion/SKILL.md @@ -0,0 +1,98 @@ +--- +name: remotion +description: "Create editable AI video projects with Remotion and React, then preview and render them to MP4. Use for vertical short videos, product demos, story-driven animations, HUD/tech visuals, feed ads, tutorial videos, subtitles, voiceover, sound effects, and code-based video iteration." +version: 1.0.0 +author: Ekko +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + source: skills-sh/google-labs-code/stitch-skills/remotion + tags: [remotion, react-video, ai-video, mp4, animation, short-video] +prerequisites: + commands: [node, npx] +--- + +# Remotion + +Use this skill when the user wants Hermes to turn a short video idea into an editable, renderable React video project with Remotion. + +Remotion is different from prompt-only AI video tools: it produces a code project. That means the agent can repeatedly edit subtitles, timing, characters, scenes, voiceover, sound effects, and visual rhythm, then render a new MP4. + +Good fits include vertical short videos, product demos, story-driven animations, HUD/tech-style videos, feed ad creatives, tutorial explainers, caption-heavy clips, and reusable video templates. + +## Setup + +If the upstream Remotion skill is not installed, install it first: + +```bash +hermes skills install skills-sh/google-labs-code/stitch-skills/remotion +``` + +For a new Remotion project, scaffold from an empty folder: + +```bash +npx create-video@latest --yes --blank --no-tailwind my-video +``` + +Replace `my-video` with a short project name based on the user's brief. + +## Workflow + +1. Turn the request into a concise production brief: purpose, audience, duration, aspect ratio, style, scenes, text, narration, music, sound effects, and output path. +2. Use practical defaults when the user does not specify them: 1080x1920 for vertical short video, 1920x1080 for horizontal video, 30 fps, MP4 output, and a duration that fits the requested platform. +3. Create or reuse a Remotion project. +4. Build the video as React components and Remotion compositions. Keep scene data, captions, colors, timing, and copy easy to edit. +5. Use Remotion primitives for timing and media: `Composition`, `Sequence`, `AbsoluteFill`, `Audio`, `Video`, `Img`, `useCurrentFrame`, `useVideoConfig`, `interpolate`, and `spring`. +6. Preview in Remotion Studio while iterating: + +```bash +npx remotion studio +``` + +7. For non-trivial layouts, render at least one still frame to catch layout, color, and timing issues: + +```bash +npx remotion still --scale=0.25 --frame=30 +``` + +8. Render the final MP4: + +```bash +npx remotion render out/final.mp4 +``` + +## Implementation Guidelines + +- Prefer code that is easy to revise over one-off generated visuals. +- Keep copy, scene timing, colors, and asset references in clear constants or data arrays. +- Make captions readable on mobile: high contrast, generous line height, and safe margins. +- Use deterministic animation. Avoid time-based randomness that changes between renders. +- Use Remotion's frame-based timing instead of browser timers. +- Use separate components for scenes, captions, overlays, lower thirds, and recurring visual motifs. +- When adding voiceover or sound effects, keep audio timing explicit and easy to adjust. +- When using user assets, keep their original files in the project and reference them through Remotion's asset path conventions. + +## Checks + +Before delivery, run the strongest practical validation for the scope: + +```bash +npm run build +npx remotion still --scale=0.25 --frame=30 +npx remotion render out/final.mp4 +``` + +If the project uses a different package script, follow that project instead. If rendering fails because of missing browser, FFmpeg, codec, or dependency setup, report the blocker and run the relevant Remotion or environment diagnostic before retrying. + +## Delivery + +When finished, tell the user: + +- the Remotion project path; +- the rendered MP4 path; +- the preview command or Studio URL if a preview server is running; +- the composition ID used for rendering; +- any assumptions about duration, aspect ratio, voiceover, music, assets, or style. + +Do not stop at a concept. A Remotion video task is complete when the project is editable and the requested MP4 is rendered, unless the user explicitly asks for source code only. diff --git a/packages/website/index.html b/packages/website/index.html new file mode 100644 index 0000000..74314d0 --- /dev/null +++ b/packages/website/index.html @@ -0,0 +1,16 @@ + + + + + + + Hermes Studio - Self-Hosted AI Chat Dashboard + + + + + +
+ + + diff --git a/packages/website/public/favicon.ico b/packages/website/public/favicon.ico new file mode 100644 index 0000000..dbc3f56 Binary files /dev/null and b/packages/website/public/favicon.ico differ diff --git a/packages/website/public/image1.png b/packages/website/public/image1.png new file mode 100644 index 0000000..833c62f Binary files /dev/null and b/packages/website/public/image1.png differ diff --git a/packages/website/public/image2.png b/packages/website/public/image2.png new file mode 100644 index 0000000..5d55a68 Binary files /dev/null and b/packages/website/public/image2.png differ diff --git a/packages/website/public/image3.png b/packages/website/public/image3.png new file mode 100644 index 0000000..9d49b83 Binary files /dev/null and b/packages/website/public/image3.png differ diff --git a/packages/website/public/image4.png b/packages/website/public/image4.png new file mode 100644 index 0000000..3e1de6b Binary files /dev/null and b/packages/website/public/image4.png differ diff --git a/packages/website/public/logo.png b/packages/website/public/logo.png new file mode 100644 index 0000000..451200c Binary files /dev/null and b/packages/website/public/logo.png differ diff --git a/packages/website/src/App.vue b/packages/website/src/App.vue new file mode 100644 index 0000000..c7c4b4e --- /dev/null +++ b/packages/website/src/App.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/website/src/components/docs/DocContent.vue b/packages/website/src/components/docs/DocContent.vue new file mode 100644 index 0000000..7a2e79a --- /dev/null +++ b/packages/website/src/components/docs/DocContent.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/packages/website/src/components/docs/DocSidebar.vue b/packages/website/src/components/docs/DocSidebar.vue new file mode 100644 index 0000000..aa64fc3 --- /dev/null +++ b/packages/website/src/components/docs/DocSidebar.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/website/src/components/landing/FeaturesGrid.vue b/packages/website/src/components/landing/FeaturesGrid.vue new file mode 100644 index 0000000..7f0002c --- /dev/null +++ b/packages/website/src/components/landing/FeaturesGrid.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/website/src/components/landing/HeroSection.vue b/packages/website/src/components/landing/HeroSection.vue new file mode 100644 index 0000000..7e053c2 --- /dev/null +++ b/packages/website/src/components/landing/HeroSection.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/packages/website/src/components/landing/InstallSection.vue b/packages/website/src/components/landing/InstallSection.vue new file mode 100644 index 0000000..888f984 --- /dev/null +++ b/packages/website/src/components/landing/InstallSection.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/packages/website/src/components/landing/PlatformsSection.vue b/packages/website/src/components/landing/PlatformsSection.vue new file mode 100644 index 0000000..7352e97 --- /dev/null +++ b/packages/website/src/components/landing/PlatformsSection.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/website/src/components/landing/ScreenshotsSection.vue b/packages/website/src/components/landing/ScreenshotsSection.vue new file mode 100644 index 0000000..c97d91a --- /dev/null +++ b/packages/website/src/components/landing/ScreenshotsSection.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/packages/website/src/components/landing/StarHistorySection.vue b/packages/website/src/components/landing/StarHistorySection.vue new file mode 100644 index 0000000..4f745d3 --- /dev/null +++ b/packages/website/src/components/landing/StarHistorySection.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/packages/website/src/components/layout/SiteFooter.vue b/packages/website/src/components/layout/SiteFooter.vue new file mode 100644 index 0000000..0b674ca --- /dev/null +++ b/packages/website/src/components/layout/SiteFooter.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/website/src/components/layout/SiteHeader.vue b/packages/website/src/components/layout/SiteHeader.vue new file mode 100644 index 0000000..aa84cd9 --- /dev/null +++ b/packages/website/src/components/layout/SiteHeader.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/packages/website/src/composables/useScrollReveal.ts b/packages/website/src/composables/useScrollReveal.ts new file mode 100644 index 0000000..0fc5a3a --- /dev/null +++ b/packages/website/src/composables/useScrollReveal.ts @@ -0,0 +1,27 @@ +import { onMounted, onUnmounted } from 'vue' + +export function useScrollReveal() { + let observer: IntersectionObserver | null = null + + onMounted(() => { + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add('revealed') + observer!.unobserve(entry.target) + } + } + }, + { threshold: 0.1, rootMargin: '0px 0px -40px 0px' }, + ) + + document.querySelectorAll('.reveal').forEach((el) => { + observer!.observe(el) + }) + }) + + onUnmounted(() => { + observer?.disconnect() + }) +} diff --git a/packages/website/src/composables/useTheme.ts b/packages/website/src/composables/useTheme.ts new file mode 100644 index 0000000..42291d2 --- /dev/null +++ b/packages/website/src/composables/useTheme.ts @@ -0,0 +1,54 @@ +import { ref, watch } from 'vue' + +export type ThemeMode = 'light' | 'dark' | 'system' + +const STORAGE_KEY = 'hermes_website_theme' + +const mode = ref( + (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system', +) + +const isDark = ref(false) + +function applyTheme(dark: boolean) { + isDark.value = dark + document.documentElement.classList.toggle('dark', dark) +} + +function resolveDark(m: ThemeMode): boolean { + if (m === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches + } + return m === 'dark' +} + +applyTheme(resolveDark(mode.value)) + +const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') +mediaQuery.addEventListener('change', () => { + if (mode.value === 'system') { + applyTheme(resolveDark('system')) + } +}) + +watch(mode, (newMode) => { + localStorage.setItem(STORAGE_KEY, newMode) + applyTheme(resolveDark(newMode)) +}) + +export function useTheme() { + function setMode(m: ThemeMode) { + mode.value = m + } + + function toggleTheme() { + mode.value = isDark.value ? 'light' : 'dark' + } + + return { + mode, + isDark, + setMode, + toggleTheme, + } +} diff --git a/packages/website/src/env.d.ts b/packages/website/src/env.d.ts new file mode 100644 index 0000000..54eaa07 --- /dev/null +++ b/packages/website/src/env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string diff --git a/packages/website/src/i18n/en.ts b/packages/website/src/i18n/en.ts new file mode 100644 index 0000000..cfacedb --- /dev/null +++ b/packages/website/src/i18n/en.ts @@ -0,0 +1,357 @@ +export default { + brand: { + name: 'Hermes Web UI', + logoAlt: 'Hermes', + }, + ui: { + copy: 'Copy', + copied: 'Copied!', + darkTheme: 'Dark', + lightTheme: 'Light', + darkMode: 'Dark Mode', + lightMode: 'Light Mode', + menu: 'Menu', + switchToChinese: 'Chinese', + switchToEnglish: 'English', + }, + nav: { + home: 'Home', + docs: 'Documentation', + github: 'GitHub', + }, + hero: { + title: 'Self-Hosted AI Chat Dashboard', + subtitle: 'Open-source AI agent dashboard — streaming chat, multi-model routing, Kanban boards, usage analytics, web terminal, all in one self-hosted interface.', + cta: 'Get Started', + viewGithub: 'View on GitHub', + install: 'npm install -g hermes-web-ui', + }, + features: { + title: 'Everything You Need', + desc: 'A complete AI agent management dashboard with rich features out of the box.', + streaming: { + title: 'Streaming Chat', + desc: 'Real-time Socket.IO-powered AI conversations with multi-session management, Markdown rendering, and code syntax highlighting.', + }, + platforms: { + title: '8 Platforms', + desc: 'Unified management for Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, and WeCom channels.', + }, + multiModel: { + title: 'Multi-Model', + desc: 'Support for Claude, GPT, Gemini, DeepSeek, and any OpenAI-compatible provider with auto-discovery.', + }, + groupChat: { + title: 'Group Chat', + desc: 'Multi-agent chat rooms with mention routing, context compression, and real-time collaboration.', + }, + kanban: { + title: 'Kanban Board', + desc: 'Visual task management with 7 status columns, assignee tracking, and filtering for AI-driven workflows.', + }, + analytics: { + title: 'Usage Analytics', + desc: 'Token usage breakdown, cost tracking, cache hit rates, model distribution, and 30-day trends.', + }, + profiles: { + title: 'Multi-Profile', + desc: 'Account-authorized Hermes profiles with isolated config, models, uploads, jobs, usage, memory, skills, plugins, and providers.', + }, + files: { + title: 'File Browser', + desc: 'Manage files across local, Docker, SSH, and Singularity backends with profile-scoped upload plus path-based download, preview, and edit.', + }, + terminal: { + title: 'Web Terminal', + desc: 'Full PTY terminal in the browser with multi-session support via WebSocket and xterm.js.', + }, + quickInstall: { + title: 'One Command', + desc: 'Install and start with a single command. Initializes Web UI data, starts the bridge, and opens the browser.', + }, + i18n: { + title: '8 Languages', + desc: 'Built-in support for English, Chinese, German, Spanish, French, Japanese, Korean, and Portuguese.', + }, + theme: { + title: 'Dark / Light', + desc: 'Pure Ink monochrome design with smooth theme switching. Responsive layout for mobile and desktop.', + }, + }, + platforms: { + title: 'Unified Platform Management', + desc: 'Configure credentials and behavior for 8 messaging platforms from a single settings page.', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + whatsapp: 'WhatsApp', + matrix: 'Matrix', + feishu: 'Feishu', + wechat: 'WeChat', + wecom: 'WeCom', + }, + screenshots: { + localUrl: 'http://localhost:8648', + previous: 'Previous screenshot', + next: 'Next screenshot', + goTo: 'View screenshot {number}', + items: [ + { src: '/image1.png', alt: 'AI chat with image generation' }, + { src: '/image2.png', alt: 'Chat and file browser' }, + { src: '/image3.png', alt: 'Multi-panel workspace' }, + { src: '/image4.png', alt: 'Kanban board' }, + ], + }, + install: { + title: 'Quick Start', + desc: 'Download the desktop app or run Hermes Web UI yourself.', + desktop: { + title: 'Desktop', + download: 'Download', + githubDownload: 'GitHub Download', + cloudflareDownload: 'Cloudflare Download', + allDownloads: 'View all release assets', + prereq: 'Desktop builds bundle the Web UI runtime.', + downloads: [ + { + title: 'macOS Apple Silicon', + desc: 'Apple Silicon DMG', + assetSuffix: 'arm64.dmg', + }, + { + title: 'macOS Intel', + desc: 'x64 DMG', + assetSuffix: 'x64.dmg', + }, + { + title: 'Windows', + desc: 'x64 installer', + assetSuffix: 'x64.exe', + }, + { + title: 'Linux x64 AppImage', + desc: 'x64 AppImage', + assetSuffix: 'x86_64.AppImage', + }, + { + title: 'Linux x64 Debian', + desc: 'amd64 .deb package', + assetSuffix: 'amd64.deb', + }, + { + title: 'Linux arm64', + desc: 'arm64 AppImage', + assetSuffix: 'arm64.AppImage', + }, + ], + }, + npm: { + title: 'npm', + cmd1: 'npm install -g hermes-web-ui', + cmd2: 'hermes-web-ui start', + }, + docker: { + title: 'Docker', + cmd: 'docker compose up -d', + }, + source: { + title: 'From Source', + cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git', + cmd2: 'cd hermes-web-ui && npm install && npm run dev', + }, + prereq: 'Requires Node.js >= 23', + }, + starHistory: { + title: 'Growing Community', + desc: 'Star us on GitHub and join the community.', + star: 'Star', + licenseAlt: 'License', + versionAlt: 'Version', + chartAlt: 'Star History', + }, + footer: { + description: 'Self-hosted AI chat dashboard for Hermes Agent.', + license: 'BSL-1.1 License', + madeWith: 'Built with Vue 3, Naive UI, and TypeScript.', + }, + docs: { + placeholder: 'Select a section from the sidebar to get started.', + sidebar: { + gettingStarted: 'Getting Started', + configuration: 'Configuration', + features: 'Features', + platforms: 'Platform Guides', + api: 'API Reference', + }, + gettingStarted: { + title: 'Getting Started', + intro: 'Hermes Web UI is a self-hosted web dashboard for managing AI conversations, platform channels, scheduled jobs, and more. It wraps the Hermes Agent CLI and provides a beautiful web interface.', + install: { + title: 'Installation', + content: 'Install globally via npm. Node.js 23 or higher is required.', + }, + firstRun: { + title: 'First Run', + content: 'On first start, Hermes Web UI will automatically generate an auth token, initialize local data, start the Hermes agent bridge, and open the dashboard in your browser.', + }, + login: { + title: 'Login', + content: 'The auto-generated token is stored in ~/.hermes-web-ui/.token. Username/password login is available with bootstrap credentials admin / 123456 on first use, and the app prompts users to change default credentials after login.', + }, + }, + configuration: { + title: 'Configuration', + intro: 'Hermes Web UI can be configured via environment variables.', + envVars: { + title: 'Environment Variables', + rows: [ + ['PORT', 'Server listen port (default: 8648)'], + ['BIND_HOST', 'Server bind host (default: 0.0.0.0). Set :: explicitly to enable IPv6 listening.'], + ['HERMES_WEB_UI_HOME', 'Web UI data home for auth token, credentials, logs, DB, and default uploads'], + ['HERMES_WEBUI_STATE_DIR', 'Compatibility alias for HERMES_WEB_UI_HOME'], + ['UPLOAD_DIR', 'Custom upload root. Uploaded files are stored below profile-scoped subdirectories.'], + ['CORS_ORIGINS', 'CORS origin config (default: *)'], + ['AUTH_TOKEN', 'Custom bearer token; overrides the auto-generated token'], + ['AUTH_JWT_SECRET', 'JWT signing secret override for username/password sessions'], + ['PROFILE', 'Startup/default Hermes profile'], + ['LOG_LEVEL', 'Server log level'], + ['BRIDGE_LOG_LEVEL', 'Bridge log level'], + ['MAX_DOWNLOAD_SIZE', 'Maximum file download size'], + ['MAX_EDIT_SIZE', 'Maximum editable file size'], + ['WORKSPACE_BASE', 'Base directory for workspace browsing'], + ['HERMES_HOME', 'Hermes data home'], + ['HERMES_BIN', 'Custom Hermes CLI binary path'], + ['HERMES_AGENT_ROOT', 'Hermes Agent source checkout containing run_agent.py'], + ['HERMES_AGENT_BRIDGE_PYTHON', 'Python interpreter used to launch the agent bridge'], + ['HERMES_AGENT_BRIDGE_UV', 'uv executable used to launch the agent bridge when available'], + ['UV', 'Fallback uv executable path'], + ['PYTHON', 'Fallback Python executable for the agent bridge'], + ['HERMES_AGENT_BRIDGE_ENDPOINT', 'Agent bridge broker endpoint. Windows defaults to tcp://127.0.0.1:18765; macOS/Linux defaults to ipc:///tmp/hermes-agent-bridge.sock'], + ['HERMES_AGENT_BRIDGE_TIMEOUT_MS', 'Timeout for Node requests to the bridge broker'], + ['HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS', 'Short retry window for connecting to the bridge socket'], + ['HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS', 'Timeout while waiting for the Python bridge to become ready'], + ['HERMES_AGENT_BRIDGE_AUTO_RESTART', 'Auto-restart the bridge broker after unexpected exit; set 0/false/no/off to disable'], + ['HERMES_AGENT_BRIDGE_RESTART_DELAY_MS', 'Base delay for bridge auto-restart backoff'], + ['HERMES_AGENT_BRIDGE_PLATFORM', 'Platform identity passed to Hermes Agent'], + ['HERMES_AGENT_BRIDGE_WORKER_TRANSPORT', 'Profile worker endpoint transport. Set tcp for loopback TCP, or ipc/unix for Unix domain sockets; defaults to Windows TCP and macOS/Linux IPC'], + ['HERMES_AGENT_BRIDGE_WORKER_PORT_BASE', 'Base port for TCP worker endpoints (default: 18780). Version Preview uses an isolated 19650 port range'], + ['HERMES_BRIDGE_PROVIDER', 'Provider override for bridge runs'], + ['HERMES_BRIDGE_TOOLSETS', 'Toolset override for bridge runs'], + ['HERMES_BRIDGE_MAX_TURNS', 'Maximum turn override for bridge runs'], + ['HERMES_BRIDGE_SUPPRESS_PLATFORM_HINT', 'Controls bridge platform hint suppression passed to Hermes Agent'], + ['HERMES_OPENROUTER_APP_REFERER', 'OpenRouter attribution referer sent by bridge runs'], + ['HERMES_OPENROUTER_APP_TITLE', 'OpenRouter attribution title sent by bridge runs'], + ['HERMES_OPENROUTER_APP_CATEGORIES', 'OpenRouter attribution categories sent by bridge runs'], + ['HERMES_WEB_UI_MANAGED_GATEWAY', 'Force managed legacy gateway process handling'], + ['HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN', 'Controls whether Web UI shutdown also stops managed gateway processes'], + ['GATEWAY_HOST', 'Default gateway host written into profile config for legacy gateway compatibility'], + ['HERMES_WEB_UI_PREVIEW_REPO', 'GitHub repository used by Version Preview'], + ['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT', 'Version Preview broker endpoint transport. Set tcp to use loopback TCP for Preview on macOS/Linux; when unset, Preview follows HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp'], + ['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT', 'Directly overrides the Version Preview broker endpoint for deployments that need a fully custom Preview bridge address'], + ['HERMES_WEB_UI_BACKEND_PORT', 'Backend port used by the Vite dev proxy'], + ['HERMES_WEB_UI_FRONTEND_PORT', 'Frontend Vite dev server port'], + ], + }, + gateway: { + title: 'Agent Bridge Runtime', + content: 'Chat runs are handled through the Hermes agent bridge, which runs alongside the Web UI server and talks directly to the Hermes Agent runtime. HERMES_AGENT_BRIDGE_ENDPOINT controls the Node-to-broker address, while HERMES_AGENT_BRIDGE_WORKER_TRANSPORT controls the broker-to-profile-worker transport. Switching the frontend Hermes Profile changes later request context only; it does not restart the bridge or clear other running tasks.', + }, + profiles: { + title: 'Profiles', + content: 'Profiles provide isolated configurations for different use cases. Super administrators can manage every profile, while regular administrators only see and use profiles assigned to their account. Create, clone, import, export, or switch Hermes profiles from the Profiles page.', + }, + }, + features: { + title: 'Features', + intro: 'Explore the core features of Hermes Web UI.', + chat: { + title: 'AI Chat', + content: 'Real-time chat streaming over Socket.IO /chat-run. Supports multi-session management, Markdown rendering with syntax highlighting, tool call inspection, profile-scoped upload, path-based download, and Ctrl+K search across the Web UI local session database.', + }, + kanban: { + title: 'Kanban Board', + content: 'A visual task management board with 7 status columns: triage, todo, ready, running, blocked, done, and archived. Supports assignee management, filtering, and detailed task editing via a side drawer.', + }, + groupChat: { + title: 'Group Chat', + content: 'Multi-agent chat rooms where multiple AI agents collaborate. Features mention routing to trigger specific agents, automatic context compression when history exceeds limits, typing indicators, and SQLite-based message persistence.', + }, + jobs: { + title: 'Scheduled Jobs', + content: 'Create and manage cron-based scheduled jobs that run AI tasks automatically. Configure schedule, prompt, and model for each job.', + }, + skills: { + title: 'Skills', + content: 'Browse and manage installed AI skills. Skills extend the agent\'s capabilities with specialized knowledge and tool integrations.', + }, + memory: { + title: 'Memory', + content: 'Manage agent memory and user notes. The agent uses memory to maintain context across conversations and personalize responses.', + }, + terminal: { + title: 'Terminal', + content: 'Full pseudo-terminal in the browser powered by node-pty and xterm.js. Supports multiple terminal sessions, real-time keyboard input, and window resizing via WebSocket.', + }, + files: { + title: 'File Browser', + content: 'Browse and manage files on remote backends including local, Docker, SSH, and Singularity. Uploads are stored under the selected/requested profile, while downloads resolve real paths so agent-generated artifacts outside the upload directory still work.', + }, + analytics: { + title: 'Usage Analytics', + content: 'Track token usage (input/output), estimated costs, cache hit rates, session counts, and model distribution. View 30-day daily trends with interactive charts.', + }, + }, + platforms: { + title: 'Platform Guides', + intro: 'Configure messaging platform integrations from the Channels settings page.', + telegram: { + title: 'Telegram', + content: 'Create a Telegram Bot via BotFather, then enter the bot token. Configure mention requirements, free-response chats, and reaction handling.', + }, + discord: { + title: 'Discord', + content: 'Create a Discord Bot in the Developer Portal. Supports auto-thread creation, allowed/ignored channels, reaction handling, and free-response channels.', + }, + slack: { + title: 'Slack', + content: 'Create a Slack App with bot token scope. Configure mention requirements, bot allowlisting, and free-response channels.', + }, + whatsapp: { + title: 'WhatsApp', + content: 'Enable WhatsApp integration and configure mention patterns and free-response chats.', + }, + matrix: { + title: 'Matrix', + content: 'Provide access token and homeserver URL. Supports auto-thread, DM mention threads, and free-response rooms.', + }, + feishu: { + title: 'Feishu (Lark)', + content: 'Register a Feishu app and configure App ID and Secret.', + }, + wechat: { + title: 'WeChat', + content: 'Scan the QR code from the settings page to log in. Credentials are auto-saved for subsequent sessions.', + }, + wecom: { + title: 'WeCom', + content: 'Configure Bot ID and Secret from the WeCom admin console.', + }, + }, + api: { + title: 'API Reference', + intro: 'Hermes Web UI provides a local BFF API for the dashboard and Socket.IO endpoints for streaming chat.', + local: { + title: 'Local BFF Endpoints', + content: 'The Koa server handles session management, profile CRUD, account- and profile-scoped management, config read/write, log access, skill listing, memory operations, and static assets.', + }, + proxy: { + title: 'Chat Streaming', + content: 'Chat runs use the /chat-run Socket.IO namespace and the Hermes agent bridge. Legacy gateway proxy routes are kept only for compatibility where applicable.', + }, + auth: { + title: 'Authentication', + content: 'API endpoints require authenticated access. The token is auto-generated on first run and stored in ~/.hermes-web-ui/.token. Username/password login uses account records; super administrators manage users and profile bindings, while regular administrators manage their own account details.', + }, + }, + }, +} diff --git a/packages/website/src/i18n/index.ts b/packages/website/src/i18n/index.ts new file mode 100644 index 0000000..29ba3dc --- /dev/null +++ b/packages/website/src/i18n/index.ts @@ -0,0 +1,13 @@ +import { createI18n } from 'vue-i18n' +import en from './en' +import zh from './zh' + +const detected = navigator.language.startsWith('zh') ? 'zh' : 'en' +const saved = localStorage.getItem('hermes_website_locale') + +export const i18n = createI18n({ + legacy: false, + locale: saved || detected, + fallbackLocale: 'en', + messages: { en, zh }, +}) diff --git a/packages/website/src/i18n/zh.ts b/packages/website/src/i18n/zh.ts new file mode 100644 index 0000000..b632bc8 --- /dev/null +++ b/packages/website/src/i18n/zh.ts @@ -0,0 +1,357 @@ +export default { + brand: { + name: 'Hermes Web UI', + logoAlt: 'Hermes', + }, + ui: { + copy: '复制', + copied: '已复制', + darkTheme: '深色', + lightTheme: '浅色', + darkMode: '深色模式', + lightMode: '浅色模式', + menu: '菜单', + switchToChinese: '中文', + switchToEnglish: 'English', + }, + nav: { + home: '首页', + docs: '文档', + github: 'GitHub', + }, + hero: { + title: '自托管 AI 聊天仪表板', + subtitle: '开源 AI Agent 仪表板 — 流式对话、多模型调度、看板管理、用量分析、Web 终端,一个界面掌控一切。', + cta: '快速开始', + viewGithub: '查看 GitHub', + install: 'npm install -g hermes-web-ui', + }, + features: { + title: '功能齐全', + desc: '开箱即用的完整 AI Agent 管理仪表板。', + streaming: { + title: '流式聊天', + desc: '基于 Socket.IO 的实时 AI 对话,支持多会话管理、Markdown 渲染和代码语法高亮。', + }, + platforms: { + title: '8 大平台', + desc: '统一管理 Telegram、Discord、Slack、WhatsApp、Matrix、飞书、微信、企业微信。', + }, + multiModel: { + title: '多模型支持', + desc: '支持 Claude、GPT、Gemini、DeepSeek 及任何 OpenAI 兼容模型,自动发现。', + }, + groupChat: { + title: '群聊协作', + desc: '多 Agent 聊天室,支持提及路由、上下文压缩和实时协作。', + }, + kanban: { + title: '看板管理', + desc: '可视化任务看板,7 个状态列,支持任务分配和筛选。', + }, + analytics: { + title: '用量分析', + desc: 'Token 用量、费用追踪、缓存命中率、模型分布和 30 天趋势。', + }, + profiles: { + title: '多配置', + desc: '按账号授权的 Hermes Profile,隔离配置、模型、上传、任务、用量、记忆、技能、插件和 Provider。', + }, + files: { + title: '文件管理', + desc: '跨本地、Docker、SSH 和 Singularity 管理文件,支持按 Profile 上传、按路径下载、预览和编辑。', + }, + terminal: { + title: 'Web 终端', + desc: '浏览器内完整 PTY 终端,基于 WebSocket 和 xterm.js 的多会话支持。', + }, + quickInstall: { + title: '一键安装', + desc: '一条命令安装启动。初始化 Web UI 数据、启动 bridge 并打开浏览器。', + }, + i18n: { + title: '8 种语言', + desc: '内置英语、中文、德语、西班牙语、法语、日语、韩语和葡萄牙语。', + }, + theme: { + title: '暗色 / 亮色', + desc: '水墨单色设计,平滑主题切换,响应式布局适配移动端和桌面端。', + }, + }, + platforms: { + title: '统一平台管理', + desc: '在一个页面配置 8 大消息平台的凭证和行为。', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + whatsapp: 'WhatsApp', + matrix: 'Matrix', + feishu: '飞书', + wechat: '微信', + wecom: '企业微信', + }, + screenshots: { + localUrl: 'http://localhost:8648', + previous: '上一张截图', + next: '下一张截图', + goTo: '查看第 {number} 张截图', + items: [ + { src: '/image1.png', alt: '带图片生成的 AI 聊天界面' }, + { src: '/image2.png', alt: '聊天和文件浏览器界面' }, + { src: '/image3.png', alt: '多面板工作区界面' }, + { src: '/image4.png', alt: '看板管理界面' }, + ], + }, + install: { + title: '快速开始', + desc: '下载桌面应用,或自行运行 Hermes Web UI。', + desktop: { + title: '桌面版', + download: '下载', + githubDownload: 'GitHub 下载', + cloudflareDownload: 'Cloudflare 下载', + allDownloads: '查看全部发布文件', + prereq: '桌面版已内置 Web UI 运行时。', + downloads: [ + { + title: 'macOS Apple Silicon', + desc: 'Apple Silicon DMG', + assetSuffix: 'arm64.dmg', + }, + { + title: 'macOS Intel', + desc: 'x64 DMG', + assetSuffix: 'x64.dmg', + }, + { + title: 'Windows', + desc: 'x64 安装包', + assetSuffix: 'x64.exe', + }, + { + title: 'Linux x64 AppImage', + desc: 'x64 AppImage', + assetSuffix: 'x86_64.AppImage', + }, + { + title: 'Linux x64 Debian', + desc: 'amd64 .deb 安装包', + assetSuffix: 'amd64.deb', + }, + { + title: 'Linux arm64', + desc: 'arm64 AppImage', + assetSuffix: 'arm64.AppImage', + }, + ], + }, + npm: { + title: 'npm', + cmd1: 'npm install -g hermes-web-ui', + cmd2: 'hermes-web-ui start', + }, + docker: { + title: 'Docker', + cmd: 'docker compose up -d', + }, + source: { + title: '源码安装', + cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git', + cmd2: 'cd hermes-web-ui && npm install && npm run dev', + }, + prereq: '需要 Node.js >= 23', + }, + starHistory: { + title: '社区成长', + desc: '在 GitHub 上给我们加星,加入社区。', + star: '加星', + licenseAlt: '许可证', + versionAlt: '版本', + chartAlt: 'Star 历史', + }, + footer: { + description: 'Hermes Agent 的自托管 AI 聊天仪表板。', + license: 'BSL-1.1 开源协议', + madeWith: '使用 Vue 3、Naive UI 和 TypeScript 构建。', + }, + docs: { + placeholder: '从侧边栏选择一个章节开始阅读。', + sidebar: { + gettingStarted: '快速开始', + configuration: '配置说明', + features: '功能详解', + platforms: '平台接入', + api: 'API 参考', + }, + gettingStarted: { + title: '快速开始', + intro: 'Hermes Web UI 是一个自托管的 Web 仪表板,用于管理 AI 对话、平台通道、定时任务等。它封装了 Hermes Agent CLI 并提供美观的 Web 界面。', + install: { + title: '安装', + content: '通过 npm 全局安装。需要 Node.js 23 或更高版本。', + }, + firstRun: { + title: '首次运行', + content: '首次启动时,Hermes Web UI 会自动生成认证令牌、初始化本地数据、启动 Hermes agent bridge 并在浏览器中打开仪表板。', + }, + login: { + title: '登录', + content: '自动生成的令牌存储在 ~/.hermes-web-ui/.token。首次使用可通过默认登录名 admin / 默认密码 123456 登录;登录后系统会提示尽快修改默认账户和密码。', + }, + }, + configuration: { + title: '配置说明', + intro: 'Hermes Web UI 可通过环境变量进行配置。', + envVars: { + title: '环境变量', + rows: [ + ['PORT', '服务器监听端口(默认:8648)'], + ['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'], + ['HERMES_WEB_UI_HOME', 'Web UI 数据目录,用于认证 token、登录凭据、日志、数据库和默认上传目录'], + ['HERMES_WEBUI_STATE_DIR', 'HERMES_WEB_UI_HOME 的兼容别名'], + ['UPLOAD_DIR', '自定义上传根目录。文件会保存在按 Profile 隔离的子目录下'], + ['CORS_ORIGINS', 'CORS 来源配置(默认:*)'], + ['AUTH_TOKEN', '自定义 bearer token,会覆盖自动生成的 token'], + ['AUTH_JWT_SECRET', '用户名/密码会话的 JWT 签名密钥覆盖'], + ['PROFILE', '启动/默认 Hermes profile'], + ['LOG_LEVEL', 'Server 日志级别'], + ['BRIDGE_LOG_LEVEL', 'Bridge 日志级别'], + ['MAX_DOWNLOAD_SIZE', '最大文件下载大小'], + ['MAX_EDIT_SIZE', '最大可编辑文件大小'], + ['WORKSPACE_BASE', 'Workspace 浏览根目录'], + ['HERMES_HOME', 'Hermes 数据目录'], + ['HERMES_BIN', '自定义 Hermes CLI 二进制路径'], + ['HERMES_AGENT_ROOT', '包含 run_agent.py 的 Hermes Agent 源码目录'], + ['HERMES_AGENT_BRIDGE_PYTHON', '用于启动 agent bridge 的 Python 解释器'], + ['HERMES_AGENT_BRIDGE_UV', '可用时用于启动 agent bridge 的 uv 可执行文件'], + ['UV', 'uv 可执行文件 fallback'], + ['PYTHON', 'agent bridge 的 Python 可执行文件 fallback'], + ['HERMES_AGENT_BRIDGE_ENDPOINT', 'Agent bridge broker endpoint。Windows 默认 tcp://127.0.0.1:18765;macOS/Linux 默认 ipc:///tmp/hermes-agent-bridge.sock'], + ['HERMES_AGENT_BRIDGE_TIMEOUT_MS', 'Node 请求 bridge broker 的响应超时'], + ['HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS', '连接 bridge socket 失败时的短重试窗口'], + ['HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS', '等待 Python bridge ready 的超时'], + ['HERMES_AGENT_BRIDGE_AUTO_RESTART', 'bridge broker 意外退出后是否自动重启;设为 0/false/no/off 可关闭'], + ['HERMES_AGENT_BRIDGE_RESTART_DELAY_MS', 'bridge 自动重启退避的基础延迟'], + ['HERMES_AGENT_BRIDGE_PLATFORM', '传给 Hermes Agent 的 platform 标识'], + ['HERMES_AGENT_BRIDGE_WORKER_TRANSPORT', 'profile worker endpoint transport。设为 tcp 使用 loopback TCP;设为 ipc/unix 使用 Unix domain socket;默认 Windows TCP、macOS/Linux IPC'], + ['HERMES_AGENT_BRIDGE_WORKER_PORT_BASE', 'TCP worker endpoint 起始端口(默认:18780)。Version Preview 会使用独立端口段 19650'], + ['HERMES_BRIDGE_PROVIDER', 'bridge 运行时的 provider 覆盖'], + ['HERMES_BRIDGE_TOOLSETS', 'bridge 运行时的 toolset 覆盖'], + ['HERMES_BRIDGE_MAX_TURNS', 'bridge 运行时的最大轮数覆盖'], + ['HERMES_BRIDGE_SUPPRESS_PLATFORM_HINT', '控制传给 Hermes Agent 的 bridge platform hint suppression'], + ['HERMES_OPENROUTER_APP_REFERER', 'bridge 运行发送给 OpenRouter 的 attribution referer'], + ['HERMES_OPENROUTER_APP_TITLE', 'bridge 运行发送给 OpenRouter 的 attribution title'], + ['HERMES_OPENROUTER_APP_CATEGORIES', 'bridge 运行发送给 OpenRouter 的 attribution categories'], + ['HERMES_WEB_UI_MANAGED_GATEWAY', '强制启用旧 gateway 进程托管'], + ['HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN', 'Web UI 关闭时是否同时停止托管的 gateway 进程'], + ['GATEWAY_HOST', '旧 gateway 兼容配置中写入 profile 的默认 gateway host'], + ['HERMES_WEB_UI_PREVIEW_REPO', 'Version Preview 使用的 GitHub 仓库'], + ['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT', 'Version Preview 的 broker endpoint transport。设为 tcp 可让预览环境在 macOS/Linux 上也使用 loopback TCP;未设置时会跟随 HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp'], + ['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT', '直接覆盖 Version Preview 的 broker endpoint;用于需要完全自定义预览 bridge 地址的部署'], + ['HERMES_WEB_UI_BACKEND_PORT', 'Vite dev proxy 使用的后端端口'], + ['HERMES_WEB_UI_FRONTEND_PORT', '前端 Vite dev server 端口'], + ], + }, + gateway: { + title: 'Agent Bridge 运行时', + content: '聊天运行通过 Hermes agent bridge 处理。它随 Web UI 服务一起运行,并直接连接 Hermes Agent runtime。HERMES_AGENT_BRIDGE_ENDPOINT 控制 Node 与 bridge broker 的连接地址;HERMES_AGENT_BRIDGE_WORKER_TRANSPORT 控制 broker 与各 Profile worker 的连接方式。前端切换 Hermes Profile 只影响后续请求上下文,不会重启 bridge 或清理其他正在运行的任务。', + }, + profiles: { + title: '配置文件', + content: 'Profile 为不同场景提供隔离配置。超级管理员可以管理全部 Profile;普通管理员只能查看和使用分配给自己的 Profile。可在 Profile 页面创建、克隆、导入、导出或切换 Hermes Profile。', + }, + }, + features: { + title: '功能详解', + intro: '探索 Hermes Web UI 的核心功能。', + chat: { + title: 'AI 聊天', + content: '通过 Socket.IO /chat-run 实时流式聊天。支持多会话管理、Markdown 渲染与语法高亮、工具调用检查、按 Profile 上传、按路径下载,以及 Ctrl+K 搜索 Web UI 本地会话库。', + }, + kanban: { + title: '看板管理', + content: '可视化任务看板,包含 7 个状态列:分流、待办、就绪、运行中、阻塞、完成和已归档。支持任务分配、筛选和通过侧边抽屉进行详细编辑。', + }, + groupChat: { + title: '群聊协作', + content: '多 Agent 聊天室,多个 AI Agent 协同工作。支持提及路由触发特定 Agent、历史记录超限时自动压缩上下文、输入状态指示和基于 SQLite 的消息持久化。', + }, + jobs: { + title: '定时任务', + content: '创建和管理基于 cron 的定时任务,自动运行 AI 任务。可配置计划、提示词和模型。', + }, + skills: { + title: '技能', + content: '浏览和管理已安装的 AI 技能。技能通过专业知识和工具集成扩展 Agent 能力。', + }, + memory: { + title: '记忆', + content: '管理 Agent 记忆和用户笔记。Agent 使用记忆在对话间保持上下文并提供个性化回复。', + }, + terminal: { + title: '终端', + content: '基于 node-pty 和 xterm.js 的浏览器内完整伪终端。支持多个终端会话、实时键盘输入和通过 WebSocket 的窗口大小调整。', + }, + files: { + title: '文件管理', + content: '浏览和管理本地、Docker、SSH 和 Singularity 等远程后端上的文件。上传保存到当前选择/请求的 Profile;下载按真实路径解析,因此上传目录外的 Agent 产物也可以下载。', + }, + analytics: { + title: '用量分析', + content: '追踪 Token 用量(输入/输出)、预估费用、缓存命中率、会话数和模型分布。查看 30 天日趋势交互图表。', + }, + }, + platforms: { + title: '平台接入', + intro: '从通道设置页面配置消息平台集成。', + telegram: { + title: 'Telegram', + content: '通过 BotFather 创建 Telegram Bot,输入 Bot Token。可配置提及要求、自由回复聊天和反应处理。', + }, + discord: { + title: 'Discord', + content: '在开发者门户创建 Discord Bot。支持自动创建线程、允许/忽略频道、反应处理和自由回复频道。', + }, + slack: { + title: 'Slack', + content: '创建带有 bot token 权限的 Slack App。配置提及要求、Bot 白名单和自由回复频道。', + }, + whatsapp: { + title: 'WhatsApp', + content: '启用 WhatsApp 集成,配置提及模式和自由回复聊天。', + }, + matrix: { + title: 'Matrix', + content: '提供访问令牌和服务器 URL。支持自动线程、私聊提及线程和自由回复房间。', + }, + feishu: { + title: '飞书', + content: '注册飞书应用并配置 App ID 和 Secret。', + }, + wechat: { + title: '微信', + content: '从设置页面扫描二维码登录。凭据会自动保存供后续使用。', + }, + wecom: { + title: '企业微信', + content: '从企业微信管理后台配置 Bot ID 和 Secret。', + }, + }, + api: { + title: 'API 参考', + intro: 'Hermes Web UI 提供本地 BFF API,并通过 Socket.IO 端点进行聊天流式通信。', + local: { + title: '本地 BFF 端点', + content: 'Koa 服务器处理会话管理、Profile CRUD、分账户分 Profile 管理、配置读写、日志访问、技能列表、记忆操作和静态资源。', + }, + proxy: { + title: '聊天流式通信', + content: '聊天运行使用 /chat-run Socket.IO 命名空间和 Hermes agent bridge。旧 gateway proxy 路由仅在兼容场景下保留。', + }, + auth: { + title: '认证', + content: 'API 端点需要经过认证访问。令牌在首次运行时自动生成并存储在 ~/.hermes-web-ui/.token。用户名/密码登录使用账户记录;超级管理员管理用户和 Profile 绑定,普通管理员管理自己的账户信息。', + }, + }, + }, +} diff --git a/packages/website/src/main.ts b/packages/website/src/main.ts new file mode 100644 index 0000000..99b473f --- /dev/null +++ b/packages/website/src/main.ts @@ -0,0 +1,18 @@ +import { createApp } from 'vue' +import router from './router' +import { i18n } from './i18n' +import App from './App.vue' +// Import CSS custom properties (theme variables) from client +import '@client/styles/variables.scss' +import './styles/global.scss' + +const savedTheme = localStorage.getItem('hermes_website_theme') || 'system' +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches +if (savedTheme === 'dark' || (savedTheme === 'system' && prefersDark)) { + document.documentElement.classList.add('dark') +} + +const app = createApp(App) +app.use(i18n) +app.use(router) +app.mount('#app') diff --git a/packages/website/src/router/index.ts b/packages/website/src/router/index.ts new file mode 100644 index 0000000..4148bf3 --- /dev/null +++ b/packages/website/src/router/index.ts @@ -0,0 +1,61 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const EmptyView = { render: () => null } + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + name: 'landing', + component: () => import('@/views/LandingView.vue'), + }, + { + path: '/docs', + name: 'docs', + component: () => import('@/views/DocsView.vue'), + redirect: { name: 'docs.getting-started' }, + children: [ + { + path: 'getting-started', + name: 'docs.getting-started', + component: EmptyView, + meta: { page: 'gettingStarted' }, + }, + { + path: 'configuration', + name: 'docs.configuration', + component: EmptyView, + meta: { page: 'configuration' }, + }, + { + path: 'features', + name: 'docs.features', + component: EmptyView, + meta: { page: 'features' }, + }, + { + path: 'platforms', + name: 'docs.platforms', + component: EmptyView, + meta: { page: 'platforms' }, + }, + { + path: 'api', + name: 'docs.api', + component: EmptyView, + meta: { page: 'api' }, + }, + ], + }, + { + path: '/:pathMatch(.*)*', + redirect: '/', + }, + ], + scrollBehavior() { + return { top: 0 } + }, +}) + +export default router diff --git a/packages/website/src/styles/_variables.scss b/packages/website/src/styles/_variables.scss new file mode 100644 index 0000000..b776c0a --- /dev/null +++ b/packages/website/src/styles/_variables.scss @@ -0,0 +1,14 @@ +// Website SCSS variables — pure constants (no CSS custom properties) +// CSS custom properties are defined in global.scss (imported from client) + +$font-ui: 'Inter', system-ui, -apple-system, sans-serif; +$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + +$breakpoint-mobile: 768px; + +$radius-sm: 6px; +$radius-md: 10px; +$radius-lg: 14px; + +$transition-fast: 0.15s ease; +$transition-normal: 0.25s ease; diff --git a/packages/website/src/styles/global.scss b/packages/website/src/styles/global.scss new file mode 100644 index 0000000..5c8ce9d --- /dev/null +++ b/packages/website/src/styles/global.scss @@ -0,0 +1,132 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 14px; + line-height: 1.6; + scroll-behavior: smooth; +} + +body { + font-family: $font-ui; + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--accent-primary); + text-decoration: none; + + &:hover { + color: var(--accent-hover); + } +} + +code { + font-family: $font-code; + background: var(--code-bg); + padding: 2px 6px; + border-radius: $radius-sm; + font-size: 0.9em; +} + +pre { + code { + display: block; + padding: 16px; + border-radius: $radius-md; + overflow-x: auto; + } +} + +.section { + max-width: 1120px; + margin: 0 auto; + padding: 80px 24px; + + @media (max-width: $breakpoint-mobile) { + padding: 48px 16px; + } +} + +.section-title { + font-size: 32px; + font-weight: 700; + text-align: center; + margin-bottom: 16px; + color: var(--text-primary); + + @media (max-width: $breakpoint-mobile) { + font-size: 24px; + } +} + +.section-desc { + text-align: center; + color: var(--text-secondary); + font-size: 16px; + max-width: 640px; + margin: 0 auto 48px; +} + +// ─── Scroll reveal animations ──────────────────────────── + +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s ease, transform 0.6s ease; + + &.revealed { + opacity: 1; + transform: translateY(0); + } +} + +.reveal-delay-1 { transition-delay: 0.08s; } +.reveal-delay-2 { transition-delay: 0.16s; } +.reveal-delay-3 { transition-delay: 0.24s; } +.reveal-delay-4 { transition-delay: 0.32s; } + +// ─── Keyframes ──────────────────────────────────────────── + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(32px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes pulse-border { + 0%, 100% { border-color: var(--border-color); } + 50% { border-color: var(--text-muted); } +} + +.animate-fade-in-up { + animation: fade-in-up 0.7s ease both; +} + +.animate-fade-in { + animation: fade-in 0.5s ease both; +} + +.animate-delay-1 { animation-delay: 0.1s; } +.animate-delay-2 { animation-delay: 0.2s; } +.animate-delay-3 { animation-delay: 0.3s; } +.animate-delay-4 { animation-delay: 0.4s; } +.animate-delay-5 { animation-delay: 0.5s; } diff --git a/packages/website/src/views/DocsView.vue b/packages/website/src/views/DocsView.vue new file mode 100644 index 0000000..da504ac --- /dev/null +++ b/packages/website/src/views/DocsView.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/packages/website/src/views/LandingView.vue b/packages/website/src/views/LandingView.vue new file mode 100644 index 0000000..b424778 --- /dev/null +++ b/packages/website/src/views/LandingView.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7afd801 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +/// +import { defineConfig, devices } from '@playwright/test' + +const PORT = Number(process.env.PLAYWRIGHT_PORT || 4173) +const BASE_URL = `http://127.0.0.1:${PORT}` + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['dot'], ['html', { open: 'never' }]] : [['list']], + use: { + baseURL: BASE_URL, + locale: 'en-US', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: `npx vite --host 127.0.0.1 --port ${PORT}`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs new file mode 100644 index 0000000..4aa08de --- /dev/null +++ b/scripts/build-server.mjs @@ -0,0 +1,45 @@ +import * as esbuild from 'esbuild' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync } from 'fs' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8')) +const version = pkg.version +const serverOutDir = resolve(rootDir, 'dist/server') + +rmSync(serverOutDir, { recursive: true, force: true }) +mkdirSync(serverOutDir, { recursive: true }) + +await esbuild.build({ + entryPoints: [resolve(rootDir, 'packages/server/src/index.ts')], + bundle: true, + platform: 'node', + target: 'node23', + format: 'cjs', + outfile: resolve(serverOutDir, 'index.js'), + external: ['node-pty', 'node:sqlite', 'socket.io'], + define: { + __APP_VERSION__: JSON.stringify(version), + }, + sourcemap: true, + minify: true, + treeShaking: true, + logLevel: 'info', +}) + +const bridgeOutDir = resolve(serverOutDir, 'agent-bridge') +mkdirSync(bridgeOutDir, { recursive: true }) +cpSync( + resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'), + resolve(bridgeOutDir, 'hermes_bridge.py'), +) +chmodSync(resolve(bridgeOutDir, 'hermes_bridge.py'), 0o755) + +const skillsOutDir = resolve(rootDir, 'dist/skills') +rmSync(skillsOutDir, { recursive: true, force: true }) +cpSync( + resolve(rootDir, 'packages/skills'), + skillsOutDir, + { recursive: true }, +) diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..1f28545 --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,609 @@ +#!/usr/bin/env node +/** + * Auto-generate OpenAPI specification from existing Koa routes and controllers + * + * This script scans both route files and controller files to generate comprehensive + * OpenAPI documentation without requiring code changes or decorators. + */ + +import { readFileSync, writeFileSync, readdirSync } from 'fs' +import { resolve, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const rootDir = resolve(__dirname, '..') +const routesDir = join(rootDir, 'packages/server/src/routes') +const controllersDir = join(rootDir, 'packages/server/src/controllers') + +// OpenAPI template +const openapi = { + openapi: '3.0.3', + info: { + title: 'Hermes Web UI API', + description: 'BFF server API for Hermes Web UI — chat sessions, scheduled jobs, platform channels, model management, skills, memory, logs, file browser, group chat, and terminal.', + version: '0.5.9', + }, + servers: [ + { url: 'http://localhost:8648', description: 'Local development' }, + ], + tags: [], + paths: {}, + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'API Token', + }, + }, + schemas: {}, + responses: {}, + }, +} + +// Tag mappings based on route directories +const tagMappings = { + 'routes/hermes/sessions.ts': { name: 'Sessions', description: 'Chat session management' }, + 'routes/hermes/profiles.ts': { name: 'Profiles', description: 'Hermes profile management' }, + 'routes/hermes/gateways.ts': { name: 'Gateways', description: 'Gateway process management' }, + 'routes/hermes/models.ts': { name: 'Models', description: 'Model configuration' }, + 'routes/hermes/providers.ts': { name: 'Providers', description: 'Model provider management' }, + 'routes/hermes/skills.ts': { name: 'Skills', description: 'Skill browsing and management' }, + 'routes/hermes/memory.ts': { name: 'Memory', description: 'Agent memory files' }, + 'routes/hermes/logs.ts': { name: 'Logs', description: 'Log file access' }, + 'routes/hermes/jobs.ts': { name: 'Jobs', description: 'Scheduled job management' }, + 'routes/hermes/cron-history.ts': { name: 'Jobs', description: 'Cron job history' }, + 'routes/hermes/weixin.ts': { name: 'Weixin', description: 'WeChat QR code login' }, + 'routes/hermes/codex-auth.ts': { name: 'Codex Auth', description: 'OpenAI Codex OAuth' }, + 'routes/hermes/nous-auth.ts': { name: 'Nous Auth', description: 'Nous Research OAuth' }, + 'routes/hermes/copilot-auth.ts': { name: 'Copilot Auth', description: 'GitHub Copilot OAuth' }, + 'routes/hermes/group-chat.ts': { name: 'Group Chat', description: 'Group chat management' }, + 'routes/hermes/chat-run.ts': { name: 'Chat', description: 'Chat run and streaming' }, + 'routes/hermes/config.ts': { name: 'Config', description: 'Configuration management' }, + 'routes/hermes/files.ts': { name: 'Files', description: 'Hermes file browser' }, + 'routes/hermes/download.ts': { name: 'Download', description: 'File download' }, + 'routes/hermes/terminal.ts': { name: 'Terminal', description: 'WebSocket terminal' }, + 'routes/hermes/proxy.ts': { name: 'Proxy', description: 'Gateway proxy' }, + 'routes/health.ts': { name: 'Health', description: 'Health check' }, + 'routes/update.ts': { name: 'Update', description: 'Self-update management' }, + 'routes/upload.ts': { name: 'Upload', description: 'File upload' }, + 'routes/webhook.ts': { name: 'Webhook', description: 'Incoming webhooks' }, + 'routes/auth.ts': { name: 'Auth', description: 'Authentication management' }, +} + +// Extract route definitions from route files +function scanRoutes() { + const paths = {} + + // Scan hermes routes + const hermesRoutesDir = join(routesDir, 'hermes') + const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts')) + + for (const file of hermesRouteFiles) { + const routePath = join('hermes', file) + const tagInfo = tagMappings[`routes/${routePath}`] + if (tagInfo) { + scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths) + } + } + + // Scan top-level routes + for (const [routeFile, tagInfo] of Object.entries(tagMappings)) { + if (!routeFile.startsWith('routes/hermes/')) { + const filePath = join(routesDir, routeFile.replace('routes/', '')) + try { + scanRouteFile(filePath, tagInfo, paths) + } catch (e) { + // File might not exist, skip + } + } + } + + return paths +} + +function scanRouteFile(filePath, tagInfo, paths) { + const content = readFileSync(filePath, 'utf-8') + + // Pattern 1: controller functions - sessionRoutes.get('/path', ctrl.method) + const ctrlRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*ctrl\.(\w+)/g + + let match + while ((match = ctrlRouteRegex.exec(content)) !== null) { + const [, method, path, controllerMethod] = match + addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index) + } + + // Pattern 2: inline functions - groupChatRoutes.post('/path', async (ctx) => {...}) + const inlineRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*async\s*\(ctx\)/g + + while ((match = inlineRouteRegex.exec(content)) !== null) { + const [, method, path] = match + const controllerMethod = generateOperationIdFromPath(path, method) + addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index) + } +} + +function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, matchIndex) { + // Clean path parameters + const openapiPath = path + .replace(/:([^/]+)/g, '{$1}') + .replace(/\*\*([^/]*)/g, '{$1}') + + if (!paths[openapiPath]) { + paths[openapiPath] = {} + } + + // Generate operation ID + const operationId = `${controllerMethod}` + + // Generate description from JSDoc comments above the route + const precedingContent = content.substring(Math.max(0, matchIndex - 500), matchIndex) + const description = extractJsDocDescription(precedingContent) || `${method.toUpperCase()} ${path}` + + paths[openapiPath][method] = { + tags: [tagInfo.name], + summary: generateSummary(path, method, controllerMethod), + description, + operationId, + security: [{ BearerAuth: [] }], + responses: generateResponses(path, method), + } +} + +function generateOperationIdFromPath(path, method) { + const parts = path.split('/').filter(Boolean) + const lastPart = parts[parts.length - 1] + + if (lastPart && !lastPart.includes(':') && !lastPart.includes('*')) { + const actionMap = { + get: 'get', + post: 'create', + put: 'update', + patch: 'patch', + delete: 'delete', + } + return `${actionMap[method]}${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}` + } + + const parentPart = parts[parts.length - 2] + if (parentPart) { + return `${method}${parentPart.charAt(0).toUpperCase() + parentPart.slice(1)}` + } + + return method +} + +function extractJsDocDescription(content) { + const jsDocRegex = /\/\*\*[\s\S]*?\*\// + const match = content.match(jsDocRegex) + if (match) { + const jsDoc = match[0] + // Extract description text + const description = jsDoc + .replace(/\/\*\*|\*\//g, '') + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '').trim()) + .filter(line => line && !line.startsWith('@')) + .join('\n') + return description || null + } + return null +} + +function generateSummary(path, method, controllerMethod) { + const parts = path.split('/').filter(Boolean) + const resource = parts[parts.length - 1] || 'root' + + // Use controller method name to generate better summary + const methodMap = { + list: 'List', + get: 'Get', + create: 'Create', + update: 'Update', + remove: 'Delete', + delete: 'Delete', + rename: 'Rename', + pause: 'Pause', + resume: 'Resume', + run: 'Run', + search: 'Search', + add: 'Add', + } + + const action = methodMap[controllerMethod] || { + get: 'Get', + post: 'Create', + put: 'Update', + patch: 'Update', + delete: 'Delete', + }[method] + + if (resource.includes('{')) { + const paramName = resource.match(/\{([^}]+)\}/)?.[1] || 'id' + const parentResource = parts[parts.length - 2] || 'resource' + return `${action} ${parentResource} by ${paramName}` + } + + return `${action} ${resource}` +} + +function generateResponses(path, method) { + const responses = { + '200': { + description: 'Success', + }, + '401': { + $ref: '#/components/responses/Unauthorized', + }, + } + + if (method === 'get' && path.includes('/')) { + responses['404'] = { description: 'Not found' } + } + + if (method === 'post' || method === 'put' || method === 'patch') { + responses['400'] = { $ref: '#/components/responses/BadRequest' } + } + + return responses +} + +// Add standard responses +openapi.components.responses = { + Unauthorized: { + description: 'Unauthorized - Invalid or missing authentication token', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + BadRequest: { + description: 'Bad Request - Invalid parameters', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { type: 'string', example: 'Invalid request' }, + }, + }, + }, + }, + }, + NotFound: { + description: 'Resource not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { type: 'string', example: 'Not found' }, + }, + }, + }, + }, + }, +} + +// Add proxy endpoints that forward to upstream Hermes API +openapi.paths['/api/hermes/{*any}'] = { + 'get': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermes', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'post': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesPost', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'put': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesPut', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'delete': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesDelete', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, +} + +openapi.paths['/v1/{*any}'] = { + 'get': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes v1 API', + description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.', + operationId: 'proxyV1', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'post': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes v1 API', + description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.', + operationId: 'proxyV1Post', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, +} + +// Add Proxy tag +if (!openapi.tags.find(t => t.name === 'Proxy')) { + openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' }) +} + +// Add WebSocket terminal endpoint +openapi.paths['/api/hermes/terminal'] = { + 'get': { + tags: ['Terminal'], + summary: 'WebSocket terminal connection', + description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.', + operationId: 'terminalWebSocket', + responses: { + '101': { description: 'Switching Protocols - WebSocket connection established' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, +} + +// Add Chat streaming endpoint +openapi.paths['/api/hermes/v1/runs/{runId}/events'] = { + 'get': { + tags: ['Chat'], + summary: 'Server-Sent Events for chat streaming', + description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.', + operationId: 'chatStreamEvents', + parameters: [ + { + name: 'runId', + in: 'path', + required: true, + description: 'Chat run ID', + schema: { type: 'string' }, + }, + { + name: 'token', + in: 'query', + required: true, + description: 'Authentication token', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'SSE stream established', + content: { + 'text/event-stream': { + schema: { + type: 'object', + properties: { + event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] }, + data: { type: 'object' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '404': { description: 'Run not found' }, + }, + }, +} + +// Add Terminal tag +if (!openapi.tags.find(t => t.name === 'Terminal')) { + openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' }) +} + +// Run scanner +console.log('Scanning routes...') +openapi.paths = scanRoutes() + +// Collect all tags +const tagSet = new Set() +Object.values(openapi.paths).forEach(pathItem => { + Object.values(pathItem).forEach(operation => { + operation.tags?.forEach(tag => tagSet.add(tag)) + }) +}) + +openapi.tags = Array.from(tagSet).map(tag => { + const tagInfo = Object.values(tagMappings).find(t => t.name === tag) + return { + name: tag, + description: tagInfo?.description || '', + } +}) + +// Sort paths +const sortedPaths = {} +Object.keys(openapi.paths).sort().forEach(key => { + sortedPaths[key] = openapi.paths[key] +}) +openapi.paths = sortedPaths + +// Add special endpoints after sorting +// Add proxy endpoints that forward to upstream Hermes API +openapi.paths['/api/hermes/{*any}'] = { + 'get': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermes', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'post': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesPost', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'put': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesPut', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'delete': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes API', + description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.', + operationId: 'proxyHermesDelete', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, +} + +openapi.paths['/v1/{*any}'] = { + 'get': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes v1 API', + description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.', + operationId: 'proxyV1', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, + 'post': { + tags: ['Proxy'], + summary: 'Proxy to upstream Hermes v1 API', + description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.', + operationId: 'proxyV1Post', + responses: { + '200': { description: 'Proxied response from upstream' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '502': { description: 'Proxy failure' }, + }, + }, +} + +// Add WebSocket terminal endpoint +openapi.paths['/api/hermes/terminal'] = { + 'get': { + tags: ['Terminal'], + summary: 'WebSocket terminal connection', + description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.', + operationId: 'terminalWebSocket', + responses: { + '101': { description: 'Switching Protocols - WebSocket connection established' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, +} + +// Add Chat streaming endpoint +openapi.paths['/api/hermes/v1/runs/{runId}/events'] = { + 'get': { + tags: ['Chat'], + summary: 'Server-Sent Events for chat streaming', + description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.', + operationId: 'chatStreamEvents', + parameters: [ + { + name: 'runId', + in: 'path', + required: true, + description: 'Chat run ID', + schema: { type: 'string' }, + }, + { + name: 'token', + in: 'query', + required: true, + description: 'Authentication token', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'SSE stream established', + content: { + 'text/event-stream': { + schema: { + type: 'object', + properties: { + event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] }, + data: { type: 'object' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '404': { description: 'Run not found' }, + }, + }, +} + +// Add Proxy and Terminal tags +if (!openapi.tags.find(t => t.name === 'Proxy')) { + openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' }) +} +if (!openapi.tags.find(t => t.name === 'Terminal')) { + openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' }) +} + +// Write output +const outputPath = join(rootDir, 'docs/openapi.json') +writeFileSync(outputPath, JSON.stringify(openapi, null, 2)) + +console.log(`✓ Generated OpenAPI spec: ${outputPath}`) +console.log(` ${Object.keys(openapi.paths).length} endpoints`) +console.log(` ${openapi.tags.length} tags`) diff --git a/scripts/harness-check.mjs b/scripts/harness-check.mjs new file mode 100644 index 0000000..c26db24 --- /dev/null +++ b/scripts/harness-check.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' + +const root = process.cwd() +const failures = [] + +function fail(message) { + failures.push(message) +} + +async function readText(relativePath) { + return readFile(path.join(root, relativePath), 'utf8') +} + +function requireFile(relativePath) { + if (!existsSync(path.join(root, relativePath))) { + fail(`Missing required harness file: ${relativePath}`) + } +} + +function requireDir(relativePath) { + if (!existsSync(path.join(root, relativePath))) { + fail(`Missing required project directory: ${relativePath}`) + } +} + +for (const file of [ + 'AGENTS.md', + 'ARCHITECTURE.md', + 'DEVELOPMENT.md', + 'docs/harness/README.md', + 'docs/harness/validation.md', + 'docs/harness/worktree-runbook.md', + 'docs/harness/pr-review.md', +]) { + requireFile(file) +} + +for (const dir of [ + 'packages/client/src', + 'packages/server/src', + 'packages/desktop', + 'packages/desktop/build/icons', + 'tests/client', + 'tests/server', + 'tests/e2e', + '.github/workflows', +]) { + requireDir(dir) +} + +for (const icon of [ + 'packages/desktop/build/icon.png', + 'packages/desktop/build/icon.icns', + 'packages/desktop/build/icon.ico', + 'packages/desktop/build/icons/16x16.png', + 'packages/desktop/build/icons/32x32.png', + 'packages/desktop/build/icons/48x48.png', + 'packages/desktop/build/icons/64x64.png', + 'packages/desktop/build/icons/128x128.png', + 'packages/desktop/build/icons/256x256.png', + 'packages/desktop/build/icons/512x512.png', +]) { + requireFile(icon) +} + +const agents = await readText('AGENTS.md') +const agentLines = agents.trimEnd().split(/\r?\n/) +if (agentLines.length > 120) { + fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`) +} + +for (const requiredLink of [ + 'DEVELOPMENT.md', + 'ARCHITECTURE.md', + 'docs/harness/README.md', + 'docs/harness/validation.md', + 'docs/harness/worktree-runbook.md', + 'docs/harness/pr-review.md', +]) { + if (!agents.includes(requiredLink)) { + fail(`AGENTS.md must link to ${requiredLink}`) + } +} + +const packageJson = JSON.parse(await readText('package.json')) +for (const scriptName of [ + 'harness:check', + 'test', + 'test:coverage', + 'test:e2e', + 'build', +]) { + if (!packageJson.scripts?.[scriptName]) { + fail(`package.json is missing script: ${scriptName}`) + } +} + +const architecture = await readText('ARCHITECTURE.md') +for (const phrase of [ + 'packages/client/src', + 'packages/server/src', + 'packages/desktop', + 'HERMES_WEB_UI_HOME', + 'fail_on_unmatched_files: true', +]) { + if (!architecture.includes(phrase)) { + fail(`ARCHITECTURE.md should document: ${phrase}`) + } +} + +const buildWorkflow = await readText('.github/workflows/build.yml') +if (!buildWorkflow.includes('npm run harness:check')) { + fail('Build workflow must run npm run harness:check') +} + +const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml') +const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml') +const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml') +const desktopPackageJson = await readText('packages/desktop/package.json') +const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs') +const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts') +const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts') +const desktopPaths = await readText('packages/desktop/src/main/paths.ts') +const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs') +if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) { + fail('desktop-release.yml must upload matrix-specific artifact_files') +} + +if (!electronBuilderConfig.includes('icon: build/icons')) { + fail('electron-builder.yml must configure the Linux icon set') +} + +for (const target of ['target_os: darwin', 'target_os: win32', 'target_os: linux']) { + if (!desktopReleaseWorkflow.includes(target)) { + fail(`desktop-release.yml is missing matrix target ${target}`) + } +} + +for (const expectedGlob of ['*.dmg', '*.exe', '*.AppImage']) { + if (!desktopReleaseWorkflow.includes(expectedGlob)) { + fail(`desktop-release.yml is missing expected artifact glob ${expectedGlob}`) + } +} + +if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) { + fail('desktop-release.yml must keep fail_on_unmatched_files: true') +} + +for (const phrase of [ + 'resources/python/${os}-${arch}', + 'resources/node/${os}-${arch}', + 'resources/git/${os}-${arch}', +]) { + if (electronBuilderConfig.includes(phrase)) { + fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`) + } +} + +for (const phrase of [ + '"fetch:node"', + '"fetch:git"', + '"prepare:runtime"', + '"package:runtime"', + '"runtime:asset-name"', +]) { + if (!desktopPackageJson.includes(phrase)) { + fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`) + } +} + +for (const phrase of [ + 'steps.check.outputs.missing', + 'npm --prefix packages/desktop run prepare:runtime', + 'npm --prefix packages/desktop run package:runtime', +]) { + if (!desktopRuntimeWorkflow.includes(phrase)) { + fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`) + } +} + +if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) { + fail('runtime asset naming must include hermes-agent version') +} + +for (const phrase of [ + 'websockets', + 'agent-browser@^0.26.0', + 'AGENT_BROWSER_HOME', + 'AGENT_BROWSER_EXECUTABLE_PATH', + 'PLAYWRIGHT_BROWSERS_PATH', + 'ms-playwright', + 'removeBrokenDashboardAuthPlugin', +]) { + if (!desktopInstallHermes.includes(phrase)) { + fail(`install-hermes.mjs must bundle Hermes browser runtime support: ${phrase}`) + } +} + +for (const phrase of [ + 'bundledNodeBin', + 'HERMES_AGENT_NODE', + 'HERMES_AGENT_GIT', + 'PLAYWRIGHT_BROWSERS_PATH', + 'ms-playwright', +]) { + if (!desktopWebuiServer.includes(phrase)) { + fail(`desktop webui server must expose bundled browser runtime: ${phrase}`) + } +} + +for (const phrase of [ + 'HERMES_DESKTOP_RUNTIME_URL', + 'HERMES_DESKTOP_RUNTIME_BASE_URL', + 'runtime-manifest.json', +]) { + if (!desktopRuntimeManager.includes(phrase)) { + fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`) + } +} + +if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) { + fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override') +} + +if (failures.length > 0) { + console.error('Harness check failed:') + for (const failure of failures) { + console.error(`- ${failure}`) + } + process.exit(1) +} + +console.log('Harness check passed') diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts new file mode 100644 index 0000000..caa6d40 --- /dev/null +++ b/tests/client/api.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// vi.mock is hoisted, so mockReplace must be inside the factory +vi.mock('@/router', () => ({ + default: { + currentRoute: { value: { name: 'hermes.chat' } }, + replace: vi.fn(), + }, +})) + +import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client' +import { getDownloadUrl } from '../../packages/client/src/api/hermes/download' +import { uploadFiles } from '../../packages/client/src/api/hermes/files' +import { batchDeleteSessions, importHermesSession } from '../../packages/client/src/api/hermes/sessions' +import router from '@/router' + +function fakeJwt(payload: Record) { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + return `${header}.${body}.signature` +} + +describe('API Client', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + describe('token management', () => { + it('hasApiKey returns false when no token', () => { + expect(hasApiKey()).toBe(false) + }) + + it('hasApiKey returns true after setApiKey', () => { + setApiKey('test-token') + expect(hasApiKey()).toBe(true) + }) + + it('getApiKey returns the stored token', () => { + setApiKey('my-token') + expect(getApiKey()).toBe('my-token') + }) + + it('clearApiKey removes the token', () => { + setApiKey('my-token') + clearApiKey() + expect(hasApiKey()).toBe(false) + expect(getApiKey()).toBe('') + }) + + it('reads the role from the stored JWT payload', () => { + setApiKey(fakeJwt({ sub: '1', role: 'super_admin' })) + + expect(getStoredUserRole()).toBe('super_admin') + expect(isStoredSuperAdmin()).toBe(true) + + setApiKey(fakeJwt({ sub: '2', role: 'admin' })) + expect(getStoredUserRole()).toBe('admin') + expect(isStoredSuperAdmin()).toBe(false) + }) + }) + + describe('request', () => { + it('adds Authorization header when token exists', async () => { + setApiKey('secret-key') + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) + + await request('/api/hermes/sessions') + + expect(mockFetch).toHaveBeenCalledOnce() + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.Authorization).toBe('Bearer secret-key') + }) + + it('adds the active profile header, including default', async () => { + localStorage.setItem('hermes_active_profile_name', 'default') + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) + + await request('/api/hermes/sessions/session-1') + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers['X-Hermes-Profile']).toBe('default') + }) + + it('does not add the active profile header to profile-wide session collection requests', async () => { + localStorage.setItem('hermes_active_profile_name', 'research') + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) + + await request('/api/hermes/sessions') + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers['X-Hermes-Profile']).toBeUndefined() + }) + + it('does not add Authorization header when no token', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) + + await request('/api/hermes/sessions') + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers.Authorization).toBeUndefined() + }) + + it('clears token and redirects on 401 for local BFF endpoints', async () => { + setApiKey('secret-key') + mockFetch.mockResolvedValue({ ok: false, status: 401 }) + + await expect(request('/api/hermes/sessions')).rejects.toThrow('Unauthorized') + expect(hasApiKey()).toBe(false) + expect(router.replace).toHaveBeenCalledWith({ name: 'login' }) + }) + + it('emits a global auth notice on local 403 responses', async () => { + const listener = vi.fn() + window.addEventListener('hermes-auth-notice', listener) + mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') }) + + await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403') + + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' }) + window.removeEventListener('hermes-auth-notice', listener) + }) + + it('clears token and redirects when the JWT user no longer exists', async () => { + setApiKey('stale-jwt') + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'), + }) + + await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403') + + expect(hasApiKey()).toBe(false) + expect(router.replace).toHaveBeenCalledWith({ name: 'login' }) + }) + + it('does NOT clear token on 401 for proxied v1 endpoints', async () => { + setApiKey('secret-key') + mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) + + await expect(request('/api/hermes/v1/runs')).rejects.toThrow('API Error 401') + expect(hasApiKey()).toBe(true) + }) + + it('throws error on non-401 failure', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }) + + await expect(request('/api/hermes/sessions')).rejects.toThrow('API Error 500: Internal Server Error') + }) + + it('returns parsed JSON on success', async () => { + const data = { sessions: [{ id: '1' }] } + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(data) }) + + const result = await request('/api/hermes/sessions') + expect(result).toEqual(data) + }) + }) + + describe('download URLs', () => { + it('adds the active profile selector to direct download URLs', () => { + setApiKey('secret-key') + localStorage.setItem('hermes_active_profile_name', 'research') + + const url = new URL(getDownloadUrl('/tmp/report.txt', 'report.txt'), 'http://localhost') + + expect(url.pathname).toBe('/api/hermes/download') + expect(url.searchParams.get('path')).toBe('/tmp/report.txt') + expect(url.searchParams.get('name')).toBe('report.txt') + expect(url.searchParams.get('profile')).toBe('research') + expect(url.searchParams.get('token')).toBe('secret-key') + }) + }) + + describe('file upload', () => { + it('adds auth and active profile headers to multipart uploads', async () => { + setApiKey('secret-key') + localStorage.setItem('hermes_active_profile_name', 'research') + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }), + }) + + await uploadFiles('notes', [new File(['hello'], 'hello.txt', { type: 'text/plain' })]) + + expect(mockFetch).toHaveBeenCalledOnce() + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('/api/hermes/files/upload?path=notes') + expect(options.method).toBe('POST') + expect(options.headers.Authorization).toBe('Bearer secret-key') + expect(options.headers['X-Hermes-Profile']).toBe('research') + expect(options.body).toBeInstanceOf(FormData) + }) + }) + + describe('sessions API', () => { + it('sends profile-qualified targets for batch deletes', async () => { + localStorage.setItem('hermes_active_profile_name', 'research') + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ deleted: 2, failed: 0, errors: [] }), + }) + + await batchDeleteSessions([ + { id: 'session-default', profile: 'default' }, + { id: 'session-travel', profile: 'travel' }, + ]) + + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('/api/hermes/sessions/batch-delete') + expect(options.method).toBe('POST') + expect(options.headers['X-Hermes-Profile']).toBeUndefined() + expect(JSON.parse(options.body)).toEqual({ + ids: ['session-default', 'session-travel'], + sessions: [ + { id: 'session-default', profile: 'default' }, + { id: 'session-travel', profile: 'travel' }, + ], + }) + }) + + it('sends the profile selector when importing a Hermes session', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ ok: true, imported: true }), + }) + + await importHermesSession('cli-1', 'travel') + + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe('/api/hermes/sessions/hermes/cli-1/import?profile=travel') + expect(options.method).toBe('POST') + }) + }) +}) diff --git a/tests/client/app-store.test.ts b/tests/client/app-store.test.ts new file mode 100644 index 0000000..e5ec11b --- /dev/null +++ b/tests/client/app-store.test.ts @@ -0,0 +1,492 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const mockSystemApi = vi.hoisted(() => ({ + checkHealth: vi.fn(), + fetchAvailableModels: vi.fn(), + addCustomModel: vi.fn(), + removeCustomModel: vi.fn(), + updateDefaultModel: vi.fn(), + updateModelAlias: vi.fn(), + updateModelVisibility: vi.fn(), + triggerUpdate: vi.fn(), +})) + +vi.mock('@/api/hermes/system', () => mockSystemApi) +vi.mock('@/api/client', () => ({ hasApiKey: () => true })) + +import { useAppStore } from '@/stores/hermes/app' + +describe('App Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockSystemApi.addCustomModel.mockResolvedValue({ success: true, custom_models: {} }) + mockSystemApi.removeCustomModel.mockResolvedValue({ success: true, custom_models: {} }) + window.localStorage.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('persists desktop sidebar collapsed state to localStorage', () => { + const store = useAppStore() + + expect(store.sidebarCollapsed).toBe(false) + + store.toggleSidebarCollapsed() + expect(store.sidebarCollapsed).toBe(true) + expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('1') + + store.toggleSidebarCollapsed() + expect(store.sidebarCollapsed).toBe(false) + expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0') + }) + + it('loads model visibility and falls back when the configured default is hidden', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'deepseek-chat', + default_provider: 'deepseek', + groups: [ + { + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + api_key: 'sk-test', + models: ['deepseek-reasoner'], + }, + ], + allProviders: [], + model_visibility: { + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.modelVisibility).toEqual({ + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }) + expect(store.selectedModel).toBe('deepseek-reasoner') + expect(store.selectedProvider).toBe('deepseek') + expect(store.customModels).toEqual({}) + expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true) + expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false) + }) + + it('loads aliases while falling back from a hidden default without rehydrating it as custom', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'deepseek-chat', + default_provider: 'deepseek', + groups: [ + { + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + api_key: 'sk-test', + models: ['deepseek-reasoner'], + available_models: ['deepseek-chat', 'deepseek-reasoner'], + }, + ], + allProviders: [ + { + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + api_key: 'sk-test', + models: ['deepseek-chat', 'deepseek-reasoner'], + }, + ], + model_aliases: { + deepseek: { 'deepseek-reasoner': 'Reasoner Alias' }, + }, + model_visibility: { + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.modelAliases).toEqual({ + deepseek: { 'deepseek-reasoner': 'Reasoner Alias' }, + }) + expect(store.modelVisibility).toEqual({ + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }) + expect(store.selectedModel).toBe('deepseek-reasoner') + expect(store.selectedProvider).toBe('deepseek') + expect(store.displayModelName('deepseek-reasoner', 'deepseek')).toBe('Reasoner Alias') + expect(store.customModels).toEqual({}) + }) + + it('persists model visibility without changing the canonical selected model id', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'deepseek-reasoner', + default_provider: 'deepseek', + groups: [ + { + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + api_key: 'sk-test', + models: ['deepseek-reasoner'], + }, + ], + allProviders: [], + model_visibility: { + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }, + }) + mockSystemApi.updateModelVisibility.mockResolvedValue({ + success: true, + model_visibility: { + deepseek: { mode: 'include', models: ['deepseek-reasoner'] }, + }, + }) + const store = useAppStore() + + await store.setModelVisibility('deepseek', { mode: 'include', models: ['deepseek-reasoner'] }) + + expect(mockSystemApi.updateModelVisibility).toHaveBeenCalledWith({ + provider: 'deepseek', + mode: 'include', + models: ['deepseek-reasoner'], + }) + expect(store.selectedModel).toBe('deepseek-reasoner') + expect(store.selectedProvider).toBe('deepseek') + expect(mockSystemApi.updateDefaultModel).not.toHaveBeenCalled() + }) + + it('marks the client stale when the served Web UI version changes', async () => { + mockSystemApi.checkHealth.mockResolvedValue({ + status: 'ok', + webui_version: '0.5.17', + webui_latest: '0.5.17', + webui_update_available: false, + }) + const store = useAppStore() + + await store.checkConnection() + + expect(store.connected).toBe(true) + expect(store.serverVersion).toBe('0.5.17') + expect(store.clientOutdated).toBe(true) + expect(store.updateAvailable).toBe(false) + }) + + it('does not mark the client stale when the served Web UI version matches this bundle', async () => { + mockSystemApi.checkHealth.mockResolvedValue({ + status: 'ok', + webui_version: 'test', + webui_latest: 'test', + webui_update_available: false, + }) + const store = useAppStore() + + await store.checkConnection() + + expect(store.serverVersion).toBe('test') + expect(store.clientOutdated).toBe(false) + }) + + it('clears the updating state and reports failure when self-update request fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed')) + const store = useAppStore() + + const ok = await store.doUpdate() + + expect(ok).toBe(false) + expect(store.updating).toBe(false) + expect(consoleError).toHaveBeenCalledWith('Failed to update Hermes Web UI:', expect.any(Error)) + consoleError.mockRestore() + }) + + it('loads model aliases and resolves display names without changing canonical IDs', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'deepseek-v4-flash', + default_provider: 'deepseek', + groups: [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash'], + api_key: '', + }], + allProviders: [], + model_aliases: { + deepseek: { 'deepseek-v4-flash': 'Flash Alias' }, + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.selectedModel).toBe('deepseek-v4-flash') + expect(store.getModelAlias('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias') + expect(store.displayModelName('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias') + expect(store.displayModelName('unknown', 'deepseek')).toBe('unknown') + }) + + it('selects the browser active profile default instead of the aggregate response default', async () => { + window.localStorage.setItem('hermes_active_profile_name', 'tester') + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'glm-5-turbo', + default_provider: 'custom:glm-coding-plan', + groups: [{ + provider: 'custom:glm-coding-plan', + label: 'glm-coding-plan', + base_url: 'https://api.z.ai/api/anthropic', + models: ['glm-5-turbo', 'glm-5.1'], + api_key: '', + }], + allProviders: [], + profiles: [ + { + profile: 'default', + default: 'glm-5-turbo', + default_provider: 'custom:glm-coding-plan', + groups: [{ + provider: 'custom:glm-coding-plan', + label: 'glm-coding-plan', + base_url: 'https://api.z.ai/api/anthropic', + models: ['glm-5-turbo', 'glm-5.1'], + api_key: '', + }], + }, + { + profile: 'tester', + default: 'claude-opus-4-6', + default_provider: 'custom:subrouter', + groups: [{ + provider: 'custom:subrouter', + label: 'subrouter', + base_url: 'https://subrouter.ai/v1', + models: ['claude-opus-4-6', 'gpt-5.5'], + api_key: '', + }], + }, + ], + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.selectedModel).toBe('claude-opus-4-6') + expect(store.selectedProvider).toBe('custom:subrouter') + }) + + it('does not refetch available models within the cache window after an empty response', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: '', + default_provider: '', + groups: [], + allProviders: [], + }) + const store = useAppStore() + + await store.loadModels() + await store.loadModels() + + expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1) + }) + + it('waits only up to the run timeout for the first available models request', async () => { + vi.useFakeTimers() + mockSystemApi.fetchAvailableModels.mockReturnValue(new Promise(() => {})) + const store = useAppStore() + let resolved = false + + const waitPromise = store.waitForModelsForRun(15000).then(() => { + resolved = true + }) + + expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(14999) + expect(resolved).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await waitPromise + expect(resolved).toBe(true) + expect(store.modelGroups).toEqual([]) + }) + + it('keeps aliases scoped to their provider when model IDs overlap', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'shared-model', + default_provider: 'provider-a', + groups: [ + { + provider: 'provider-a', + label: 'Provider A', + base_url: 'https://a.example/v1', + models: ['shared-model'], + api_key: '', + }, + { + provider: 'provider-b', + label: 'Provider B', + base_url: 'https://b.example/v1', + models: ['shared-model'], + api_key: '', + }, + ], + allProviders: [], + model_aliases: { + 'provider-a': { 'shared-model': 'A Alias' }, + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.displayModelName('shared-model', 'provider-a')).toBe('A Alias') + expect(store.displayModelName('shared-model', 'provider-b')).toBe('shared-model') + expect(store.displayModelName('shared-model')).toBe('A Alias') + }) + + it('rehydrates an active unlisted default model as removable after loading models', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'manually-supported-id', + default_provider: 'deepseek', + groups: [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash'], + api_key: '', + }], + allProviders: [], + model_aliases: {}, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.selectedModel).toBe('manually-supported-id') + expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] }) + }) + + it('loads persisted custom models from the server response', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: 'gemma-4-26b-a4b-it', + default_provider: 'google-ai-studio', + groups: [{ + provider: 'google-ai-studio', + label: 'Google AI Studio', + base_url: 'https://generativelanguage.googleapis.com/v1beta', + models: ['gemma-4-26b-a4b-it'], + api_key: '', + }], + allProviders: [], + custom_models: { + 'google-ai-studio': ['gemma-4-26b-a4b-it'], + }, + }) + const store = useAppStore() + + await store.loadModels() + + expect(store.selectedModel).toBe('gemma-4-26b-a4b-it') + expect(store.customModels).toEqual({ + 'google-ai-studio': ['gemma-4-26b-a4b-it'], + }) + }) + + it('saves and clears model aliases via the Web UI-only alias API', async () => { + mockSystemApi.updateModelAlias.mockResolvedValue(undefined) + const store = useAppStore() + + await store.setModelAlias('deepseek-v4-flash', 'deepseek', ' Flash Alias ') + + expect(mockSystemApi.updateModelAlias).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + alias: 'Flash Alias', + }) + expect(store.modelAliases).toEqual({ deepseek: { 'deepseek-v4-flash': 'Flash Alias' } }) + + await store.setModelAlias('deepseek-v4-flash', 'deepseek', '') + expect(store.modelAliases).toEqual({}) + }) + + it('removes an unlisted custom model and falls back to a listed model when active', async () => { + mockSystemApi.updateDefaultModel.mockResolvedValue(undefined) + const store = useAppStore() + store.modelGroups = [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash'], + api_key: '', + }] + mockSystemApi.addCustomModel.mockResolvedValue({ + success: true, + custom_models: { deepseek: ['test'] }, + }) + mockSystemApi.removeCustomModel.mockResolvedValue({ + success: true, + custom_models: {}, + }) + + await store.switchModel('test', 'deepseek') + expect(store.selectedModel).toBe('test') + expect(store.customModels).toEqual({ deepseek: ['test'] }) + expect(mockSystemApi.addCustomModel).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'test', + }) + + await store.removeCustomModel('test', 'deepseek') + expect(store.customModels).toEqual({}) + expect(mockSystemApi.removeCustomModel).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'test', + }) + expect(store.selectedModel).toBe('deepseek-v4-flash') + expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({ + default: 'deepseek-v4-flash', + provider: 'deepseek', + }) + }) + + it('removes deleted custom models from loaded model groups immediately', async () => { + mockSystemApi.removeCustomModel.mockResolvedValue({ + success: true, + custom_models: {}, + }) + const store = useAppStore() + store.customModels = { deepseek: ['manual-model'] } + store.modelGroups = [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash', 'manual-model'], + available_models: ['deepseek-v4-flash', 'manual-model'], + api_key: '', + }] + store.profileModelGroups = [{ + profile: 'default', + default: 'deepseek-v4-flash', + default_provider: 'deepseek', + groups: [{ + provider: 'deepseek', + label: 'DeepSeek', + base_url: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-flash', 'manual-model'], + available_models: ['deepseek-v4-flash', 'manual-model'], + api_key: '', + }], + }] + + await store.removeCustomModel('manual-model', 'deepseek') + + expect(store.modelGroups[0].models).toEqual(['deepseek-v4-flash']) + expect(store.modelGroups[0].available_models).toEqual(['deepseek-v4-flash']) + expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash']) + expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash']) + }) +}) diff --git a/tests/client/chat-input-draft.test.ts b/tests/client/chat-input-draft.test.ts new file mode 100644 index 0000000..7b8ebba --- /dev/null +++ b/tests/client/chat-input-draft.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { nextTick } from 'vue' +import { useChatStore } from '@/stores/hermes/chat' +import ChatInput from '@/components/hermes/chat/ChatInput.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('naive-ui', () => ({ + NButton: { template: '' }, + NTooltip: { template: '
' }, + NSwitch: { template: '' }, + NModal: { template: '
' }, + NInputNumber: { template: '' }, + useMessage: () => ({ error: vi.fn(), success: vi.fn() }), +})) + +vi.mock('@/api/hermes/sessions', () => ({ + fetchContextLength: vi.fn().mockResolvedValue(256000), +})) + +vi.mock('@/api/hermes/model-context', () => ({ + setModelContext: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/composables/useToolTraceVisibility', () => ({ + useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }), +})) + +function mountForSession(sessionId: string) { + const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn }) + const chatStore = useChatStore() + chatStore.sessions = [ + { id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now() }, + ] + chatStore.activeSessionId = sessionId + chatStore.activeSession = chatStore.sessions[0] + return mount(ChatInput, { global: { plugins: [pinia] } }) +} + +describe('ChatInput draft persistence', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('restores unsent text for the active session after the chat view is remounted', async () => { + const wrapper = mountForSession('session-a') + const textarea = wrapper.get('textarea') + + await textarea.setValue('draft before tab switch') + await nextTick() + wrapper.unmount() + + const remounted = mountForSession('session-a') + await nextTick() + + expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch') + }) + + it('stores drafts under one localStorage key mapped by session id', async () => { + const wrapperA = mountForSession('session-a') + await wrapperA.get('textarea').setValue('draft for session a') + await nextTick() + wrapperA.unmount() + + const wrapperB = mountForSession('session-b') + await wrapperB.get('textarea').setValue('draft for session b') + await nextTick() + wrapperB.unmount() + + expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull() + expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({ + 'session-a': 'draft for session a', + 'session-b': 'draft for session b', + }) + + const remountedA = mountForSession('session-a') + await nextTick() + expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a') + }) +}) diff --git a/tests/client/chat-run-reconnect.test.ts b/tests/client/chat-run-reconnect.test.ts new file mode 100644 index 0000000..37bccc1 --- /dev/null +++ b/tests/client/chat-run-reconnect.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const socketState = vi.hoisted(() => ({ + sockets: [] as any[], +})) + +vi.mock('socket.io-client', () => { + function createSocket() { + const listeners = new Map void>>() + + const addListener = (event: string, handler: (...args: any[]) => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()) + listeners.get(event)!.add(handler) + } + + const removeListener = (event: string, handler: (...args: any[]) => void) => { + const eventListeners = listeners.get(event) + if (!eventListeners) return + for (const candidate of [...eventListeners]) { + if (candidate === handler || (candidate as any).__original === handler) { + eventListeners.delete(candidate) + } + } + } + + const socket: any = { + connected: true, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + addListener(event, handler) + return socket + }), + once: vi.fn((event: string, handler: (...args: any[]) => void) => { + const wrapped = (...args: any[]) => { + removeListener(event, wrapped) + handler(...args) + } + ;(wrapped as any).__original = handler + addListener(event, wrapped) + return socket + }), + off: vi.fn((event: string, handler: (...args: any[]) => void) => { + removeListener(event, handler) + return socket + }), + removeListener: vi.fn((event: string, handler: (...args: any[]) => void) => { + removeListener(event, handler) + return socket + }), + removeAllListeners: vi.fn(() => { + listeners.clear() + return socket + }), + emit: vi.fn(), + disconnect: vi.fn(() => { + socket.connected = false + }), + __listenerCount: (event: string) => listeners.get(event)?.size || 0, + __trigger: (event: string, ...args: any[]) => { + if (event === 'connect') socket.connected = true + if (event === 'disconnect') socket.connected = false + for (const handler of [...(listeners.get(event) || [])]) handler(...args) + }, + } + + return socket + } + + return { + io: vi.fn(() => { + const socket = createSocket() + socketState.sockets.push(socket) + return socket + }), + } +}) + +vi.mock('../../packages/client/src/api/client', () => ({ + getApiKey: () => 'test-token', + getBaseUrlValue: () => '', +})) + +describe('chat-run socket reconnect handling', () => { + beforeEach(() => { + vi.resetModules() + socketState.sockets = [] + }) + + it('keeps transient mobile disconnects alive and resumes after reconnect', async () => { + const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat') + const onEvent = vi.fn() + const onDone = vi.fn() + const onError = vi.fn() + const onReconnectResume = vi.fn() + + startRunViaSocket( + { session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' }, + onEvent, + onDone, + onError, + undefined, + { onReconnectResume }, + ) + + const socket = socketState.sockets[0] + expect(socket.emit).toHaveBeenCalledWith('run', expect.objectContaining({ session_id: 'session-1' })) + + socket.__trigger('disconnect', 'ping timeout') + expect(onError).not.toHaveBeenCalled() + + socket.__trigger('connect_error', new Error('temporary reconnect failure')) + expect(onError).not.toHaveBeenCalled() + + socket.__trigger('connect') + expect(socket.emit).toHaveBeenCalledWith('resume', { session_id: 'session-1', profile: 'default' }) + + const resumed = { session_id: 'session-1', messages: [], isWorking: true, events: [] } + socket.__trigger('resumed', resumed) + expect(onReconnectResume).toHaveBeenCalledWith(resumed) + + socket.__trigger('message.delta', { event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' }) + expect(onEvent).toHaveBeenCalledWith({ event: 'message.delta', session_id: 'session-1', delta: 'after reconnect' }) + expect(onDone).not.toHaveBeenCalled() + }) + + it('keeps fatal disconnects fatal and removes per-run listeners', async () => { + const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat') + const onError = vi.fn() + + startRunViaSocket( + { session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' }, + vi.fn(), + vi.fn(), + onError, + ) + + const socket = socketState.sockets[0] + socket.__trigger('disconnect', 'io server disconnect') + + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0].message).toBe('Socket disconnected: io server disconnect') + expect(socket.__listenerCount('connect')).toBe(0) + expect(socket.__listenerCount('disconnect')).toBe(0) + expect(socket.__listenerCount('connect_error')).toBe(0) + }) + + it('does not attach extra reconnect listeners when the session already has handlers', async () => { + const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat') + const body = { session_id: 'session-1', input: 'hello', profile: 'default', source: 'cli' as const } + + startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn()) + const socket = socketState.sockets[0] + expect(socket.__listenerCount('connect')).toBe(1) + expect(socket.__listenerCount('disconnect')).toBe(1) + + startRunViaSocket(body, vi.fn(), vi.fn(), vi.fn()) + expect(socket.__listenerCount('connect')).toBe(1) + expect(socket.__listenerCount('disconnect')).toBe(1) + expect(socket.emit).toHaveBeenCalledWith('run', body) + }) + + it('fans session.command events to run-local and global handlers', async () => { + const { onSessionCommand, startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat') + const onEvent = vi.fn() + const onGlobalCommand = vi.fn() + const offGlobalCommand = onSessionCommand(onGlobalCommand) + + startRunViaSocket( + { session_id: 'session-1', input: '/goal status', profile: 'default', source: 'cli' }, + onEvent, + vi.fn(), + vi.fn(), + ) + + const socket = socketState.sockets[0] + const event = { + event: 'session.command', + session_id: 'session-1', + command: 'goal', + action: 'status', + message: 'Goal (active, 0/20 turns): write site', + } + + socket.__trigger('session.command', event) + + expect(onEvent).toHaveBeenCalledWith(event) + expect(onGlobalCommand).toHaveBeenCalledWith(event) + + offGlobalCommand() + socket.__trigger('session.command', { ...event, message: 'next status' }) + expect(onGlobalCommand).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/client/chat-store-compression-state.test.ts b/tests/client/chat-store-compression-state.test.ts new file mode 100644 index 0000000..fc15ee7 --- /dev/null +++ b/tests/client/chat-store-compression-state.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const chatApi = vi.hoisted(() => ({ + resumeSession: vi.fn(), + registerSessionHandlers: vi.fn(), + unregisterSessionHandlers: vi.fn(), +})) + +vi.mock('@/api/hermes/chat', () => ({ + startRunViaSocket: vi.fn(), + resumeSession: chatApi.resumeSession, + registerSessionHandlers: chatApi.registerSessionHandlers, + unregisterSessionHandlers: chatApi.unregisterSessionHandlers, + getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })), + respondToolApproval: vi.fn(), + respondClarify: vi.fn(), + onPeerUserMessage: vi.fn(() => vi.fn()), + onSessionCommand: vi.fn(() => vi.fn()), +})) + +vi.mock('@/api/client', () => ({ + getActiveProfileName: () => 'default', +})) + +vi.mock('@/api/hermes/sessions', () => ({ + deleteSession: vi.fn(), + fetchSession: vi.fn(), + fetchSessions: vi.fn(), + setSessionModel: vi.fn(), +})) + +vi.mock('@/api/hermes/download', () => ({ + getDownloadUrl: (_path: string, name: string) => `/download/${name}`, +})) + +vi.mock('@/utils/completion-sound', () => ({ + primeCompletionSound: vi.fn(), + playCompletionSound: vi.fn(), +})) + +import { useChatStore, type Session } from '@/stores/hermes/chat' + +function makeSession(id: string): Session { + return { + id, + title: id, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +describe('chat store compression state', () => { + beforeEach(() => { + vi.resetAllMocks() + setActivePinia(createPinia()) + chatApi.resumeSession.mockImplementation((sessionId: string, onResumed: (data: any) => void) => { + onResumed({ + session_id: sessionId, + messages: [], + isWorking: sessionId === 'session-1', + events: [], + }) + return {} as any + }) + }) + + it('does not show a background session compression indicator in the active session', async () => { + const store = useChatStore() + store.sessions = [makeSession('session-1'), makeSession('session-2')] + + await store.switchSession('session-1') + const handlers = chatApi.registerSessionHandlers.mock.calls.find(call => call[0] === 'session-1')?.[1] + expect(handlers).toBeTruthy() + + await store.switchSession('session-2') + handlers.onCompressionStarted({ + event: 'compression.started', + session_id: 'session-1', + message_count: 6, + token_count: 1234, + }) + + expect(store.activeSessionId).toBe('session-2') + expect(store.compressionState).toBeNull() + + await store.switchSession('session-1') + expect(store.compressionState).toEqual(expect.objectContaining({ + compressing: true, + messageCount: 6, + beforeTokens: 1234, + })) + }) +}) diff --git a/tests/client/chat-store-session-command.test.ts b/tests/client/chat-store-session-command.test.ts new file mode 100644 index 0000000..bbb529d --- /dev/null +++ b/tests/client/chat-store-session-command.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const chatApi = vi.hoisted(() => ({ + registerSessionHandlers: vi.fn(), + unregisterSessionHandlers: vi.fn(), + getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })), + sessionCommandHandlers: [] as Array<(event: any) => void>, + peerUserMessageHandlers: [] as Array<(event: any) => void>, +})) + +vi.mock('@/api/hermes/chat', () => ({ + startRunViaSocket: vi.fn(), + resumeSession: vi.fn(), + registerSessionHandlers: chatApi.registerSessionHandlers, + unregisterSessionHandlers: chatApi.unregisterSessionHandlers, + getChatRunSocket: chatApi.getChatRunSocket, + respondToolApproval: vi.fn(), + respondClarify: vi.fn(), + onPeerUserMessage: vi.fn((handler: (event: any) => void) => { + chatApi.peerUserMessageHandlers.push(handler) + return vi.fn() + }), + onSessionCommand: vi.fn((handler: (event: any) => void) => { + chatApi.sessionCommandHandlers.push(handler) + return vi.fn() + }), +})) + +vi.mock('@/api/client', () => ({ + getActiveProfileName: () => 'default', +})) + +vi.mock('@/api/hermes/sessions', () => ({ + deleteSession: vi.fn(), + fetchSession: vi.fn(), + fetchSessions: vi.fn(), + setSessionModel: vi.fn(), +})) + +vi.mock('@/api/hermes/download', () => ({ + getDownloadUrl: (_path: string, name: string) => `/download/${name}`, +})) + +vi.mock('@/utils/completion-sound', () => ({ + primeCompletionSound: vi.fn(), + playCompletionSound: vi.fn(), +})) + +import { useChatStore, type Session } from '@/stores/hermes/chat' + +function makeSession(): Session { + return { + id: 'session-1', + title: 'session', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +describe('chat store session.command fanout', () => { + beforeEach(() => { + vi.resetAllMocks() + chatApi.sessionCommandHandlers = [] + chatApi.peerUserMessageHandlers = [] + setActivePinia(createPinia()) + }) + + it('attaches to a goal resume run started from another window', () => { + const store = useChatStore() + const session = makeSession() + store.sessions = [session] + store.activeSessionId = 'session-1' + store.activeSession = session + + expect(chatApi.sessionCommandHandlers).toHaveLength(1) + + chatApi.sessionCommandHandlers[0]({ + event: 'session.command', + session_id: 'session-1', + command: 'goal', + action: 'resume', + message: 'Goal resumed', + started: true, + terminal: false, + }) + + expect(store.isStreaming).toBe(true) + expect(chatApi.registerSessionHandlers).toHaveBeenCalledWith('session-1', expect.objectContaining({ + onRunStarted: expect.any(Function), + onSessionCommand: expect.any(Function), + })) + expect(store.messages).toEqual([ + expect.objectContaining({ + role: 'command', + content: 'Goal resumed', + commandAction: 'resume', + }), + ]) + }) + + it('does not clear the transcript for goal done commands', () => { + const store = useChatStore() + const session = makeSession() + session.messages = [ + { id: 'user-1', role: 'user', content: 'keep me', timestamp: 1 }, + ] + store.sessions = [session] + store.activeSessionId = 'session-1' + store.activeSession = session + + chatApi.sessionCommandHandlers[0]({ + event: 'session.command', + session_id: 'session-1', + command: 'goal', + action: 'clear', + message: 'Goal cleared.', + terminal: true, + }) + + expect(store.messages).toEqual([ + expect.objectContaining({ id: 'user-1', content: 'keep me' }), + expect.objectContaining({ + role: 'command', + content: 'Goal cleared.', + commandAction: 'clear', + }), + ]) + }) +}) diff --git a/tests/client/chat-store-thinking.test.ts b/tests/client/chat-store-thinking.test.ts new file mode 100644 index 0000000..f0d03de --- /dev/null +++ b/tests/client/chat-store-thinking.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useChatStore } from '@/stores/hermes/chat' + +describe('chat store thinkingObservation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('starts empty', () => { + const store = useChatStore() + expect(store.getThinkingObservation('any-id')).toBeUndefined() + }) + + it('records startedAt when delta first introduces an opening tag', () => { + const store = useChatStore() + store.noteThinkingDelta('msg-1', '', 'hi') + const ob = store.getThinkingObservation('msg-1') + expect(ob).toBeDefined() + expect(typeof ob!.startedAt).toBe('number') + expect(ob!.endedAt).toBeUndefined() + }) + + it('records endedAt when delta first introduces closing tag', () => { + const store = useChatStore() + store.noteThinkingDelta('msg-1', '', 'hi') + store.noteThinkingDelta('msg-1', 'hi', 'hidone') + const ob = store.getThinkingObservation('msg-1') + expect(ob!.startedAt).toBeDefined() + expect(typeof ob!.endedAt).toBe('number') + }) + + it('is idempotent for subsequent openings/closings', () => { + const store = useChatStore() + store.noteThinkingDelta('m', '', 'a') + const first = store.getThinkingObservation('m')! + const firstStarted = first.startedAt + const firstEnded = first.endedAt + store.noteThinkingDelta( + 'm', + 'a', + 'ab', + ) + const second = store.getThinkingObservation('m')! + expect(second.startedAt).toBe(firstStarted) + expect(second.endedAt).toBe(firstEnded) + }) + + it('is ignored when delta is inside a code block', () => { + const store = useChatStore() + store.noteThinkingDelta('m', '', '```\nfake\n```') + expect(store.getThinkingObservation('m')).toBeUndefined() + }) + + it('clears observations on clearThinkingObservationFor', () => { + const store = useChatStore() + store.noteThinkingDelta('m', '', 'hi') + expect(store.getThinkingObservation('m')).toBeDefined() + store.clearThinkingObservationFor('any-session') + expect(store.getThinkingObservation('m')).toBeUndefined() + }) + + it('noteReasoningStart records startedAt only once', () => { + const store = useChatStore() + store.noteReasoningStart('r1') + const t1 = store.getThinkingObservation('r1')!.startedAt + expect(typeof t1).toBe('number') + store.noteReasoningStart('r1') + expect(store.getThinkingObservation('r1')!.startedAt).toBe(t1) + }) + + it('noteReasoningEnd requires prior start', () => { + const store = useChatStore() + store.noteReasoningEnd('r2') + expect(store.getThinkingObservation('r2')).toBeUndefined() + store.noteReasoningStart('r2') + store.noteReasoningEnd('r2') + expect(store.getThinkingObservation('r2')!.endedAt).toBeDefined() + }) + + it('noteReasoningEnd is idempotent', () => { + const store = useChatStore() + store.noteReasoningStart('r3') + store.noteReasoningEnd('r3') + const end1 = store.getThinkingObservation('r3')!.endedAt + store.noteReasoningEnd('r3') + expect(store.getThinkingObservation('r3')!.endedAt).toBe(end1) + }) +}) diff --git a/tests/client/completion-sound.test.ts b/tests/client/completion-sound.test.ts new file mode 100644 index 0000000..c82aeff --- /dev/null +++ b/tests/client/completion-sound.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { __resetCompletionSoundForTests, playCompletionSound, primeCompletionSound } from '@/utils/completion-sound' + +function installMockAudioContext(initialState: AudioContextState = 'running') { + const oscillator = { + type: 'sine' as OscillatorType, + frequency: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } + + const gain = { + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + connect: vi.fn(), + } + + const context = { + state: initialState, + currentTime: 10, + destination: {}, + resume: vi.fn(async () => { + context.state = 'running' + }), + createOscillator: vi.fn(() => oscillator), + createGain: vi.fn(() => gain), + } + + const AudioContextMock = vi.fn(() => context) + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: AudioContextMock, + }) + + return { AudioContextMock, context, oscillator, gain } +} + +describe('completion sound', () => { + beforeEach(() => { + __resetCompletionSoundForTests() + vi.restoreAllMocks() + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: undefined, + }) + }) + + it('returns false when Web Audio is unavailable', async () => { + await expect(playCompletionSound()).resolves.toBe(false) + }) + + it('primes a suspended audio context from user interaction', () => { + const { context } = installMockAudioContext('suspended') + + primeCompletionSound() + + expect(context.resume).toHaveBeenCalledTimes(1) + }) + + it('plays a short tone through Web Audio', async () => { + const { context, oscillator, gain } = installMockAudioContext('running') + + await expect(playCompletionSound()).resolves.toBe(true) + + expect(context.createOscillator).toHaveBeenCalledTimes(1) + expect(context.createGain).toHaveBeenCalledTimes(1) + expect(oscillator.connect).toHaveBeenCalledWith(gain) + expect(gain.connect).toHaveBeenCalledWith(context.destination) + expect(oscillator.start).toHaveBeenCalledWith(10) + expect(oscillator.stop).toHaveBeenCalledWith(10.16) + }) +}) diff --git a/tests/client/conversation-monitor-pane.test.ts b/tests/client/conversation-monitor-pane.test.ts new file mode 100644 index 0000000..813b9c8 --- /dev/null +++ b/tests/client/conversation-monitor-pane.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +const mockConversationsApi = vi.hoisted(() => ({ + fetchConversationSummaries: vi.fn(), + fetchConversationDetail: vi.fn(), +})) + +vi.mock('@/api/hermes/conversations', () => mockConversationsApi) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'chat.linkedSessions' && params?.count != null) return `${params.count} linked` + return key + }, + }), +})) + +import ConversationMonitorPane from '@/components/hermes/chat/ConversationMonitorPane.vue' + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(res => { + resolve = res + }) + return { promise, resolve } +} + +describe('ConversationMonitorPane', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + vi.useFakeTimers() + mockConversationsApi.fetchConversationSummaries.mockResolvedValue([ + { + id: 'conv-1', + title: 'First conversation', + source: 'cli', + model: 'openai/gpt-5.4', + started_at: 10, + ended_at: 20, + last_active: 20, + message_count: 2, + tool_call_count: 0, + input_tokens: 3, + output_tokens: 5, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'openai', + estimated_cost_usd: 0, + actual_cost_usd: 0, + cost_status: 'estimated', + preview: 'preview', + is_active: true, + thread_session_count: 1, + }, + { + id: 'conv-2', + title: 'Second conversation', + source: 'discord', + model: 'openai/gpt-5.4', + started_at: 30, + ended_at: 40, + last_active: 40, + message_count: 2, + tool_call_count: 0, + input_tokens: 3, + output_tokens: 5, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'openai', + estimated_cost_usd: 0, + actual_cost_usd: 0, + cost_status: 'estimated', + preview: 'preview-2', + is_active: false, + thread_session_count: 2, + }, + ]) + mockConversationsApi.fetchConversationDetail.mockResolvedValue({ + session_id: 'conv-1', + visible_count: 2, + thread_session_count: 1, + messages: [ + { id: 1, session_id: 'conv-1', role: 'user', content: 'hello', timestamp: 11 }, + { id: 2, session_id: 'conv-1', role: 'assistant', content: 'world', timestamp: 12 }, + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('loads conversations and the first transcript using the humanOnly preference', async () => { + const wrapper = mount(ConversationMonitorPane, { + props: { humanOnly: true }, + }) + + await flushPromises() + + expect(mockConversationsApi.fetchConversationSummaries).toHaveBeenCalledWith({ humanOnly: true }) + expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true }) + expect(wrapper.text()).toContain('First conversation') + expect(wrapper.text()).toContain('hello') + expect(wrapper.text()).toContain('world') + }) + + it('ignores stale detail responses when selection changes quickly', async () => { + const first = deferred() + const second = deferred() + mockConversationsApi.fetchConversationDetail + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const wrapper = mount(ConversationMonitorPane, { + props: { humanOnly: true }, + }) + + await flushPromises() + expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true }) + + const sessionButtons = wrapper.findAll('.conversation-monitor__session') + expect(sessionButtons).toHaveLength(2) + await sessionButtons[1].trigger('click') + + expect(mockConversationsApi.fetchConversationDetail).toHaveBeenLastCalledWith('conv-2', { humanOnly: true }) + + second.resolve({ + session_id: 'conv-2', + visible_count: 1, + thread_session_count: 2, + messages: [ + { id: 21, session_id: 'conv-2', role: 'assistant', content: 'newer detail wins', timestamp: 41 }, + ], + }) + await flushPromises() + + first.resolve({ + session_id: 'conv-1', + visible_count: 1, + thread_session_count: 1, + messages: [ + { id: 11, session_id: 'conv-1', role: 'assistant', content: 'stale detail loses', timestamp: 12 }, + ], + }) + await flushPromises() + + const renderedMessages = wrapper.findAll('.conversation-monitor__message-content').map(node => node.text()) + expect(renderedMessages).toEqual(['newer detail wins']) + }) + + it('clears the polling interval on unmount', async () => { + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval') + + const wrapper = mount(ConversationMonitorPane, { + props: { humanOnly: true }, + }) + + await flushPromises() + wrapper.unmount() + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/client/conversations-api.test.ts b/tests/client/conversations-api.test.ts new file mode 100644 index 0000000..4030db7 --- /dev/null +++ b/tests/client/conversations-api.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockRequest = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/client', () => ({ + request: mockRequest, +})) + +import { fetchConversationDetail, fetchConversationSummaries } from '@/api/hermes/conversations' + +describe('conversations api', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds summaries URLs with optional params', async () => { + mockRequest.mockResolvedValue({ sessions: [] }) + + await fetchConversationSummaries() + await fetchConversationSummaries({ humanOnly: false, source: 'cli', limit: 25 }) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/hermes/sessions/conversations') + expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/hermes/sessions/conversations?humanOnly=false&source=cli&limit=25') + }) + + it('encodes detail URLs and forwards optional params', async () => { + mockRequest.mockResolvedValue({ session_id: 'conv', messages: [], visible_count: 0, thread_session_count: 1 }) + + await fetchConversationDetail('folder/with spaces', { humanOnly: false, source: 'discord' }) + + expect(mockRequest).toHaveBeenCalledWith('/api/hermes/sessions/conversations/folder%2Fwith%20spaces/messages?humanOnly=false&source=discord') + }) + + it('propagates conversation detail errors so the monitor can render an error state', async () => { + mockRequest.mockRejectedValue(new Error('boom')) + + await expect(fetchConversationDetail('conv-1', { humanOnly: true })).rejects.toThrow('boom') + }) +}) diff --git a/tests/client/copilot-login-modal.test.ts b/tests/client/copilot-login-modal.test.ts new file mode 100644 index 0000000..c7ecf35 --- /dev/null +++ b/tests/client/copilot-login-modal.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +const mockApi = vi.hoisted(() => ({ + startCopilotLogin: vi.fn(), + pollCopilotLogin: vi.fn(), +})) + +const mockMessage = vi.hoisted(() => ({ + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +})) + +vi.mock('@/api/hermes/copilot-auth', () => mockApi) +vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn(async () => true) })) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (k: string) => k }), +})) +vi.mock('naive-ui', () => ({ + NModal: { template: '
' }, + NButton: { template: '' }, + NSpin: { template: '' }, + useMessage: () => mockMessage, +})) + +import CopilotLoginModal from '@/components/hermes/models/CopilotLoginModal.vue' + +function mountModal() { + return mount(CopilotLoginModal) +} + +describe('CopilotLoginModal device-flow state machine', () => { + beforeEach(() => { + vi.useFakeTimers() + mockApi.startCopilotLogin.mockReset() + mockApi.pollCopilotLogin.mockReset() + mockMessage.success.mockReset() + mockMessage.warning.mockReset() + mockMessage.error.mockReset() + }) + + it('启动后进入 waiting 并显示 user_code', async () => { + mockApi.startCopilotLogin.mockResolvedValue({ + session_id: 'sess-1', + user_code: 'ABCD-1234', + verification_url: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }) + mockApi.pollCopilotLogin.mockResolvedValue({ status: 'pending', error: null }) + + const wrapper = mountModal() + await flushPromises() + + expect(wrapper.text()).toContain('ABCD-1234') + expect(mockApi.startCopilotLogin).toHaveBeenCalledTimes(1) + }) + + it('approved 时 emit success 且消息为 copilotApproved', async () => { + mockApi.startCopilotLogin.mockResolvedValue({ + session_id: 'sess-2', + user_code: 'WXYZ-9999', + verification_url: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }) + mockApi.pollCopilotLogin.mockResolvedValue({ status: 'approved', error: null }) + + const wrapper = mountModal() + await flushPromises() + + // 推动一次 poll timer + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(mockMessage.success).toHaveBeenCalledWith('models.copilotApproved') + + // approved 后 1s 自动关闭 + await vi.advanceTimersByTimeAsync(1500) + await flushPromises() + expect(wrapper.emitted('success')).toBeTruthy() + }) + + it('expired 时进入 expired 状态并显示重试按钮', async () => { + mockApi.startCopilotLogin.mockResolvedValue({ + session_id: 'sess-3', + user_code: 'EXPI-RED!', + verification_url: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }) + mockApi.pollCopilotLogin.mockResolvedValue({ status: 'expired', error: null }) + + const wrapper = mountModal() + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(wrapper.text()).toContain('models.copilotExpired') + expect(wrapper.emitted('success')).toBeFalsy() + }) + + it('startCopilotLogin 抛错时显示 error 且不 emit success', async () => { + mockApi.startCopilotLogin.mockRejectedValue(new Error('boom')) + + const wrapper = mountModal() + await flushPromises() + + expect(mockMessage.error).toHaveBeenCalled() + expect(wrapper.emitted('success')).toBeFalsy() + }) + + it('denied 时进入 error 状态', async () => { + mockApi.startCopilotLogin.mockResolvedValue({ + session_id: 'sess-4', + user_code: 'NOPE', + verification_url: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }) + mockApi.pollCopilotLogin.mockResolvedValue({ status: 'denied', error: null }) + + const wrapper = mountModal() + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(wrapper.text()).toContain('models.copilotDenied') + expect(wrapper.emitted('success')).toBeFalsy() + }) +}) diff --git a/tests/client/default-credential-prompt.test.ts b/tests/client/default-credential-prompt.test.ts new file mode 100644 index 0000000..8108394 --- /dev/null +++ b/tests/client/default-credential-prompt.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' + +const mockPush = vi.hoisted(() => vi.fn()) +const mockFetchCurrentUser = vi.hoisted(() => vi.fn()) +const mockGetApiKey = vi.hoisted(() => vi.fn()) +const routeState = vi.hoisted(() => ({ fullPath: '/hermes/chat', name: 'hermes.chat' as any })) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/api/auth', () => ({ + fetchCurrentUser: mockFetchCurrentUser, +})) + +vi.mock('@/api/client', () => ({ + getApiKey: mockGetApiKey, +})) + +vi.mock('naive-ui', async () => { + const { defineComponent, h } = await import('vue') + return { + NModal: defineComponent({ + props: { show: Boolean, title: String }, + setup(props, { slots }) { + return () => props.show + ? h('div', { class: 'modal' }, [ + h('h2', props.title), + slots.default?.(), + h('div', { class: 'modal-actions' }, slots.action?.()), + ]) + : null + }, + }), + NButton: defineComponent({ + emits: ['click'], + setup(_props, { emit, slots }) { + return () => h('button', { onClick: () => emit('click') }, slots.default?.()) + }, + }), + } +}) + +import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue' + +describe('DefaultCredentialPrompt', () => { + beforeEach(() => { + sessionStorage.clear() + vi.clearAllMocks() + routeState.fullPath = '/hermes/chat' + routeState.name = 'hermes.chat' + mockGetApiKey.mockReturnValue('jwt-token') + }) + + it('prompts after login when the current user still has default credentials', async () => { + mockFetchCurrentUser.mockResolvedValue({ + id: 1, + username: 'admin', + role: 'super_admin', + status: 'active', + created_at: 1, + updated_at: 1, + last_login_at: 1, + requiresCredentialChange: true, + }) + + const wrapper = mount(DefaultCredentialPrompt) + await flushPromises() + await nextTick() + + expect(mockFetchCurrentUser).toHaveBeenCalledOnce() + expect(wrapper.text()).toContain('login.defaultCredentialMessage') + await wrapper.findAll('button')[1].trigger('click') + expect(mockPush).toHaveBeenCalledWith({ name: 'hermes.settings', query: { tab: 'account' } }) + }) + + it('does not prompt on the login route', async () => { + routeState.fullPath = '/' + routeState.name = 'login' + + mount(DefaultCredentialPrompt) + await Promise.resolve() + + expect(mockFetchCurrentUser).not.toHaveBeenCalled() + }) +}) diff --git a/tests/client/file-path.test.ts b/tests/client/file-path.test.ts new file mode 100644 index 0000000..b9545ff --- /dev/null +++ b/tests/client/file-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { getClipboardPathForEntry } from '@/utils/file-path' + +const baseEntry = { + name: 'app.log', + path: 'logs/app.log', + isDir: false, + size: 12, + modTime: '2026-05-20T00:00:00.000Z', +} + +describe('file path clipboard helpers', () => { + it('prefers absolute path metadata when available', () => { + expect(getClipboardPathForEntry({ + ...baseEntry, + absolutePath: '/home/agent/.hermes/logs/app.log', + })).toBe('/home/agent/.hermes/logs/app.log') + }) + + it('falls back to the relative operation path for older API responses', () => { + expect(getClipboardPathForEntry(baseEntry)).toBe('logs/app.log') + }) +}) diff --git a/tests/client/group-chat-mention-options.test.ts b/tests/client/group-chat-mention-options.test.ts new file mode 100644 index 0000000..8126aa5 --- /dev/null +++ b/tests/client/group-chat-mention-options.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildMentionOptions } from '@/components/hermes/group-chat/mention-options' + +describe('group chat mention options', () => { + const agents = [ + { name: 'Alice', profile: 'alice-profile' }, + { name: 'Bob', profile: 'bob-profile' }, + { name: 'all', profile: 'literal-all-agent' }, + ] + + it('offers @all before agent mentions when the mention query is empty', () => { + expect(buildMentionOptions(agents, '').map(option => option.key)).toEqual([ + 'special:all', + 'agent:Alice', + 'agent:Bob', + ]) + }) + + it('keeps @all reserved when filtering by all and hides a literal all agent', () => { + expect(buildMentionOptions(agents, 'all')).toEqual([ + { + key: 'special:all', + type: 'all', + name: 'all', + label: '@all', + description: 'All agents', + }, + ]) + }) + + it('filters normal agent mentions without showing @all for unrelated queries', () => { + expect(buildMentionOptions(agents, 'bo').map(option => option.key)).toEqual(['agent:Bob']) + }) +}) diff --git a/tests/client/group-chat-store-streaming.test.ts b/tests/client/group-chat-store-streaming.test.ts new file mode 100644 index 0000000..2c3d213 --- /dev/null +++ b/tests/client/group-chat-store-streaming.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import type { ChatMessage, RoomInfo } from '@/api/hermes/group-chat' + +const groupChatApiMock = vi.hoisted(() => { + const handlers = new Map() + const socket: any = { + connected: true, + id: 'socket-1', + on: vi.fn((event: string, cb: Function) => { + const existing = handlers.get(event) || [] + existing.push(cb) + handlers.set(event, existing) + return socket + }), + emit: vi.fn((event: string, _data?: unknown, ack?: Function) => { + if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] }) + return socket + }), + disconnect: vi.fn(), + } + return { + handlers, + socket, + connectGroupChat: vi.fn(() => socket), + disconnectGroupChat: vi.fn(), + getSocket: vi.fn(() => socket), + getStoredUserId: vi.fn(() => 'user-1'), + getStoredUserName: vi.fn(() => 'tester'), + createRoom: vi.fn(), + listRooms: vi.fn(), + getRoomDetail: vi.fn(), + joinRoomByCode: vi.fn(), + addAgent: vi.fn(), + listAgents: vi.fn(), + removeAgent: vi.fn(), + cloneRoom: vi.fn(), + deleteRoom: vi.fn(), + clearRoomContext: vi.fn(), + } +}) + +vi.mock('@/api/hermes/group-chat', () => groupChatApiMock) +vi.mock('@/api/client', () => ({ getApiKey: vi.fn(() => 'test-token') })) +vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) })) + +function emitSocket(event: string, payload: unknown) { + for (const cb of groupChatApiMock.handlers.get(event) || []) cb(payload) +} + +const room: RoomInfo = { + id: 'room-1', + name: 'Test Room', + inviteCode: 'ROOM1', +} + +function assistantMessage(overrides: Partial): ChatMessage { + return { + id: 'msg-1', + roomId: 'room-1', + senderId: 'agent-1', + senderName: 'bot', + content: '', + timestamp: 1, + role: 'assistant', + ...overrides, + } +} + +async function createJoinedStore(initialMessages: ChatMessage[] = []) { + groupChatApiMock.getRoomDetail.mockResolvedValue({ + room, + messages: initialMessages, + agents: [], + members: [], + }) + const { useGroupChatStore } = await import('@/stores/hermes/group-chat') + const store = useGroupChatStore() + store.connect() + await store.joinRoom('room-1') + groupChatApiMock.getRoomDetail.mockClear() + return store +} + +describe('group chat store streaming merge', () => { + beforeEach(() => { + vi.useRealTimers() + setActivePinia(createPinia()) + groupChatApiMock.handlers.clear() + for (const key of Object.keys(groupChatApiMock)) { + const value = (groupChatApiMock as any)[key] + if (value?.mockReset && key !== 'socket') value.mockReset() + } + groupChatApiMock.connectGroupChat.mockReturnValue(groupChatApiMock.socket) + groupChatApiMock.getSocket.mockReturnValue(groupChatApiMock.socket) + groupChatApiMock.getStoredUserId.mockReturnValue('user-1') + groupChatApiMock.getStoredUserName.mockReturnValue('tester') + groupChatApiMock.socket.on.mockClear() + groupChatApiMock.socket.emit.mockClear() + groupChatApiMock.socket.disconnect.mockClear() + }) + + it('preserves streamed reasoning when the final message supplies content only', async () => { + const store = await createJoinedStore() + + emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' })) + emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' }) + emitSocket('message', assistantMessage({ id: 'msg-1', content: '收到', reasoning: null, reasoning_content: null })) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: '收到', + reasoning: 'thinking...', + reasoning_content: 'thinking...', + isStreaming: false, + }) + }) + + it('preserves streamed content when the final message payload is blank', async () => { + const store = await createJoinedStore() + + emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' })) + emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: 'final' }) + emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' answer' }) + emitSocket('message', assistantMessage({ id: 'msg-1', content: '', reasoning: 'thinking...' })) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: 'final answer', + reasoning: 'thinking...', + isStreaming: false, + }) + }) + + it('ignores late content deltas for a completed message', async () => { + const store = await createJoinedStore() + + emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' })) + emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' }) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: 'final answer', + reasoning: 'thinking...', + isStreaming: false, + }) + }) + + it('ignores late reasoning deltas for a completed message', async () => { + const store = await createJoinedStore() + + emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' })) + emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' }) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: 'final answer', + reasoning: 'thinking...', + isStreaming: false, + }) + }) + + it('ignores a late empty stream start for a completed message', async () => { + const store = await createJoinedStore() + + emitSocket('message', assistantMessage({ id: 'msg-1', content: 'final answer', reasoning: 'thinking...' })) + emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 })) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: 'final answer', + reasoning: 'thinking...', + isStreaming: false, + }) + }) + + it('ignores a late stream start for a completed empty tool-call message', async () => { + const store = await createJoinedStore() + const toolCalls = [{ id: 'tool-1', type: 'function', function: { name: 'lookup', arguments: '{}' } }] + + emitSocket('message', assistantMessage({ id: 'msg-1', content: '', tool_calls: toolCalls })) + emitSocket('message_stream_start', assistantMessage({ id: 'msg-1', content: '', timestamp: 2 })) + emitSocket('message_stream_delta', { roomId: 'room-1', id: 'msg-1', delta: ' stale' }) + + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: '', + tool_calls: toolCalls, + isStreaming: false, + }) + }) + + it('refetches room detail when a stream ends with reasoning but no final content', async () => { + vi.useFakeTimers() + const store = await createJoinedStore() + groupChatApiMock.getRoomDetail.mockResolvedValue({ + room, + agents: [], + members: [], + messages: [assistantMessage({ id: 'msg-1', content: 'final from db', reasoning: 'thinking...' })], + }) + + emitSocket('message_stream_start', assistantMessage({ id: 'msg-1' })) + emitSocket('message_reasoning_delta', { roomId: 'room-1', id: 'msg-1', delta: 'thinking...' }) + emitSocket('message_stream_end', { roomId: 'room-1', id: 'msg-1' }) + + await vi.runAllTimersAsync() + + expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1') + expect(store.messages[0]).toMatchObject({ + id: 'msg-1', + content: 'final from db', + reasoning: 'thinking...', + isStreaming: false, + }) + }) +}) diff --git a/tests/client/highlight-helper.test.ts b/tests/client/highlight-helper.test.ts new file mode 100644 index 0000000..13bc2aa --- /dev/null +++ b/tests/client/highlight-helper.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const highlightJsMock = vi.hoisted(() => ({ + getLanguage: vi.fn((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')), + highlight: vi.fn((content: string, { language }: { language: string }) => ({ + value: `${content}`, + })), + registerLanguage: vi.fn(), +})) + +vi.mock('highlight.js', () => ({ + default: highlightJsMock, +})) + +import { normalizeHighlightLanguage, renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight' + +describe('highlight helper', () => { + beforeEach(() => { + vi.clearAllMocks() + highlightJsMock.getLanguage.mockImplementation((lang?: string) => ['shell', 'xml', 'yaml', 'bash', 'json'].includes(lang || '')) + highlightJsMock.highlight.mockImplementation((content: string, { language }: { language: string }) => ({ + value: `${content}`, + })) + }) + + it.each([ + ['vue', 'xml'], + ['yml', 'yaml'], + ['sh', 'bash'], + ['zsh', 'bash'], + ['shellscript', 'bash'], + ['shell', 'shell'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeHighlightLanguage(input)).toBe(expected) + }) + + it('uses a delegated copy attribute instead of inline javascript', () => { + const html = renderHighlightedCodeBlock('x', 'json', 'Copy') + + expect(html).toContain('data-copy-code="true"') + expect(html).not.toContain('onclick=') + }) + + it('preserves shell-session highlighting instead of remapping shell fences to bash', () => { + const html = renderHighlightedCodeBlock('$ ls\nfoo.txt\n', 'shell', 'Copy') + + expect(highlightJsMock.highlight).toHaveBeenCalledWith('$ ls\nfoo.txt\n', { + language: 'shell', + ignoreIllegals: true, + }) + expect(html).toContain('class="code-lang">shell') + }) + + it('skips highlighting for large known-language blocks when a render limit is set', () => { + const html = renderHighlightedCodeBlock('x'.repeat(5000), 'vue', 'Copy', { + maxHighlightLength: 2000, + }) + + expect(highlightJsMock.highlight).not.toHaveBeenCalled() + expect(html).toContain('class="code-lang">vue') + }) + + it('falls back to escaped plaintext for unsupported fence labels', () => { + const html = renderHighlightedCodeBlock('', 'unknown', 'Copy') + + expect(highlightJsMock.highlight).not.toHaveBeenCalled() + expect(html).toContain('<tag>') + expect(html).toContain('class="code-lang">unknown') + }) + + it('falls back to escaped plaintext when direct highlighting throws', () => { + highlightJsMock.highlight.mockImplementationOnce(() => { + throw new Error('boom') + }) + + const html = renderHighlightedCodeBlock('', 'vue', 'Copy') + + expect(html).toContain('<tag>') + expect(html).toContain('class="code-lang">vue') + }) +}) diff --git a/tests/client/highlight-safety.test.ts b/tests/client/highlight-safety.test.ts new file mode 100644 index 0000000..b8669ab --- /dev/null +++ b/tests/client/highlight-safety.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { renderHighlightedCodeBlock } from '@/components/hermes/chat/highlight' + +describe('highlight safety', () => { + it('escapes large unknown code content', () => { + const html = renderHighlightedCodeBlock(''.repeat(100), 'unknown', 'Copy') + + expect(html).toContain('<img') + expect(html).not.toContain(' { + const html = renderHighlightedCodeBlock('', 'xml', 'Copy') + + expect(html).not.toContain('', 'Copy') + + expect(html).toContain('<script>alert(1)</script>') + expect(html).not.toContain('