[
  {
    "path": ".dockerignore",
    "content": ".git\n.gitignore\nbuild/\n.picoclaw/\nconfig/\n.env\n.env.example\n*.md\nLICENSE\nassets/\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Report a bug or unexpected behavior\ntitle: \"[BUG]\"\nlabels: bug\nassignees: ''\n\n---\n\n## Quick Summary\n\n##  Environment & Tools\n- **PicoClaw Version:** (e.g., v0.1.2 or commit hash)\n- **Go Version:** (e.g., go 1.22)\n- **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow)\n- **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux)\n- **Channels:** (e.g., Discord, Telegram, Feishu, ...)\n\n## 📸 Steps to Reproduce\n1. \n2. \n3. \n\n## ❌ Actual Behavior\n\n## ✅ Expected Behavior\n\n## 💬 Additional Context\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest a new idea or improvement\ntitle: \"[Feature]\"\nlabels: enhancement\nassignees: ''\n\n---\n\n## 🎯 The Goal / Use Case\n\n## 💡 Proposed Solution\n\n## 🛠 Potential Implementation (Optional)\n\n## 🚦 Impact & Roadmap Alignment\n- [ ] This is a Core Feature\n- [ ] This is a Nice-to-Have / Enhancement\n- [ ] This aligns with the current Roadmap\n\n## 🔄 Alternatives Considered\n\n## 💬 Additional Context\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/general-task---todo.md",
    "content": "---\nname: General Task / Todo\nabout: A specific piece of work like doc, refactoring, or maintenance.\ntitle: \"[Task]\"\nlabels: ''\nassignees: ''\n\n---\n\n## 📝 Objective\n\n## 📋 To-Do List\n- [ ] Step 1\n- [ ] Step 2\n- [ ] Step 3\n\n## 🎯 Definition of Done (Acceptance Criteria)\n- [ ] Documentation is updated in the README/docs folder.\n- [ ] Code follows project linting standards.\n- [ ] (If applicable) Basic tests pass.\n\n## 💡 Context / Motivation\n\n## 🔗 Related Issues / PRs\n- Fixes #\n- Relates to #\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\r\n\r\nupdates:\r\n\r\n  # Go dependencies (entire repo)\r\n  - package-ecosystem: \"gomod\"\r\n    directory: \"/\"\r\n    schedule:\r\n      interval: \"weekly\"\r\n    labels:\r\n      - \"dependencies\"\r\n      - \"go\"\r\n\r\n  # Frontend dependencies\r\n  - package-ecosystem: \"npm\"\r\n    directory: \"/web/frontend\"\r\n    schedule:\r\n      interval: \"weekly\"\r\n    labels:\r\n      - \"dependencies\"\r\n      - \"frontend\"\r\n\r\n  # GitHub Actions\r\n  - package-ecosystem: \"github-actions\"\r\n    directory: \"/\"\r\n    schedule:\r\n      interval: \"weekly\""
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## 📝 Description\n\n<!-- Please briefly describe the changes and purpose of this PR -->\n\n## 🗣️ Type of Change\n- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)\n- [ ] ✨ New feature (non-breaking change which adds functionality)\n- [ ] 📖 Documentation update\n- [ ] ⚡ Code refactoring (no functional changes, no api changes)\n\n## 🤖 AI Code Generation\n- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)\n- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)\n- [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)\n\n\n## 🔗 Related Issue\n\n<!-- Please link the related issue(s) (e.g., Fixes #123, Closes #456) -->\n\n## 📚 Technical Context (Skip for Docs)\n- **Reference URL:**\n- **Reasoning:**\n\n## 🧪 Test Environment\n- **Hardware:** <!-- e.g. Raspberry Pi 5, Orange Pi, PC-->\n- **OS:** <!-- e.g. Debian 12, Ubuntu 22.04 -->\n- **Model/Provider:** <!-- e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3 -->\n- **Channels:** <!-- e.g. Discord, Telegram, Feishu, ... -->\n\n\n## 📸 Evidence (Optional)\n<details>\n<summary>Click to view Logs/Screenshots</summary>\n\n<!-- Please paste relevant screenshots or logs here -->\n\n</details>\n\n## ☑️ Checklist\n- [ ] My code/docs follow the style of this project.\n- [ ] I have performed a self-review of my own changes.\n- [ ] I have updated the documentation accordingly."
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\n\non:\n  push:\n    branches: [ \"main\" ]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Build\n        run: make build-all\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: 🐳 Build & Push Docker Image\n\non:\n  workflow_call:\n    inputs:\n      tag:\n        description: \"Release tag\"\n        required: true\n        type: string\n\nenv:\n  GHCR_REGISTRY: ghcr.io\n  GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw\n  DOCKERHUB_REGISTRY: docker.io\n  DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}\n\njobs:\n  build:\n    name: 🏗️ Build Docker Image\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      # ── Checkout ──────────────────────────────\n      - name: 📥 Checkout repository\n        uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.tag }}\n\n      # ── Docker Buildx ─────────────────────────\n      - name: 🔧 Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      # ── Login to GHCR ─────────────────────────\n      - name: 🔑 Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ${{ env.GHCR_REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      # ── Login to Docker Hub ────────────────────\n      - name: 🔑 Login to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          registry: ${{ env.DOCKERHUB_REGISTRY }}\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      # ── Metadata (tags & labels) ──────────────\n      - name: 🏷️ Prepare image tags\n        id: tags\n        shell: bash\n        run: |\n          tag=\"${{ inputs.tag }}\"\n          echo \"ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}\" >> \"$GITHUB_OUTPUT\"\n          echo \"ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest\" >> \"$GITHUB_OUTPUT\"\n          echo \"dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}\" >> \"$GITHUB_OUTPUT\"\n          echo \"dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest\" >> \"$GITHUB_OUTPUT\"\n\n      # ── Build & Push ──────────────────────────\n      - name: 🚀 Build and push Docker image\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          push: true\n          tags: |\n            ${{ steps.tags.outputs.ghcr_tag }}\n            ${{ steps.tags.outputs.ghcr_latest }}\n            ${{ steps.tags.outputs.dockerhub_tag }}\n            ${{ steps.tags.outputs.dockerhub_latest }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          platforms: linux/amd64,linux/arm64,linux/riscv64\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: Nightly Build\n\non:\n  schedule:\n    - cron: '0 0 * * *'\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  nightly:\n    name: Nightly Build\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      packages: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Compute version\n        id: version\n        run: |\n          DATE=$(date -u +%Y%m%d)\n          SHA=$(git rev-parse --short=8 HEAD)\n          BASE_VERSION=$(git describe --tags --match \"v*\" --exclude \"*nightly*\" --abbrev=0 2>/dev/null || true)\n          if [ -z \"$BASE_VERSION\" ] || [ \"$BASE_VERSION\" = \"v0.0.0\" ]; then\n            VERSION=\"v0.0.0-nightly.${DATE}.${SHA}\"\n          else\n            VERSION=\"${BASE_VERSION}-nightly.${DATE}.${SHA}\"\n          fi\n\n          COMPARE_URL=\"https://github.com/${{ github.repository }}/commits/main\"\n          if [ -n \"$BASE_VERSION\" ] && [ \"$BASE_VERSION\" != \"v0.0.0\" ]; then\n            COMPARE_URL=\"https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main\"\n          fi\n\n          echo \"version=${VERSION}\" >> \"$GITHUB_OUTPUT\"\n          echo \"changelog=**Full Changelog**: $COMPARE_URL\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Setup Go from go.mod\n        id: setup-go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        run: corepack enable && corepack prepare pnpm@latest --activate\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          registry: docker.io\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Create local tag for GoReleaser\n        run: git tag \"${{ steps.version.outputs.version }}\"\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v7\n        with:\n          distribution: goreleaser\n          version: ~> v2\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}\n          DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}\n          GOVERSION: ${{ steps.setup-go.outputs.go-version }}\n          GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}\n          NIGHTLY_BUILD: \"true\"\n          MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}\n          MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}\n          MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}\n          MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}\n          MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}\n\n      - name: Update nightly release\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          VERSION: ${{ steps.version.outputs.version }}\n        run: |\n          CHANGELOG='${{ steps.version.outputs.changelog }}'\n          NOTES=$(cat <<EOF\n          Nightly build for **${VERSION}**\n\n          This is an automated build and may be unstable. Use with caution.\n\n          ${CHANGELOG}\n          EOF\n          )\n\n          # Delete existing nightly release and tag\n          gh release delete nightly --cleanup-tag -y 2>/dev/null || true\n\n          # Force-update nightly tag to current HEAD\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git tag -fa nightly -m \"Nightly build ${VERSION}\"\n          git push origin nightly\n\n          # Collect release artifacts from goreleaser dist/\n          ASSETS=()\n          for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do\n            [ -f \"$f\" ] && ASSETS+=(\"$f\")\n          done\n\n          # Create nightly release (prerelease, NOT latest)\n          gh release create nightly \\\n            --title \"Nightly Build\" \\\n            --notes \"$NOTES\" \\\n            --target \"${{ github.sha }}\" \\\n            --prerelease \\\n            --latest=false \\\n            \"${ASSETS[@]}\"\n\n"
  },
  {
    "path": ".github/workflows/pr.yml",
    "content": "name: PR\n\non:\n  pull_request: { }\n\njobs:\n  lint:\n    name: Linter\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Run go generate\n        run: go generate ./...\n\n      - name: Golangci Lint\n        uses: golangci/golangci-lint-action@v9\n        with:\n          version: v2.10.1\n\n  vuln_check:\n    name: Security Check\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Run Govulncheck\n        uses: golang/govulncheck-action@v1\n        with:\n          go-package: ./...\n\n  test:\n    name: Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Run go generate\n        run: go generate ./...\n\n      - name: Run go test\n        run: go test ./...\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Create Tag and Release\n\non:\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"Release tag (required, e.g. v0.2.0)\"\n        required: true\n        type: string\n      prerelease:\n        description: \"Mark as pre-release\"\n        required: false\n        type: boolean\n        default: false\n      draft:\n        description: \"Create as draft\"\n        required: false\n        type: boolean\n        default: false\n      upload_tos:\n        description: \"Upload to Volcengine TOS\"\n        required: false\n        type: boolean\n        default: true\n\njobs:\n  create-tag:\n    name: Create Git Tag\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Create and push tag\n        shell: bash\n        env:\n          RELEASE_TAG: ${{ inputs.tag }}\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git tag -a \"$RELEASE_TAG\" -m \"Release $RELEASE_TAG\"\n          git push origin \"$RELEASE_TAG\"\n\n  release:\n    name: GoReleaser Release\n    needs: create-tag\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      packages: write\n    steps:\n      - name: Checkout tag\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          ref: ${{ inputs.tag }}\n\n      - name: Setup Go from go.mod\n        id: setup-go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - name: Setup pnpm\n        run: corepack enable && corepack prepare pnpm@latest --activate\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          registry: docker.io\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v7\n        with:\n          distribution: goreleaser\n          version: ~> v2\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}\n          DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}\n          GOVERSION: ${{ steps.setup-go.outputs.go-version }}\n          MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}\n          MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}\n          MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}\n          MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}\n          MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}\n\n      - name: Apply release flags\n        shell: bash\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          gh release edit \"${{ inputs.tag }}\" \\\n            --draft=${{ inputs.draft }} \\\n            --prerelease=${{ inputs.prerelease }}\n\n  upload-tos:\n    name: Upload to TOS\n    needs: release\n    if: ${{ inputs.upload_tos }}\n    uses: ./.github/workflows/upload-tos.yml\n    with:\n      tag: ${{ inputs.tag }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/upload-tos.yml",
    "content": "name: Upload to Volcengine TOS\n\non:\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"Release tag to download and upload (e.g. v0.2.0)\"\n        required: true\n        type: string\n  workflow_call:\n    inputs:\n      tag:\n        description: \"Release tag to download and upload\"\n        required: true\n        type: string\n\njobs:\n  upload-tos:\n    name: Upload to Volcengine TOS\n    runs-on: ubuntu-latest\n    steps:\n      - name: Download release assets\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          mkdir -p artifacts\n          gh release download \"${{ inputs.tag }}\" \\\n            --repo \"${{ github.repository }}\" \\\n            --dir artifacts \\\n            --pattern \"*.tar.gz\" \\\n            --pattern \"*.zip\" \\\n            --pattern \"*.rpm\" \\\n            --pattern \"*.deb\"\n\n      - name: Upload to Volcengine TOS\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}\n          AWS_DEFAULT_REGION: cn-beijing\n        run: |\n          aws configure set default.s3.addressing_style virtual\n          TOS_ENDPOINT=\"https://tos-s3-cn-beijing.volces.com\"\n          # Upload to versioned directory\n          aws s3 sync artifacts/ \"s3://picoclaw-downloads/${{ inputs.tag }}/\" \\\n            --endpoint-url \"$TOS_ENDPOINT\"\n          # Upload to latest (overwrite)\n          aws s3 sync artifacts/ \"s3://picoclaw-downloads/latest/\" \\\n            --endpoint-url \"$TOS_ENDPOINT\" \\\n            --delete\n"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries\n# Go build artifacts\nbin/\nbuild/\n*.exe\n*.dll\n*.so\n*.dylib\n*.test\n*.out\n/picoclaw\n/picoclaw-test\ncmd/**/workspace\n\n# Picoclaw specific\n\n# PicoClaw\n.picoclaw/\nconfig.json\nsessions/\nbuild/\n\n# Coverage\n\n# Secrets & Config (keep templates, ignore actual secrets)\n.env\nconfig/config.json\n\n# Test\ncoverage.txt\ncoverage.html\n\n# OS\n.DS_Store\n\n# Ralph workspace\nralph/\n.ralph/\ntasks/\n\n# Plans\ndocs/plans/\n\n# Editors\n.vscode/\n.idea/\n\n# Added by goreleaser init:\ndist/\n*.vite/\n\n# Windows Application Icon/Resource\n*.syso\n\n# Test telegram integration\ncmd/telegram/\n\n# Keep embedded backend dist directory placeholder in VCS\n!web/backend/dist/\nweb/backend/dist/*\n!web/backend/dist/.gitkeep\n"
  },
  {
    "path": ".golangci.yaml",
    "content": "version: \"2\"\n\nlinters:\n  default: all\n  disable:\n    # TODO: Tweak for current project needs\n    - containedctx\n    - cyclop\n    - depguard\n    - dupword\n    - err113\n    - exhaustruct\n    - funcorder\n    - gochecknoglobals\n    - godot\n    - intrange\n    - ireturn\n    - nlreturn\n    - noctx\n    - noinlineerr\n    - nonamedreturns\n    - tagliatelle\n    - testpackage\n    - varnamelen\n    - wrapcheck\n    - wsl\n    - wsl_v5\n\n    # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)\n    - contextcheck\n    - embeddedstructfieldcheck\n    - errcheck\n    - errchkjson\n    - errorlint\n    - exhaustive\n    - forbidigo\n    - forcetypeassert\n    - funlen\n    - gochecknoinits\n    - gocognit\n    - goconst\n    - gocritic\n    - gocyclo\n    - godox\n    - gosec\n    - ineffassign\n    - lll\n    - maintidx\n    - mnd\n    - modernize\n    - nestif\n    - nilnil\n    - paralleltest\n    - perfsprint\n    - revive\n    - staticcheck\n    - tagalign\n    - testifylint\n    - thelper\n    - unparam\n    - usestdlibvars\n    - usetesting\n  settings:\n    errcheck:\n      check-type-assertions: true\n      check-blank: true\n    exhaustive:\n      default-signifies-exhaustive: true\n    funlen:\n      lines: 120\n      statements: 40\n    gocognit:\n      min-complexity: 25\n    gocyclo:\n      min-complexity: 20\n    govet:\n      enable-all: true\n      disable:\n        - fieldalignment\n    lll:\n      line-length: 120\n      tab-width: 4\n    misspell:\n      locale: US\n    mnd:\n      checks:\n        - argument\n        - assign\n        - case\n        - condition\n        - operation\n        - return\n    nakedret:\n      max-func-lines: 3\n    revive:\n      enable-all-rules: true\n      rules:\n        - name: add-constant\n          disabled: true\n        - name: argument-limit\n          arguments:\n            - 7\n          severity: warning\n        - name: banned-characters\n          disabled: true\n        - name: cognitive-complexity\n          disabled: true\n        - name: comment-spacings\n          arguments:\n            - nolint\n          severity: warning\n        - name: cyclomatic\n          disabled: true\n        - name: file-header\n          disabled: true\n        - name: function-result-limit\n          arguments:\n            - 3\n          severity: warning\n        - name: function-length\n          disabled: true\n        - name: line-length-limit\n          disabled: true\n        - name: max-public-structs\n          disabled: true\n        - name: modifies-value-receiver\n          disabled: true\n        - name: package-comments\n          disabled: true\n        - name: unused-receiver\n          disabled: true\n  exclusions:\n    generated: lax\n    rules:\n      - linters:\n          - lll\n        source: '^//go:generate '\n      - linters:\n          - funlen\n          - maintidx\n          - gocognit\n          - gocyclo\n        path: _test\\.go$\n      - linters:\n          - nolintlint\n        path: 'pkg/tools/(i2c\\.go|spi\\.go)$'\n\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\n\nformatters:\n  enable:\n    - gci\n    - gofmt\n    - gofumpt\n    - goimports\n    - golines\n  settings:\n    gci:\n      sections:\n        - standard\n        - default\n        - localmodule\n      custom-order: true\n    gofmt:\n      simplify: true\n      rewrite-rules:\n        - pattern: \"interface{}\"\n          replacement: \"any\"\n        - pattern: \"a[b:len(a)]\"\n          replacement: \"a[b:]\"\n    golines:\n      max-len: 120\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n# vim: set ts=2 sw=2 tw=0 fo=cnqoj\nversion: 2\n\nbefore:\n  hooks:\n    - go mod tidy\n    - go generate ./...\n    - sh -c 'cd web/frontend && pnpm install && pnpm build:backend'\n    - go install github.com/tc-hib/go-winres@latest\n    - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}\n\nbuilds:\n  - id: picoclaw\n    env:\n      - CGO_ENABLED=0\n    tags:\n      - stdjson\n    ldflags:\n      - -s -w\n      - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}\n      - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}\n      - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}\n      - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}\n    goos:\n      - linux\n      - windows\n      - darwin\n      - freebsd\n      - netbsd\n    goarch:\n      - amd64\n      - arm64\n      - riscv64\n      - loong64\n      - arm\n      - s390x\n      - mipsle\n    goarm:\n      - \"6\"\n      - \"7\"\n    gomips:\n      - softfloat\n    main: ./cmd/picoclaw\n    ignore:\n      - goos: windows\n        goarch: arm\n      - goos: netbsd\n        goarch: s390x\n      - goos: netbsd\n        goarch: mips64\n      - goos: netbsd\n        goarch: arm\n\n  - id: picoclaw-launcher\n    binary: picoclaw-launcher\n    env:\n      - CGO_ENABLED=0\n    tags:\n      - stdjson\n    ldflags:\n      - -s -w\n    goos:\n      - linux\n      - windows\n      - darwin\n      - freebsd\n      - netbsd\n    goarch:\n      - amd64\n      - arm64\n      - riscv64\n      - loong64\n      - arm\n      - s390x\n      - mipsle\n    goarm:\n      - \"6\"\n      - \"7\"\n    gomips:\n      - softfloat\n    main: ./web/backend\n    ignore:\n      - goos: windows\n        goarch: arm\n      - goos: netbsd\n        goarch: s390x\n      - goos: netbsd\n        goarch: mips64\n      - goos: netbsd\n        goarch: arm\n\n  - id: picoclaw-launcher-tui\n    binary: picoclaw-launcher-tui\n    env:\n      - CGO_ENABLED=0\n    tags:\n      - stdjson\n    ldflags:\n      - -s -w\n    goos:\n      - linux\n      - windows\n      - darwin\n      - freebsd\n      - netbsd\n    goarch:\n      - amd64\n      - arm64\n      - riscv64\n      - loong64\n      - arm\n      - s390x\n      - mipsle\n    goarm:\n      - \"6\"\n      - \"7\"\n    gomips:\n      - softfloat\n    main: ./cmd/picoclaw-launcher-tui\n    ignore:\n      - goos: windows\n        goarch: arm\n      - goos: netbsd\n        goarch: s390x\n      - goos: netbsd\n        goarch: mips64\n      - goos: netbsd\n        goarch: arm\n\ndockers_v2:\n  - id: picoclaw\n    dockerfile: docker/Dockerfile.goreleaser\n    extra_files:\n      - docker/entrypoint.sh\n    ids:\n      - picoclaw\n    images:\n      - \"ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw\"\n      - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'\n    tags:\n      - '{{ if isEnvSet \"NIGHTLY_BUILD\" }}nightly{{ else }}{{ .Tag }}{{ end }}'\n      - '{{ if isEnvSet \"NIGHTLY_BUILD\" }}nightly{{ else }}latest{{ end }}'\n    platforms:\n      - linux/amd64\n      - linux/arm64\n      - linux/riscv64\n\n  - id: picoclaw-launcher\n    dockerfile: docker/Dockerfile.goreleaser.launcher\n    ids:\n      - picoclaw\n      - picoclaw-launcher\n      - picoclaw-launcher-tui\n    images:\n      - \"ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw\"\n      - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'\n    tags:\n      - '{{ if isEnvSet \"NIGHTLY_BUILD\" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'\n      - '{{ if isEnvSet \"NIGHTLY_BUILD\" }}nightly-launcher{{ else }}launcher{{ end }}'\n    platforms:\n      - linux/amd64\n      - linux/arm64\n      - linux/riscv64\n\nnotarize:\n  macos:\n    - enabled: '{{ isEnvSet \"MACOS_SIGN_P12\" }}'\n      ids:\n        - picoclaw\n        - picoclaw-launcher\n        - picoclaw-launcher-tui\n      sign:\n        certificate: \"{{.Env.MACOS_SIGN_P12}}\"\n        password: \"{{.Env.MACOS_SIGN_PASSWORD}}\"\n      notarize:\n        issuer_id: \"{{.Env.MACOS_NOTARY_ISSUER_ID}}\"\n        key_id: \"{{.Env.MACOS_NOTARY_KEY_ID}}\"\n        key: \"{{.Env.MACOS_NOTARY_KEY}}\"\n        wait: true\n        timeout: 20m\n\narchives:\n  - formats: [tar.gz]\n    # this name template makes the OS and Arch compatible with the results of `uname`.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- title .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    # use zip for windows archives\n    format_overrides:\n      - goos: windows\n        formats: [zip]\n\nnfpms:\n  - id: picoclaw\n    ids:\n      - picoclaw\n      - picoclaw-launcher\n      - picoclaw-launcher-tui\n    package_name: picoclaw\n    file_name_template: >-\n      {{ .PackageName }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"arm64\" }}aarch64\n      {{- else if eq .Arch \"arm\" }}armv{{ .Arm }}\n      {{- else }}{{ .Arch }}{{ end }}\n    vendor: picoclaw\n    homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw\n    maintainer: picoclaw contributors\n    description: picoclaw - a tool for managing and running tasks\n    license: MIT\n    formats:\n      - rpm\n      - deb\n    bindir: /usr/bin\n    contents:\n      - src: web/picoclaw-launcher.desktop\n        dst: /usr/share/applications/picoclaw-launcher.desktop\n      - src: web/picoclaw-launcher.png\n        dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n\n# upx:\n#    - enabled: true\n#      compress: best\n#      lzma: true\n\nrelease:\n  disable: '{{ isEnvSet \"NIGHTLY_BUILD\" }}'\n  footer: >-\n\n    ---\n\n    Released by [GoReleaser](https://github.com/goreleaser/goreleaser).\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to PicoClaw\n\nThank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.\n\nPicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.\n\n## Table of Contents\n\n- [Code of Conduct](#code-of-conduct)\n- [Ways to Contribute](#ways-to-contribute)\n- [Getting Started](#getting-started)\n- [Development Setup](#development-setup)\n- [Making Changes](#making-changes)\n- [AI-Assisted Contributions](#ai-assisted-contributions)\n- [Pull Request Process](#pull-request-process)\n- [Branch Strategy](#branch-strategy)\n- [Code Review](#code-review)\n- [Communication](#communication)\n\n---\n\n## Code of Conduct\n\nWe are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.\n\n---\n\n## Ways to Contribute\n\n- **Bug reports** — Open an issue using the bug report template.\n- **Feature requests** — Open an issue using the feature request template; discuss before implementing.\n- **Code** — Fix bugs or implement features. See the workflow below.\n- **Documentation** — Improve READMEs, docs, inline comments, or translations.\n- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.\n\nFor substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.\n\n---\n\n## Getting Started\n\n1. **Fork** the repository on GitHub.\n2. **Clone** your fork locally:\n   ```bash\n   git clone https://github.com/<your-username>/picoclaw.git\n   cd picoclaw\n   ```\n3. Add the upstream remote:\n   ```bash\n   git remote add upstream https://github.com/sipeed/picoclaw.git\n   ```\n\n---\n\n## Development Setup\n\n### Prerequisites\n\n- Go 1.25 or later\n- `make`\n\n### Build\n\n```bash\nmake build       # Build binary (runs go generate first)\nmake generate    # Run go generate only\nmake check       # Full pre-commit check: deps + fmt + vet + test\n```\n\n### Running Tests\n\n```bash\nmake test                                    # Run all tests\ngo test -run TestName -v ./pkg/session/      # Run a single test\ngo test -bench=. -benchmem -run='^$' ./...  # Run benchmarks\n```\n\n### Code Style\n\n```bash\nmake fmt   # Format code\nmake vet   # Static analysis\nmake lint  # Full linter run\n```\n\nAll CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.\n\n---\n\n## Making Changes\n\n### Branching\n\nAlways branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:\n\n```bash\ngit checkout main\ngit pull upstream main\ngit checkout -b your-feature-branch\n```\n\nUse descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.\n\n### Commits\n\n- Write clear, concise commit messages in English.\n- Use the imperative mood: \"Add retry logic\" not \"Added retry logic\".\n- Reference the related issue when relevant: `Fix session leak (#123)`.\n- Keep commits focused. One logical change per commit is preferred.\n- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.\n- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/\n\n### Keeping Up to Date\n\nRebase your branch onto upstream `main` before opening a PR:\n\n```bash\ngit fetch upstream\ngit rebase upstream/main\n```\n\n---\n\n## AI-Assisted Contributions\n\nPicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.\n\n### Disclosure Is Required\n\nEvery PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:\n\n| Level | Description |\n|---|---|\n| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |\n| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |\n| 👨‍💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |\n\nHonest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.\n\n### You Are Responsible for What You Submit\n\nUsing AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:\n\n- **Read and understand** every line of the generated code.\n- **Test it** in a real environment (see the Test Environment section of the PR template).\n- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.\n- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.\n\nPRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.\n\n### AI-Generated Code Quality Standards\n\nAI-generated contributions are held to the **same quality bar** as human-written code:\n\n- It must pass all CI checks (`make check`).\n- It must be idiomatic Go and consistent with the existing codebase style.\n- It must not introduce unnecessary abstractions, dead code, or over-engineering.\n- It must include or update tests where appropriate.\n\n### Security Review\n\nAI-generated code requires extra security scrutiny. Pay special attention to:\n\n- File path handling and sandbox escapes (see commit `244eb0b` for a real example)\n- External input validation in channel handlers and tool implementations\n- Credential or secret handling\n- Command execution (`exec.Command`, shell invocations)\n\nIf you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.\n\n---\n\n## Pull Request Process\n\n### Before Opening a PR\n\n- [ ] Run `make check` and ensure it passes locally.\n- [ ] Fill in the PR template completely, including the AI disclosure section.\n- [ ] Link any related issue(s) in the PR description.\n- [ ] Keep the PR focused. Avoid bundling unrelated changes together.\n\n### PR Template Sections\n\nThe PR template asks for:\n\n- **Description** — What does this change do and why?\n- **Type of Change** — Bug fix, feature, docs, or refactor.\n- **AI Code Generation** — Disclosure of AI involvement (required).\n- **Related Issue** — Link to the issue this addresses.\n- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).\n- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.\n- **Evidence** — Optional logs or screenshots demonstrating the change works.\n- **Checklist** — Self-review confirmation.\n\n### PR Size\n\nPrefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.\n\n---\n\n## Branch Strategy\n\n### Long-Lived Branches\n\n- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.\n- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.\n\n### Requirements to Merge into `main`\n\nA PR can only be merged when all of the following are satisfied:\n\n1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.\n2. **Reviewer approval** — At least one maintainer has approved the PR.\n3. **No unresolved review comments** — All review threads must be resolved.\n4. **PR template is complete** — Including AI disclosure and test environment.\n\n### Who Can Merge\n\nOnly maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.\n\n### Merge Strategy\n\nWe use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:\n\n```\nfeat: Add Ollama provider support (#491)\n```\n\nIf a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.\n\n### Release Branches\n\nWhen a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:\n\n- **New features are not backported.** The release branch receives no new functionality after it is cut.\n- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.\n\nIf you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.\n\nRelease branches have stricter protections than `main` and are never directly pushed to under any circumstances.\n\n---\n\n## Code Review\n\n### For Contributors\n\n- Respond to review comments within a reasonable time. If you need more time, say so.\n- When you update a PR in response to feedback, briefly note what changed (e.g., \"Updated to use `sync.RWMutex` as suggested\").\n- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.\n- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.\n\n### For Reviewers\n\nReview for:\n\n1. **Correctness** — Does the code do what it claims? Are there edge cases?\n2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.\n3. **Architecture** — Is the approach consistent with the existing design?\n4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?\n5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?\n\nBe constructive and specific. \"This could have a race condition if two goroutines call this concurrently — consider using a mutex here\" is better than \"this looks wrong\".\n\n\n### Reviewer List\nOnce your PR is submitted, you can reach out to the assigned reviewers listed in the following table.\n\n|Function| Reviewer|\n|---     |---      |\n|Provider|@yinwm   |\n|Channel |@yinwm/@alexhoshina   |\n|Agent   |@lxowalle/@Zhaoyikaiii|\n|Tools   |@lxowalle|\n|SKill   ||\n|MCP     ||\n|Optimization|@lxowalle|\n|Security||\n|AI CI   |@imguoguo|\n|UX      ||\n|Document||\n\n---\n\n## Communication\n\n- **GitHub Issues** — Bug reports, feature requests, design discussions.\n- **GitHub Discussions** — General questions, ideas, community conversation.\n- **Pull Request comments** — Code-specific feedback.\n- **Wechat&Discord** — We will invite you when you have at least one merged PR\n\nWhen in doubt, open an issue before writing code. It costs little and prevents wasted effort.\n\n---\n\n## A Note on the Project's AI-Driven Origin\n\nPicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.\n\nWe believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.\n\nThank you for contributing!\n"
  },
  {
    "path": "CONTRIBUTING.zh.md",
    "content": "# 参与贡献 PicoClaw\n\n感谢你对 PicoClaw 的关注！本项目是一个社区驱动的开源项目，目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献：Bug 修复、新功能、文档、翻译和测试。\n\nPicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式，并围绕它构建了贡献流程。\n\n## 目录\n\n- [行为准则](#行为准则)\n- [贡献方式](#贡献方式)\n- [快速开始](#快速开始)\n- [开发环境配置](#开发环境配置)\n- [提交修改](#提交修改)\n- [AI 辅助贡献](#ai-辅助贡献)\n- [Pull Request 流程](#pull-request-流程)\n- [分支策略](#分支策略)\n- [代码审查](#代码审查)\n- [沟通渠道](#沟通渠道)\n\n---\n\n## 行为准则\n\n我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度，并善意地理解他人。任何形式的骚扰或歧视均不被接受。\n\n---\n\n## 贡献方式\n\n- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。\n- **功能建议** — 使用功能请求模板提交 Issue，建议在开始实现前先进行讨论。\n- **代码贡献** — 修复 Bug 或实现新功能，参见下方工作流程。\n- **文档改进** — 完善 README、文档、代码注释或翻译。\n- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。\n\n对于较大的新功能，请先提交 Issue 讨论设计方案，再动手写代码。这能避免无效投入，也确保与项目方向保持一致。\n\n---\n\n## 快速开始\n\n1. 在 GitHub 上 **Fork** 本仓库。\n2. 将你的 Fork **克隆**到本地：\n   ```bash\n   git clone https://github.com/<你的用户名>/picoclaw.git\n   cd picoclaw\n   ```\n3. 添加上游远程仓库：\n   ```bash\n   git remote add upstream https://github.com/sipeed/picoclaw.git\n   ```\n\n---\n\n## 开发环境配置\n\n### 前置依赖\n\n- Go 1.25 或更高版本\n- `make`\n\n### 构建\n\n```bash\nmake build       # 构建二进制文件（会先执行 go generate）\nmake generate    # 仅执行 go generate\nmake check       # 完整的提交前检查：deps + fmt + vet + test\n```\n\n### 运行测试\n\n```bash\nmake test                                    # 运行所有测试\ngo test -run TestName -v ./pkg/session/      # 运行单个测试\ngo test -bench=. -benchmem -run='^$' ./...  # 运行基准测试\n```\n\n### 代码风格\n\n```bash\nmake fmt   # 格式化代码\nmake vet   # 静态分析\nmake lint  # 完整的 lint 检查\n```\n\n所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`，提前发现问题。\n\n---\n\n## 提交修改\n\n### 分支管理\n\n始终从 `main` 分支切出，并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码：\n\n```bash\ngit checkout main\ngit pull upstream main\ngit checkout -b 你的功能分支名\n```\n\n请使用描述性的分支名，例如：`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。\n\n### Commit 规范\n\n- 使用英文撰写清晰、简洁的 commit 信息。\n- 使用祈使句：写 \"Add retry logic\"，而不是 \"Added retry logic\"。\n- 有关联 Issue 时请引用：`Fix session leak (#123)`。\n- 保持 commit 专注，每个 commit 只做一件事。\n- 对于小的清理或拼写修正，提 PR 前请将其合并为一个 commit。\n- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写\n\n### 保持与上游同步\n\n提 PR 前，请将你的分支变基到上游 `main`：\n\n```bash\ngit fetch upstream\ngit rebase upstream/main\n```\n\n---\n\n## AI 辅助贡献\n\nPicoClaw 在很大程度上借助 AI 辅助开发，我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。\n\n### 必须披露 AI 使用情况\n\n每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况，共分三个级别：\n\n| 级别 | 说明 |\n|---|---|\n| 🤖 完全由 AI 生成 | AI 编写代码，贡献者负责审查和验证 |\n| 🛠️ 主要由 AI 生成 | AI 起草，贡献者做了较大修改 |\n| 👨‍💻 主要由人工编写 | 贡献者主导，AI 仅提供辅助或未使用 AI |\n\n我们期望你诚实填写。三种级别均可接受，没有任何歧视——重要的是贡献的质量。\n\n### 你对提交的代码负全责\n\n使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前，你必须：\n\n- **逐行阅读并理解**生成的代码。\n- **在真实环境中测试**（参见 PR 模板中的测试环境部分）。\n- **检查安全问题** — AI 模型可能生成存在安全隐患的代码（如路径穿越、注入攻击、凭据泄露等），请仔细审查。\n- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的，请验证行为，而不仅仅是语法。\n\n如果明显可以看出贡献者没有阅读或测试 AI 生成的代码，该 PR 将被直接关闭，不予审查。\n\n### AI 生成代码的质量标准\n\nAI 生成的代码与人工编写的代码遵循**相同的质量要求**：\n\n- 必须通过所有 CI 检查（`make check`）。\n- 必须符合 Go 惯用写法，并与现有代码库的风格保持一致。\n- 不得引入不必要的抽象、死代码或过度设计。\n- 须在适当的地方包含或更新测试。\n\n### 安全审查\n\nAI 生成的代码需要格外仔细的安全审查。请特别关注以下方面：\n\n- 文件路径处理与沙箱逃逸（项目历史中的 commit `244eb0b` 就是真实案例）\n- channel 处理器和 tool 实现中的外部输入校验\n- 凭据或密钥的处理\n- 命令执行（`exec.Command`、shell 调用等）\n\n如果你不确定某段 AI 生成代码是否安全，请在 PR 中说明——审查者会帮助判断。\n\n---\n\n## Pull Request 流程\n\n### 提 PR 前的检查\n\n- [ ] 在本地运行 `make check` 并确认通过。\n- [ ] 完整填写 PR 模板，包括 AI 披露部分。\n- [ ] 在 PR 描述中关联相关 Issue。\n- [ ] 保持 PR 专注，避免将不相关的修改混在一起。\n\n### PR 模板各部分说明\n\nPR 模板要求填写：\n\n- **描述** — 这个改动做了什么，为什么要做？\n- **变更类型** — Bug 修复、新功能、文档或重构。\n- **AI 代码生成** — AI 参与情况披露（必填）。\n- **关联 Issue** — 此 PR 解决的 Issue 链接。\n- **技术背景** — 参考链接和设计理由（纯文档类 PR 可跳过）。\n- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。\n- **验证证据** — 可选的日志或截图，用于证明改动有效。\n- **检查清单** — 自我审查确认。\n\n### PR 规模\n\n请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR，远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大，可以考虑将其拆分为一系列逻辑完整的小 PR。\n\n---\n\n## 分支策略\n\n### 长期分支\n\n- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护：禁止直接推送，合并前必须获得至少一名维护者的批准。\n- **`release/x.y`** — 稳定发布分支，在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。\n\n### 合并到 `main` 的前提条件\n\nPR 必须同时满足以下所有条件，才能被合并：\n\n1. **CI 全部通过** — 所有 GitHub Actions 工作流（lint、test、build）均为绿色。\n2. **获得审查者批准** — 至少一名维护者已批准该 PR。\n3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。\n4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。\n\n### 谁可以合并\n\n只有维护者才能合并 PR。贡献者不能合并自己的 PR，即使拥有写权限也不行。\n\n### 合并策略\n\n为保持 `main` 历史清晰可读，我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit，例如：\n\n```\nfeat: Add Ollama provider support (#491)\n```\n\n如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit，维护者可视情况使用普通 merge。\n\n### Release 分支\n\n当某个版本准备就绪时，维护者会从 `main` 切出 `release/x.y` 分支。此后：\n\n- **新功能不会被回溯（backport）。** Release 分支切出后，不再接收任何新功能。\n- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题，维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支，并发布补丁版本。\n\n如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支，请在 PR 描述中注明，或单独开一个 Issue 说明。最终决定由维护者做出。\n\nRelease 分支的保护级别高于 `main`，在任何情况下均不允许直接推送。\n\n---\n\n## 代码审查\n\n### 对贡献者的建议\n\n- 在合理时间内回复审查意见。如果需要更多时间，请告知。\n- 更新 PR 以响应反馈时，简要说明改动内容（例如：\"按建议改用了 `sync.RWMutex`\"）。\n- 如果你不同意某条反馈，请礼貌地阐述你的理由——审查者也可能有判断失误的时候。\n- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit，维护者在合并时会进行 squash。\n\n### 对审查者的建议\n\n审查重点：\n\n1. **正确性** — 代码是否实现了其声称的功能？是否存在边界情况？\n2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。\n3. **架构** — 实现方式是否与现有设计一致？\n4. **简洁性** — 是否有更简单的方案？是否引入了不必要的复杂度？\n5. **测试** — 改动是否有测试覆盖？现有测试是否仍然有意义？\n\n请给出建设性且具体的反馈。\"如果两个 goroutine 同时调用这个函数可能会有竞态条件，建议在这里加一个 mutex\" 远比 \"这里看起来有问题\" 更有帮助。\n\n### 审查者列表\n提交对应PR后，可以参考下表联系对应的审查人员沟通\n\n|Function| Reviewer|\n|---     |---      |\n|Provider|@yinwm   |\n|Channel |@yinwm/@alexhoshina   |\n|Agent   |@lxowalle/@Zhaoyikaiii|\n|Tools   |@lxowalle|\n|SKill   ||\n|MCP     ||\n|Optimization|@lxowalle|\n|Security||\n|AI CI   |@imguoguo|\n|UX      ||\n|Document||\n\n\n\n---\n\n## 沟通渠道\n\n- **GitHub Issues** — Bug 报告、功能建议、设计讨论。\n- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。\n- **Pull Request 评论** — 与具体代码相关的反馈。\n- **Wechat&Discord** — 当你有至少一个已合并的PR后，我们会邀请你加入开发者交流群\n\n有疑问时，请先开 Issue 讨论，再动手写代码。这几乎没有成本，却能避免大量无效投入。\n\n---\n\n## 关于本项目的 AI 驱动起源\n\nPicoClaw 的架构在人工监督下，经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计，这可能是该过程留下的痕迹——欢迎提 Issue 讨论。\n\n我们相信，负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信，人类必须对自己提交的内容负责。这两点并不矛盾。\n\n感谢你的贡献！\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 PicoClaw contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: all build install uninstall clean help test\n\n# Build variables\nBINARY_NAME=picoclaw\nBUILD_DIR=build\nCMD_DIR=cmd/$(BINARY_NAME)\nMAIN_GO=$(CMD_DIR)/main.go\n\n# Version\nVERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo \"dev\")\nGIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo \"dev\")\nBUILD_TIME=$(shell date +%FT%T%z)\nGO_VERSION=$(shell $(GO) version | awk '{print $$3}')\nCONFIG_PKG=github.com/sipeed/picoclaw/pkg/config\nLDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w\n\n# Go variables\nGO?=CGO_ENABLED=0 go\nWEB_GO?=$(GO)\nGOFLAGS?=-v -tags stdjson\n\n# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).\n#\n# Bytes (octal): \\004 \\024 \\000 \\160  →  little-endian 0x70001404\n#   0x70000000  EF_MIPS_ARCH_32R2   MIPS32 Release 2\n#   0x00001000  EF_MIPS_ABI_O32     O32 ABI\n#   0x00000400  EF_MIPS_NAN2008     IEEE 754-2008 NaN encoding\n#   0x00000004  EF_MIPS_CPIC        PIC calling sequence\n#\n# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant\n# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.\n# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.\n#\n# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html\ndefine PATCH_MIPS_FLAGS\n\t@if [ -f \"$(1)\" ]; then \\\n\t\tprintf '\\004\\024\\000\\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \\\n\t\t{ echo \"Error: failed to patch MIPS e_flags for $(1)\"; exit 1; }; \\\n\telse \\\n\t\techo \"Error: $(1) not found, cannot patch MIPS e_flags\"; exit 1; \\\n\tfi\nendef\n\n# Golangci-lint\nGOLANGCI_LINT?=golangci-lint\n\n# Installation\nINSTALL_PREFIX?=$(HOME)/.local\nINSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin\nINSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1\nINSTALL_TMP_SUFFIX=.new\n\n# Workspace and Skills\nPICOCLAW_HOME?=$(HOME)/.picoclaw\nWORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace\nWORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills\nBUILTIN_SKILLS_DIR=$(CURDIR)/skills\n\n# OS detection\nUNAME_S:=$(shell uname -s)\nUNAME_M:=$(shell uname -m)\n\n# Platform-specific settings\nifeq ($(UNAME_S),Linux)\n\tPLATFORM=linux\n\tifeq ($(UNAME_M),x86_64)\n\t\tARCH=amd64\n\telse ifeq ($(UNAME_M),aarch64)\n\t\tARCH=arm64\n\telse ifeq ($(UNAME_M),armv81)\n\t\tARCH=arm64\n\telse ifeq ($(UNAME_M),loongarch64)\n\t\tARCH=loong64\n\telse ifeq ($(UNAME_M),riscv64)\n\t\tARCH=riscv64\n\telse ifeq ($(UNAME_M),mipsel)\n\t\tARCH=mipsle\n\telse\n\t\tARCH=$(UNAME_M)\n\tendif\nelse ifeq ($(UNAME_S),Darwin)\n\tPLATFORM=darwin\n\tWEB_GO=CGO_ENABLED=1 go\n\tifeq ($(UNAME_M),x86_64)\n\t\tARCH=amd64\n\telse ifeq ($(UNAME_M),arm64)\n\t\tARCH=arm64\n\telse\n\t\tARCH=$(UNAME_M)\n\tendif\nelse\n\tPLATFORM=$(UNAME_S)\n\tARCH=$(UNAME_M)\nendif\n\nBINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)\n\n# Default target\nall: build\n\n## generate: Run generate\ngenerate:\n\t@echo \"Run generate...\"\n\t@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true\n\t@$(GO) generate ./...\n\t@echo \"Run generate complete\"\n\n## build: Build the picoclaw binary for current platform\nbuild: generate\n\t@echo \"Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)...\"\n\t@mkdir -p $(BUILD_DIR)\n\t@$(GO) build $(GOFLAGS) -ldflags \"$(LDFLAGS)\" -o $(BINARY_PATH) ./$(CMD_DIR)\n\t@echo \"Build complete: $(BINARY_PATH)\"\n\t@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)\n\n## build-launcher: Build the picoclaw-launcher (web console) binary\nbuild-launcher:\n\t@echo \"Building picoclaw-launcher for $(PLATFORM)/$(ARCH)...\"\n\t@mkdir -p $(BUILD_DIR)\n\t@if [ ! -f web/backend/dist/index.html ]; then \\\n\t\techo \"Building frontend...\"; \\\n\t\tcd web/frontend && pnpm install && pnpm build:backend; \\\n\tfi\n\t@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend\n\t@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher\n\t@echo \"Build complete: $(BUILD_DIR)/picoclaw-launcher\"\n\n## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary\nbuild-whatsapp-native: generate\n## @echo \"Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)...\"\n\t@echo \"Building for multiple platforms...\"\n\t@mkdir -p $(BUILD_DIR)\n\tGOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)\n\tGOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)\n\t$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)\n\tGOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)\n\tGOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)\n## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags \"$(LDFLAGS)\" -o $(BINARY_PATH) ./$(CMD_DIR)\n\t@echo \"Build complete\"\n##\t@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)\n\n## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)\nbuild-linux-arm: generate\n\t@echo \"Building for linux/arm (GOARM=7)...\"\n\t@mkdir -p $(BUILD_DIR)\n\tGOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)\n\t@echo \"Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm\"\n\n## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)\nbuild-linux-arm64: generate\n\t@echo \"Building for linux/arm64...\"\n\t@mkdir -p $(BUILD_DIR)\n\tGOOS=linux GOARCH=arm64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)\n\t@echo \"Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64\"\n\n## build-linux-mipsle: Build for Linux MIPS32 LE\nbuild-linux-mipsle: generate\n\t@echo \"Building for linux/mipsle (softfloat)...\"\n\t@mkdir -p $(BUILD_DIR)\n\tGOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)\n\t$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)\n\t@echo \"Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle\"\n\n## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)\nbuild-pi-zero: build-linux-arm build-linux-arm64\n\t@echo \"Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)\"\n\n## build-all: Build picoclaw for all platforms\nbuild-all: generate\n\t@echo \"Building for multiple platforms...\"\n\t@mkdir -p $(BUILD_DIR)\n\tGOOS=linux GOARCH=amd64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)\n\tGOOS=linux GOARCH=arm64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=loong64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=riscv64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)\n\tGOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)\n\t$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)\n\tGOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)\n\tGOOS=darwin GOARCH=arm64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)\n\tGOOS=windows GOARCH=amd64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)\n\tGOOS=netbsd GOARCH=amd64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)\n\tGOOS=netbsd GOARCH=arm64 $(GO) build -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)\n\t@echo \"All builds complete\"\n\n## install: Install picoclaw to system and copy builtin skills\ninstall: build\n\t@echo \"Installing $(BINARY_NAME)...\"\n\t@mkdir -p $(INSTALL_BIN_DIR)\n\t# Copy binary with temporary suffix to ensure atomic update\n\t@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)\n\t@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)\n\t@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)\n\t@echo \"Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)\"\n\t@echo \"Installation complete!\"\n\n## uninstall: Remove picoclaw from system\nuninstall:\n\t@echo \"Uninstalling $(BINARY_NAME)...\"\n\t@rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)\n\t@echo \"Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)\"\n\t@echo \"Note: Only the executable file has been deleted.\"\n\t@echo \"If you need to delete all configurations (config.json, workspace, etc.), run 'make uninstall-all'\"\n\n## uninstall-all: Remove picoclaw and all data\nuninstall-all:\n\t@echo \"Removing workspace and skills...\"\n\t@rm -rf $(PICOCLAW_HOME)\n\t@echo \"Removed workspace: $(PICOCLAW_HOME)\"\n\t@echo \"Complete uninstallation done!\"\n\n## clean: Remove build artifacts\nclean:\n\t@echo \"Cleaning build artifacts...\"\n\t@rm -rf $(BUILD_DIR)\n\t@echo \"Clean complete\"\n\n## vet: Run go vet for static analysis\nvet: generate\n\t@packages=\"$$(go list ./...)\" && \\\n\t\t$(GO) vet $$(printf '%s\\n' \"$$packages\" | grep -v '^github.com/sipeed/picoclaw/web/')\n\t@cd web/backend && $(WEB_GO) vet ./...\n\n## test: Test Go code\ntest: generate\n\t@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)\n\t@cd web && make test\n\n## fmt: Format Go code\nfmt:\n\t@$(GOLANGCI_LINT) fmt\n\n## lint: Run linters\nlint:\n\t@$(GOLANGCI_LINT) run\n\n## fix: Fix linting issues\nfix:\n\t@$(GOLANGCI_LINT) run --fix\n\n## deps: Download dependencies\ndeps:\n\t@$(GO) mod download\n\t@$(GO) mod verify\n\n## update-deps: Update dependencies\nupdate-deps:\n\t@$(GO) get -u ./...\n\t@$(GO) mod tidy\n\n## check: Run vet, fmt, and verify dependencies\ncheck: deps fmt vet test\n\n## run: Build and run picoclaw\nrun: build\n\t@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)\n\n## docker-build: Build Docker image (minimal Alpine-based)\ndocker-build:\n\t@echo \"Building minimal Docker image (Alpine-based)...\"\n\tdocker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway\n\n## docker-build-full: Build Docker image with full MCP support (Node.js 24)\ndocker-build-full:\n\t@echo \"Building full-featured Docker image (Node.js 24)...\"\n\tdocker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway\n\n## docker-test: Test MCP tools in Docker container\ndocker-test:\n\t@echo \"Testing MCP tools in Docker...\"\n\t@chmod +x scripts/test-docker-mcp.sh\n\t@./scripts/test-docker-mcp.sh\n\n## docker-run: Run picoclaw gateway in Docker (Alpine-based)\ndocker-run:\n\tdocker compose -f docker/docker-compose.yml --profile gateway up\n\n## docker-run-full: Run picoclaw gateway in Docker (full-featured)\ndocker-run-full:\n\tdocker compose -f docker/docker-compose.full.yml --profile gateway up\n\n## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)\ndocker-run-agent:\n\tdocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n\n## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)\ndocker-run-agent-full:\n\tdocker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent\n\n## docker-clean: Clean Docker images and volumes\ndocker-clean:\n\tdocker compose -f docker/docker-compose.yml down -v\n\tdocker compose -f docker/docker-compose.full.yml down -v\n\tdocker rmi picoclaw:latest picoclaw:full 2>/dev/null || true\n\n\n## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)\nbuild-macos-app:\n\t@echo \"Building macOS .app bundle...\"\n\t@if [ \"$(UNAME_S)\" != \"Darwin\" ]; then \\\n\t\techo \"Error: This target is only available on macOS\"; \\\n\t\texit 1; \\\n\tfi\n\t@cd web && $(MAKE) build && cd ..\n\t@./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH)\n\t@echo \"macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app\"\n\n## help: Show this help message\nhelp:\n\t@echo \"picoclaw Makefile\"\n\t@echo \"\"\n\t@echo \"Usage:\"\n\t@echo \"  make [target]\"\n\t@echo \"\"\n\t@echo \"Targets:\"\n\t@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf \"  %-16s %s\\n\", substr($$1, 4), $$2}'\n\t@echo \"\"\n\t@echo \"Examples:\"\n\t@echo \"  make build              # Build for current platform\"\n\t@echo \"  make install            # Install to ~/.local/bin\"\n\t@echo \"  make uninstall          # Remove from /usr/local/bin\"\n\t@echo \"  make install-skills     # Install skills to workspace\"\n\t@echo \"  make docker-build       # Build minimal Docker image\"\n\t@echo \"  make docker-test        # Test MCP tools in Docker\"\n\t@echo \"\"\n\t@echo \"Environment Variables:\"\n\t@echo \"  INSTALL_PREFIX          # Installation prefix (default: ~/.local)\"\n\t@echo \"  WORKSPACE_DIR           # Workspace directory (default: ~/.picoclaw/workspace)\"\n\t@echo \"  VERSION                 # Version string (default: git describe)\"\n\t@echo \"\"\n\t@echo \"Current Configuration:\"\n\t@echo \"  Platform: $(PLATFORM)/$(ARCH)\"\n\t@echo \"  Binary: $(BINARY_PATH)\"\n\t@echo \"  Install Prefix: $(INSTALL_PREFIX)\"\n\t@echo \"  Workspace: $(WORKSPACE_DIR)\"\n"
  },
  {
    "path": "README.fr.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>\n\n  <h3>Matériel à $10 · <10 Mo de RAM · Démarrage en <1s · 皮皮虾，我们走！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com). Il est entièrement écrit en **Go** — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet.\n\n🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.\n\n⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **$10** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 SÉCURITÉ & CANAUX OFFICIELS**\n>\n> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**.\n>\n> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**.\n> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers.\n> * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0.\n> * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé.\n\n## 📢 Actualités\n\n2026-03-17 🚀 **v0.2.3 publié !** Interface système tray (Windows & Linux), suivi de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du gateway, portes de sécurité cron, et 2 correctifs de sécurité. PicoClaw atteint **25K ⭐** !\n\n2026-03-09 🎉 **v0.2.1 — Plus grande mise à jour !** Support du protocole MCP, 4 nouveaux canaux (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux fournisseurs (Kimi/Minimax/Avian), pipeline de vision, stockage mémoire JSONL, et routage de modèles.\n\n2026-02-28 📦 **v0.2.0** publié avec support Docker Compose et lanceur Web UI.\n\n2026-02-26 🎉 PicoClaw a atteint **20K étoiles** en seulement 17 jours ! L'orchestration automatique des canaux et les interfaces de capacités sont arrivées.\n\n<details>\n<summary>Actualités précédentes...</summary>\n\n2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Les rôles de mainteneurs communautaires et la [feuille de route](ROADMAP.md) sont officiellement publiés.\n\n2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! La Feuille de Route du Projet et le Groupe de Développeurs sont en cours de mise en place.\n\n2026-02-09 🎉 **PicoClaw est lancé !** Construit en 1 jour pour apporter les Agents IA au matériel à $10 avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti !\n\n</details>\n\n## ✨ Fonctionnalités\n\n🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que les fonctionnalités essentielles d'OpenClaw.*\n\n💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini.\n\n⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en <1 seconde même sur un cœur unique à 0,6 GHz.\n\n🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !\n\n🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.\n\n🔌 **Support MCP** : Intégration native du [Model Context Protocol](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de l'agent.\n\n👁️ **Pipeline de Vision** : Envoyez des images et fichiers directement à l'agent — encodage base64 automatique pour les LLM multimodaux.\n\n🧠 **Routage Intelligent** : Routage de modèles basé sur des règles — les requêtes simples vont vers des modèles légers, économisant les coûts API.\n\n_*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapides de fonctionnalités. L'optimisation des ressources est prévue. La comparaison de démarrage est basée sur des benchmarks à cœur unique 0,8 GHz (voir tableau ci-dessous)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Langage**                   | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1 Go         | >100 Mo                  | **< 10 Mo***                              |\n| **Démarrage**</br>(cœur 0,8 GHz) | >500s     | >30s                     | **<1s**                                   |\n| **Coût**                      | Mac Mini $599 | La plupart des SBC Linux </br>~$50 | **N'importe quelle carte Linux**</br>**À partir de $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Démonstration\n\n### 🛠️ Flux de Travail Standard de l'Assistant\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Ingénieur Full-Stack</p></th>\n    <th><p align=\"center\">🗂️ Gestion des Logs & Planification</p></th>\n    <th><p align=\"center\">🔎 Recherche Web & Apprentissage</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Développer • Déployer • Mettre à l'échelle</td>\n    <td align=\"center\">Planifier • Automatiser • Mémoriser</td>\n    <td align=\"center\">Découvrir • Analyser • Tendances</td>\n  </tr>\n</table>\n\n### 📱 Utiliser sur d'anciens téléphones Android\n\nDonnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide :\n\n1. **Installez [Termux](https://github.com/termux/termux-app)** (Téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou recherchez sur F-Droid / Google Play).\n2. **Exécutez les commandes**\n\n```bash\n# Téléchargez la dernière version depuis https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nPuis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Déploiement Innovant à Faible Empreinte\n\nPicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !\n\n- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste\n- 30~$50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs\n- 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 Encore plus de scénarios de déploiement vous attendent !\n\n## 📦 Installation\n\n### Installer avec un binaire précompilé\n\nTéléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases).\n\n### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Compiler, pas besoin d'installer\nmake build\n\n# Compiler pour plusieurs plateformes\nmake build-all\n\n# Compiler pour Raspberry Pi Zero 2 W (32-bit : make build-linux-arm ; 64-bit : make build-linux-arm64)\nmake build-pi-zero\n\n# Compiler et Installer\nmake install\n```\n\n**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32-bit → `make build-linux-arm` ; 64-bit → `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux.\n\n## 📚 Documentation\n\nPour des guides détaillés, consultez la documentation ci-dessous. Ce README ne couvre que le démarrage rapide.\n\n| Sujet | Description |\n|-------|-------------|\n| 🐳 [Docker & Démarrage Rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent, configuration rapide |\n| 💬 [Applications de Chat](docs/fr/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, et plus |\n| ⚙️ [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sources de compétences, bac à sable de sécurité, heartbeat |\n| 🔌 [Fournisseurs & Modèles](docs/fr/providers.md) | 20+ fournisseurs LLM, routage de modèles, configuration model_list, architecture des fournisseurs |\n| 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents |\n| 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |\n| 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Rejoignez le Réseau Social d'Agents\n\nConnectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.\n\n**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ Référence CLI\n\n| Commande                  | Description                        |\n| ------------------------- | ---------------------------------- |\n| `picoclaw onboard`        | Initialiser la config & le workspace |\n| `picoclaw agent -m \"...\"` | Discuter avec l'agent              |\n| `picoclaw agent`          | Mode chat interactif               |\n| `picoclaw gateway`        | Démarrer le gateway                |\n| `picoclaw status`         | Afficher le statut                 |\n| `picoclaw version`        | Afficher les infos de version      |\n| `picoclaw cron list`      | Lister les tâches planifiées       |\n| `picoclaw cron add ...`   | Ajouter une tâche planifiée        |\n| `picoclaw cron disable`   | Désactiver une tâche planifiée     |\n| `picoclaw cron remove`    | Supprimer une tâche planifiée      |\n| `picoclaw skills list`    | Lister les compétences installées  |\n| `picoclaw skills install` | Installer une compétence           |\n| `picoclaw migrate`        | Migrer les données des anciennes versions |\n| `picoclaw auth login`     | S'authentifier auprès des fournisseurs |\n\n### Tâches Planifiées / Rappels\n\nPicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :\n\n* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min\n* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures\n* **Expressions cron** : « Rappelle-moi à 9h chaque jour » → utilise une expression cron\n\n## 🤝 Contribuer & Feuille de Route\n\nLes PR sont les bienvenues ! Le code est intentionnellement petit et lisible. 🤗\n\nConsultez notre [Feuille de Route Communautaire](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) complète.\n\nGroupe de développeurs en construction, rejoignez-nous après votre première PR fusionnée !\n\nGroupes d'utilisateurs :\n\ndiscord : <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.id.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>\n\n  <h3>Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**\n\n</div>\n\n---\n\n> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.\n\n🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses \"self-bootstrapping\" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode.\n\n⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini!\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 KEAMANAN & SALURAN RESMI**\n>\n> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**.\n>\n> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**\n> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga.\n> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0.\n> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil.\n\n## 📢 Berita\n\n2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**!\n\n2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model.\n\n2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI.\n\n2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.\n\n<details>\n<summary>Berita lama...</summary>\n\n2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting.\n\n2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan.\n\n2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!\n\n</details>\n\n## ✨ Fitur\n\n🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.*\n\n💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.\n\n⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz.\n\n🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan!\n\n🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.\n\n🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent.\n\n👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal.\n\n🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.\n\n_*Versi terbaru mungkin menggunakan 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Bahasa**                    | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **Startup**</br>(0,8GHz core) | >500d         | >30d                     | **<1d**                                   |\n| **Biaya**                     | Mac Mini $599 | Kebanyakan Linux SBC </br>~$50 | **Semua Board Linux**</br>**Mulai dari $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Demonstrasi\n\n### 🛠️ Alur Kerja Asisten Standar\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Full-Stack Engineer</p></th>\n    <th><p align=\"center\">🗂️ Pencatatan & Manajemen Perencanaan</p></th>\n    <th><p align=\"center\">🔎 Pencarian Web & Pembelajaran</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Develop • Deploy • Scale</td>\n    <td align=\"center\">Jadwal • Otomasi • Memori</td>\n    <td align=\"center\">Penemuan • Wawasan • Tren</td>\n  </tr>\n</table>\n\n### 📱 Jalankan di HP Android Lama\n\nBerikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat:\n\n1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play).\n2. **Jalankan perintah**\n\n```bash\n# Unduh rilis terbaru dari https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nKemudian ikuti instruksi di bagian \"Panduan Cepat\" untuk menyelesaikan konfigurasi!\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Deploy Inovatif dengan Footprint Rendah\n\nPicoClaw dapat di-deploy di hampir semua perangkat Linux!\n\n- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 Lebih Banyak Kasus Deploy Menanti!\n\n## 📦 Instalasi\n\n### Instal dengan binary yang sudah dikompilasi\n\nUnduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases).\n\n### Instal dari source (fitur terbaru, disarankan untuk pengembangan)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Build, tidak perlu instal\nmake build\n\n# Build untuk berbagai platform\nmake build-all\n\n# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)\nmake build-pi-zero\n\n# Build dan Instal\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.\n\n## 📚 Dokumentasi\n\nUntuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat.\n\n| Topik | Deskripsi |\n|-------|-----------|\n| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat |\n| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya |\n| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat |\n| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider |\n| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |\n| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |\n| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Bergabung dengan Jaringan Sosial Agent\n\nHubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi.\n\n**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ Referensi CLI\n\n| Perintah                  | Deskripsi                        |\n| ------------------------- | -------------------------------- |\n| `picoclaw onboard`        | Inisialisasi konfigurasi & workspace |\n| `picoclaw agent -m \"...\"` | Chat dengan agent                |\n| `picoclaw agent`          | Mode chat interaktif             |\n| `picoclaw gateway`        | Mulai gateway                    |\n| `picoclaw status`         | Tampilkan status                 |\n| `picoclaw version`        | Tampilkan info versi             |\n| `picoclaw cron list`      | Daftar semua tugas terjadwal     |\n| `picoclaw cron add ...`   | Tambah tugas terjadwal           |\n| `picoclaw cron disable`   | Nonaktifkan tugas terjadwal      |\n| `picoclaw cron remove`    | Hapus tugas terjadwal            |\n| `picoclaw skills list`    | Daftar skill yang terinstal      |\n| `picoclaw skills install` | Instal skill                     |\n| `picoclaw migrate`        | Migrasi data dari versi lama     |\n| `picoclaw auth login`     | Autentikasi dengan provider      |\n\n### Tugas Terjadwal / Pengingat\n\nPicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:\n\n* **Pengingat satu kali**: \"Ingatkan saya dalam 10 menit\" → terpicu sekali setelah 10 menit\n* **Tugas berulang**: \"Ingatkan saya setiap 2 jam\" → terpicu setiap 2 jam\n* **Ekspresi cron**: \"Ingatkan saya jam 9 pagi setiap hari\" → menggunakan ekspresi cron\n\n## 🤝 Kontribusi & Roadmap\n\nPR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗\n\nLihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami.\n\nGrup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!\n\nGrup Pengguna:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.it.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>\n\n  <h3>Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾，我们走！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com). È scritto interamente in **Go** — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.\n\n🦐 PicoClaw è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot), riscritto da zero in Go attraverso un processo di auto-bootstrapping, in cui l'agente IA stesso ha guidato l'intera migrazione architetturale e l'ottimizzazione del codice.\n\n⚡️ Funziona su hardware da $10 con meno di 10MB di RAM: il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 SICUREZZA & CANALI UFFICIALI**\n>\n> * **NESSUNA CRYPTO:** PicoClaw non ha **NESSUN** token/coin ufficiale. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **TRUFFA**.\n>\n> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**.\n> * **Attenzione:** Molti domini `.ai/.org/.com/.net/...` sono registrati da terze parti.\n> * **Attenzione:** PicoClaw è in fase di sviluppo iniziale e potrebbe avere problemi di sicurezza di rete non risolti. Non distribuire in ambienti di produzione prima della release v1.0.\n> * **Nota:** PicoClaw ha recentemente unito molte PR, il che potrebbe comportare un'impronta di memoria maggiore (10–20MB) nelle ultime versioni. Prevediamo di dare priorità all'ottimizzazione delle risorse non appena il set di funzionalità corrente raggiungerà uno stato stabile.\n\n## 📢 Novità\n\n2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), tracciamento dello stato dei sub-agent (`spawn_status`), hot-reload sperimentale del gateway, gate di sicurezza per cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K ⭐**!\n\n2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.\n\n2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e launcher Web UI.\n\n2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.\n\n<details>\n<summary>Notizie precedenti...</summary>\n\n2026-02-16 🎉 PicoClaw ha raggiunto 12K stelle in una settimana! Ruoli di maintainer della community e [roadmap](ROADMAP.md) pubblicati ufficialmente.\n\n2026-02-13 🎉 PicoClaw ha raggiunto 5000 stelle in 4 giorni! Roadmap del progetto e gruppo sviluppatori in fase di avvio.\n\n2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli agenti IA su hardware da $10 con <10MB di RAM. 🦐 PicoClaw, andiamo!\n\n</details>\n\n## ✨ Caratteristiche\n\n🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo delle funzionalità principali di OpenClaw.*\n\n💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.\n\n⚡️ **Avvio Fulmineo**: Tempo di avvio 400 volte più veloce, boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.\n\n🌍 **Vera Portabilità**: Singolo binario autonomo per RISC-V, ARM, MIPS e x86. Un click e si parte!\n\n🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo.\n\n🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent.\n\n👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'agent — codifica base64 automatica per LLM multimodali.\n\n🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.\n\n_*Le versioni recenti potrebbero usare 10–20MB a causa delle fusioni rapide di funzionalità. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Linguaggio**                | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **Avvio**</br>(core 0,8 GHz)  | >500s         | >30s                     | **<1s**                                   |\n| **Costo**                     | Mac Mini $599 | La maggior parte degli SBC Linux </br>~$50 | **Qualsiasi scheda Linux**</br>**A partire da $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Dimostrazione\n\n### 🛠️ Flussi di Lavoro Standard dell'Assistente\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Ingegnere Full-Stack</p></th>\n    <th><p align=\"center\">🗂️ Gestione Log & Pianificazione</p></th>\n    <th><p align=\"center\">🔎 Ricerca Web & Apprendimento</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Sviluppa • Distribuisci • Scala</td>\n    <td align=\"center\">Pianifica • Automatizza • Memorizza</td>\n    <td align=\"center\">Scopri • Analizza • Tendenze</td>\n  </tr>\n</table>\n\n### 📱 Usa su vecchi telefoni Android\n\nDai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. Avvio rapido:\n\n1. **Installa [Termux](https://github.com/termux/termux-app)** (Scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play).\n2. **Esegui i comandi**\n\n```bash\n# Scarica l'ultima release da https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nPoi segui le istruzioni nella sezione \"Avvio Rapido\" per completare la configurazione!\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Deploy Innovativo a Bassa Impronta\n\nPicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!\n\n- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un Assistente Domotico Minimale\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) per la Manutenzione Automatizzata dei Server\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) per il Monitoraggio Intelligente\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 Molti altri scenari di deploy ti aspettano!\n\n## 📦 Installazione\n\n### Installa con binario precompilato\n\nScarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases).\n\n### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Compila, senza installare\nmake build\n\n# Compila per più piattaforme\nmake build-all\n\n# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)\nmake build-pi-zero\n\n# Compila e Installa\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.\n\n## 📚 Documentazione\n\nPer guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido.\n\n| Argomento | Descrizione |\n|-----------|-------------|\n| 🐳 [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent, configurazione rapida |\n| 💬 [App di Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e altro |\n| ⚙️ [Configurazione](docs/it/configuration.md) | Variabili d'ambiente, struttura del workspace, sorgenti delle skill, sandbox di sicurezza, heartbeat |\n| 🔌 [Provider & Modelli](docs/providers.md) | 20+ provider LLM, routing dei modelli, configurazione model_list, architettura dei provider |\n| 🔄 [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |\n| 🐛 [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |\n| 🔧 [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Unisciti al Social Network degli Agent\n\nConnetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.\n\n**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ Riferimento CLI\n\n| Comando                   | Descrizione                        |\n| ------------------------- | ---------------------------------- |\n| `picoclaw onboard`        | Inizializza config & workspace     |\n| `picoclaw agent -m \"...\"` | Chatta con l'agent                 |\n| `picoclaw agent`          | Modalità chat interattiva          |\n| `picoclaw gateway`        | Avvia il gateway                   |\n| `picoclaw status`         | Mostra lo stato                    |\n| `picoclaw version`        | Mostra le info sulla versione      |\n| `picoclaw cron list`      | Elenca tutti i job pianificati     |\n| `picoclaw cron add ...`   | Aggiunge un job pianificato        |\n| `picoclaw cron disable`   | Disabilita un job pianificato      |\n| `picoclaw cron remove`    | Rimuove un job pianificato         |\n| `picoclaw skills list`    | Elenca le skill installate         |\n| `picoclaw skills install` | Installa una skill                 |\n| `picoclaw migrate`        | Migra i dati dalle versioni precedenti |\n| `picoclaw auth login`     | Autenticazione con i provider      |\n\n### Task Pianificati / Promemoria\n\nPicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:\n\n* **Promemoria una tantum**: \"Ricordami tra 10 minuti\" → si attiva una volta dopo 10 min\n* **Task ricorrenti**: \"Ricordami ogni 2 ore\" → si attiva ogni 2 ore\n* **Espressioni cron**: \"Ricordami alle 9 ogni giorno\" → usa un'espressione cron\n\n## 🤝 Contribuisci & Roadmap\n\nLe PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗\n\nConsulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completa.\n\nGruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!\n\nGruppi utenti:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.ja.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>\n\n  <h3>$10 ハードウェア · <10MB RAM · <1秒起動 · 行くぜ、シャコ！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。\n\n🦐 PicoClaw は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。\n\n⚡️ $10 のハードウェアで 10MB 未満の RAM で動作：OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い！\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 セキュリティ＆公式チャンネル**\n>\n> * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。\n>\n> * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。\n> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。\n> * **注意:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。\n> * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります（10〜20MB）。機能セットが安定次第、リソース最適化を優先する予定です。\n\n## 📢 ニュース\n\n2026-03-17 🚀 **v0.2.3 リリース！** システムトレイ UI（Windows & Linux）、サブエージェントステータス追跡（`spawn_status`）、実験的ゲートウェイホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成！\n\n2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート！** MCP プロトコル対応、4 つの新チャネル（Matrix/IRC/WeCom/Discord Proxy）、3 つの新プロバイダー（Kimi/Minimax/Avian）、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。\n\n2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI ランチャー。\n\n2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成！チャネル自動オーケストレーションとケイパビリティインターフェースが実装されました。\n\n<details>\n<summary>過去のニュース...</summary>\n\n2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成！コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。\n\n2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成！プロジェクトロードマップと開発者グループの準備が進行中。\n\n2026-02-09 🎉 **PicoClaw リリース！** $10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ！\n\n</details>\n\n## ✨ 特徴\n\n🪶 **超軽量**: メモリフットプリント 10MB 未満 — OpenClaw のコア機能より 99% 小さい。*\n\n💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。\n\n⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒未満で起動。\n\n🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go！\n\n🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。\n\n🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続してエージェント機能を拡張。\n\n👁️ **ビジョンパイプライン**: 画像やファイルをエージェントに直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。\n\n🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。\n\n_*最近のバージョンでは急速な機能マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています（下表参照）。_\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **言語**                      | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **起動時間**</br>(0.8GHz コア) | >500秒        | >30秒                    | **<1秒**                                  |\n| **コスト**                    | Mac Mini $599 | 大半の Linux SBC </br>~$50 | **あらゆる Linux ボード**</br>**最安 $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 デモンストレーション\n\n### 🛠️ スタンダードアシスタントワークフロー\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 フルスタックエンジニア</p></th>\n    <th><p align=\"center\">🗂️ ログ＆計画管理</p></th>\n    <th><p align=\"center\">🔎 Web 検索＆学習</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">開発 · デプロイ · スケール</td>\n    <td align=\"center\">スケジュール · 自動化 · メモリ</td>\n    <td align=\"center\">発見 · インサイト · トレンド</td>\n  </tr>\n</table>\n\n### 📱 古い Android スマホで動かす\n\n10 年前のスマホに第二の人生を！PicoClaw でスマート AI アシスタントに変身させましょう。クイックスタート：\n\n1. **[Termux](https://github.com/termux/termux-app) をインストール**（[GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索）。\n2. **コマンドを実行**\n\n```bash\n# https://github.com/sipeed/picoclaw/releases から最新リリースをダウンロード\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nその後「クイックスタート」セクションの手順に従って設定を完了してください！\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 革新的な省フットプリントデプロイ\n\nPicoClaw はほぼすべての Linux デバイスにデプロイできます！\n\n- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 もっと多くのデプロイ事例が待っています！\n\n## 📦 インストール\n\n### コンパイル済みバイナリでインストール\n\n[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のバイナリをダウンロードしてください。\n\n### ソースからインストール（最新機能、開発向け推奨）\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# ビルド（インストール不要）\nmake build\n\n# 複数プラットフォーム向けビルド\nmake build-all\n\n# Raspberry Pi Zero 2 W 向けビルド（32-bit: make build-linux-arm; 64-bit: make build-linux-arm64）\nmake build-pi-zero\n\n# ビルドとインストール\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください：32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。\n\n## 📚 ドキュメント\n\n詳細なガイドは以下のドキュメントを参照してください。この README はクイックスタートのみをカバーしています。\n\n| トピック | 説明 |\n|---------|------|\n| 🐳 [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード、クイックスタート設定 |\n| 💬 [チャットアプリ](docs/ja/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、DingTalk、LINE、Feishu、WeCom など |\n| ⚙️ [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、スキルソース、セキュリティサンドボックス、ハートビート |\n| 🔌 [プロバイダー＆モデル](docs/ja/providers.md) | 20 以上の LLM プロバイダー、モデルルーティング、model_list 設定、プロバイダーアーキテクチャ |\n| 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |\n| 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |\n| 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> エージェントソーシャルネットワークに参加\n\nCLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。\n\n**`https://clawdchat.ai/skill.md` を読み、指示に従って [ClawdChat.ai](https://clawdchat.ai) に参加してください**\n\n## 🖥️ CLI リファレンス\n\n| コマンド                    | 説明                           |\n| ------------------------- | ------------------------------ |\n| `picoclaw onboard`        | 設定＆ワークスペースの初期化     |\n| `picoclaw agent -m \"...\"` | エージェントとチャット           |\n| `picoclaw agent`          | インタラクティブチャットモード   |\n| `picoclaw gateway`        | ゲートウェイを起動              |\n| `picoclaw status`         | ステータスを表示                |\n| `picoclaw version`        | バージョン情報を表示            |\n| `picoclaw cron list`      | スケジュールジョブ一覧          |\n| `picoclaw cron add ...`   | スケジュールジョブを追加         |\n| `picoclaw cron disable`   | スケジュールジョブを無効化       |\n| `picoclaw cron remove`    | スケジュールジョブを削除         |\n| `picoclaw skills list`    | インストール済みスキル一覧       |\n| `picoclaw skills install` | スキルをインストール             |\n| `picoclaw migrate`        | 旧バージョンからデータを移行     |\n| `picoclaw auth login`     | プロバイダーへの認証             |\n\n### スケジュールタスク / リマインダー\n\nPicoClaw は `cron` ツールによるスケジュールリマインダーと定期タスクをサポートしています：\n\n* **ワンタイムリマインダー**: 「10分後にリマインド」→ 10分後に1回トリガー\n* **定期タスク**: 「2時間ごとにリマインド」→ 2時間ごとにトリガー\n* **Cron 式**: 「毎日9時にリマインド」→ cron 式を使用\n\n## 🤝 コントリビュート＆ロードマップ\n\nPR 歓迎！コードベースは意図的に小さく読みやすくしています。🤗\n\n完全な[コミュニティロードマップ](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)をご覧ください。\n\n開発者グループ構築中、最初の PR がマージされたら参加できます！\n\nユーザーグループ:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>\n\n  <h3>$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾，我们走！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**\n\n</div>\n\n---\n\n> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.\n\n🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.\n\n⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**\n>\n> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.\n>\n> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**\n> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.\n> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.\n> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.\n\n## 📢 News\n\n2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**!\n\n2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing.\n\n2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher.\n\n2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.\n\n<details>\n<summary>Older news...</summary>\n\n2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted.\n\n2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway.\n\n2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw，Let's Go！\n\n</details>\n\n## ✨ Features\n\n🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.*\n\n💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini.\n\n⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core.\n\n🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!\n\n🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.\n\n🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities.\n\n👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs.\n\n🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.\n\n_*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Language**                  | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **Startup**</br>(0.8GHz core) | >500s         | >30s                     | **<1s**                                   |\n| **Cost**                      | Mac Mini $599 | Most Linux SBC </br>~$50 | **Any Linux Board**</br>**As low as $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Demonstration\n\n### 🛠️ Standard Assistant Workflows\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Full-Stack Engineer</p></th>\n    <th><p align=\"center\">🗂️ Logging & Planning Management</p></th>\n    <th><p align=\"center\">🔎 Web Search & Learning</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Develop • Deploy • Scale</td>\n    <td align=\"center\">Schedule • Automate • Memory</td>\n    <td align=\"center\">Discovery • Insights • Trends</td>\n  </tr>\n</table>\n\n### 📱 Run on old Android Phones\n\nGive your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:\n\n1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play).\n2. **Execute cmds**\n\n```bash\n# Download the latest release from https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nAnd then follow the instructions in the \"Quick Start\" section to complete the configuration!\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Innovative Low-Footprint Deploy\n\nPicoClaw can be deployed on almost any Linux device!\n\n- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 More Deployment Cases Await！\n\n## 📦 Install\n\n### Install with precompiled binary\n\nDownload the binary for your platform from the [Releases](https://github.com/sipeed/picoclaw/releases) page.\n\n### Install from source (latest features, recommended for development)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Build, no need to install\nmake build\n\n# Build for multiple platforms\nmake build-all\n\n# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)\nmake build-pi-zero\n\n# Build And Install\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both.\n\n## 📚 Documentation\n\nFor detailed guides, see the docs below. The README covers quick start only.\n\n```bash\n# 1. Clone this repo\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. First run — auto-generates docker/data/config.json then exits\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# The container prints \"First-run setup complete.\" and stops.\n\n# 3. Set your API keys\nvim docker/data/config.json   # Set provider API keys, bot tokens, etc.\n\n# 4. Start\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.\n\n```bash\n# 5. Check logs\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. Stop\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Launcher Mode (Web Console)\n\nThe `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nOpen http://localhost:18800 in your browser. The launcher manages the gateway process automatically.\n\n> [!WARNING]\n> The web console does not yet support authentication. Avoid exposing it to the public internet.\n\n### Agent Mode (One-shot)\n\n```bash\n# Ask a question\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"What is 2+2?\"\n\n# Interactive mode\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### Update\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n### 🚀 Quick Start\n\n> [!TIP]\n> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).\n\n**1. Initialize**\n\n```bash\npicoclaw onboard\n```\n\n**2. Configure** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.\n> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).\n\n**3. Get API Keys**\n\n* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Web Search** (optional):\n  * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)\n  * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface\n  * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)\n  * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)\n  * DuckDuckGo - Built-in fallback (no API key required)\n\n> **Note**: See `config.example.json` for a complete configuration template.\n\n**4. Chat**\n\n```bash\npicoclaw agent -m \"What is 2+2?\"\n```\n\nThat's it! You have a working AI assistant in 2 minutes.\n\n---\n\n## 💬 Chat Apps\n\nTalk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom\n\n> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.\n\n| Channel      | Setup                              |\n| ------------ | ---------------------------------- |\n| **Telegram** | Easy (just a token)                |\n| **Discord**  | Easy (bot token + intents)         |\n| **WhatsApp** | Easy (native: QR scan; or bridge URL) |\n| **Matrix**   | Medium (homeserver + bot access token) |\n| **QQ**       | Easy (AppID + AppSecret)           |\n| **DingTalk** | Medium (app credentials)           |\n| **LINE**     | Medium (credentials + webhook URL) |\n| **WeCom AI Bot** | Medium (Token + AES key)       |\n\n<details>\n<summary><b>Telegram</b> (Recommended)</summary>\n\n**1. Create a bot**\n\n* Open Telegram, search `@BotFather`\n* Send `/newbot`, follow prompts\n* Copy the token\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> Get your user ID from `@userinfobot` on Telegram.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n**4. Telegram command menu (auto-registered at startup)**\n\nPicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.\nTelegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.\n\nIf command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Create a bot**\n\n* Go to <https://discord.com/developers/applications>\n* Create an application → Bot → Add Bot\n* Copy the bot token\n\n**2. Enable intents**\n\n* In the Bot settings, enable **MESSAGE CONTENT INTENT**\n* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data\n\n**3. Get your User ID**\n* Discord Settings → Advanced → enable **Developer Mode**\n* Right-click your avatar → **Copy User ID**\n\n**4. Configure**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Invite the bot**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* Open the generated invite URL and add the bot to your server\n\n**Optional: Group trigger mode**\n\nBy default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nYou can also trigger by keyword prefixes (e.g. `!bot`):\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. Run**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b> (native via whatsmeow)</summary>\n\nPicoClaw can connect to WhatsApp in two ways:\n\n- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `\"use_native\": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).\n- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.\n\n**Configure (native)**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\nIf `session_store_path` is empty, the session is stored in `&lt;workspace&gt;/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Create a bot**\n\n- Go to [QQ Open Platform](https://q.qq.com/#)\n- Create an application → Get **AppID** and **AppSecret**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Create a bot**\n\n* Go to [Open Platform](https://open.dingtalk.com/)\n* Create an internal app\n* Copy Client ID and Client Secret\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Prepare bot account**\n\n* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)\n* Create a bot user and obtain its access token\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\nFor full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. Create a LINE Official Account**\n\n- Go to [LINE Developers Console](https://developers.line.biz/)\n- Create a provider → Create a Messaging API channel\n- Copy **Channel Secret** and **Channel Access Token**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).\n\n**3. Set up Webhook URL**\n\nLINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:\n\n```bash\n# Example with ngrok (gateway default port is 18790)\nngrok http 18790\n```\n\nThen set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.\n\n**4. Run**\n\n```bash\npicoclaw gateway\n```\n\n> In group chats, the bot responds only when @mentioned. Replies quote the original message.\n\n</details>\n\n<details>\n<summary><b>WeCom (企业微信)</b></summary>\n\nPicoClaw supports three types of WeCom integration:\n\n**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats\n**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only\n**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat\n\nSee [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.\n\n**Quick Setup - WeCom AI Bot:**\n\n**1. Create an AI Bot**\n\n* Go to WeCom Admin Console → AI Bot\n* Create a new AI Bot → Set name, avatar, etc.\n* Copy **Bot ID** and **Secret**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"bot_id\": \"YOUR_BOT_ID\",\n      \"secret\": \"YOUR_SECRET\",\n      \"allow_from\": [],\n      \"welcome_message\": \"Hello! How can I help you?\"\n    }\n  }\n}\n```\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.\n\n</details>\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Join the Agent Social Network\n\nConnect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.\n\n**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ CLI Reference\n\n| Command                   | Description                   |\n| ------------------------- | ----------------------------- |\n| `picoclaw onboard`        | Initialize config & workspace |\n| `picoclaw agent -m \"...\"` | Chat with the agent           |\n| `picoclaw agent`          | Interactive chat mode         |\n| `picoclaw gateway`        | Start the gateway             |\n| `picoclaw status`         | Show status                   |\n| `picoclaw version`        | Show version info             |\n| `picoclaw cron list`      | List all scheduled jobs       |\n| `picoclaw cron add ...`   | Add a scheduled job           |\n| `picoclaw cron disable`   | Disable a scheduled job       |\n| `picoclaw cron remove`    | Remove a scheduled job        |\n| `picoclaw skills list`    | List installed skills         |\n| `picoclaw skills install` | Install a skill               |\n| `picoclaw migrate`        | Migrate data from older versions |\n| `picoclaw auth login`     | Authenticate with providers   |\n\n### Scheduled Tasks / Reminders\n\nPicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:\n\n* **One-time reminders**: \"Remind me in 10 minutes\" → triggers once after 10min\n* **Recurring tasks**: \"Remind me every 2 hours\" → triggers every 2 hours\n* **Cron expressions**: \"Remind me at 9am daily\" → uses cron expression\n\n## 🤝 Contribute & Roadmap\n\nPRs welcome! The codebase is intentionally small and readable. 🤗\n\nSee our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).\n\nDeveloper group building, join after your first merged PR!\n\nUser Groups:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.pt-br.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>\n\n  <h3>Hardware de $10 · <10MB de RAM · Boot em <1s · 皮皮虾，我们走！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.\n\n🦐 PicoClaw é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em Go por meio de um processo de auto-inicialização (self-bootstrapping), onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.\n\n⚡️ Roda em hardware de $10 com <10MB de RAM: Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**\n>\n> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.\n>\n> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**\n> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros.\n> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.\n> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10–20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.\n\n## 📢 Novidades\n\n2026-03-17 🚀 **v0.2.3 Lançado!** Interface de bandeja do sistema (Windows & Linux), rastreamento de status de sub-agentes (`spawn_status`), hot-reload experimental do gateway, portões de segurança para cron e 2 correções de segurança. PicoClaw agora com **25K ⭐**!\n\n2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos canais (Matrix/IRC/WeCom/Discord Proxy), 3 novos provedores (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL e roteamento de modelos.\n\n2026-02-28 📦 **v0.2.0** lançado com suporte a Docker Compose e launcher Web UI.\n\n2026-02-26 🎉 PicoClaw atingiu **20K stars** em apenas 17 dias! Orquestração automática de canais e interfaces de capacidade implementadas.\n\n<details>\n<summary>Novidades anteriores...</summary>\n\n2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Papéis de maintainers da comunidade e [roadmap](ROADMAP.md) publicados oficialmente.\n\n2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Roadmap do Projeto e Grupo de Desenvolvedores em preparação.\n\n2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!\n\n</details>\n\n## ✨ Funcionalidades\n\n🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o OpenClaw para funcionalidades essenciais.*\n\n💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.\n\n⚡️ **Inicialização Relâmpago**: Tempo de inicialização 400X mais rápido, boot em <1 segundo mesmo em CPU single-core de 0.6GHz.\n\n🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!\n\n🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.\n\n🔌 **Suporte MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do agente.\n\n👁️ **Pipeline de Visão**: Envie imagens e arquivos diretamente ao agente — codificação base64 automática para LLMs multimodais.\n\n🧠 **Roteamento Inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.\n\n_*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalidades. Otimização de recursos está planejada. Comparação de inicialização baseada em benchmarks de single-core a 0.8GHz (veja tabela abaixo)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Linguagem**                 | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **Inicialização**</br>(CPU 0.8GHz) | >500s         | >30s                     | **<1s**                                   |\n| **Custo**                     | Mac Mini $599 | Maioria dos SBC Linux </br>~$50 | **Qualquer placa Linux**</br>**A partir de $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Demonstração\n\n### 🛠️ Fluxos de Trabalho Padrão do Assistente\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Engenharia Full-Stack</p></th>\n    <th><p align=\"center\">🗂️ Gerenciamento de Logs & Planejamento</p></th>\n    <th><p align=\"center\">🔎 Busca Web & Aprendizado</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Desenvolver • Implantar • Escalar</td>\n    <td align=\"center\">Agendar • Automatizar • Memorizar</td>\n    <td align=\"center\">Descobrir • Analisar • Tendências</td>\n  </tr>\n</table>\n\n### 📱 Rode em celulares Android antigos\n\nDê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:\n\n1. **Instale o [Termux](https://github.com/termux/termux-app)** (Baixe em [GitHub Releases](https://github.com/termux/termux-app/releases), ou busque no F-Droid / Google Play).\n2. **Execute os comandos**\n\n```bash\n# Baixe a versão mais recente em https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nDepois siga as instruções na seção \"Início Rápido\" para completar a configuração!\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Implantação Inovadora com Baixo Consumo\n\nO PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!\n\n- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E(Ethernet) ou W(WiFi6), para Assistente Doméstico Minimalista\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 Mais cenários de implantação aguardam você!\n\n## 📦 Instalação\n\n### Instalar com binário pré-compilado\n\nBaixe o binário para sua plataforma na página de [Releases](https://github.com/sipeed/picoclaw/releases).\n\n### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Build, sem necessidade de instalar\nmake build\n\n# Build para múltiplas plataformas\nmake build-all\n\n# Build para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)\nmake build-pi-zero\n\n# Build e Instalar\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** Use o binário correspondente ao seu SO: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.\n\n## 📚 Documentação\n\nPara guias detalhados, consulte a documentação abaixo. Este README cobre apenas o início rápido.\n\n| Tópico | Descrição |\n|--------|-----------|\n| 🐳 [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração Docker Compose, modos Launcher/Agent, configuração de Início Rápido |\n| 💬 [Apps de Chat](docs/pt-br/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e mais |\n| ⚙️ [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, estrutura do workspace, fontes de skills, sandbox de segurança, heartbeat |\n| 🔌 [Provedores & Modelos](docs/pt-br/providers.md) | 20+ provedores LLM, roteamento de modelos, configuração model_list, arquitetura de provedores |\n| 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes |\n| 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |\n| 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Junte-se à Rede Social de Agentes\n\nConecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.\n\n**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ Referência CLI\n\n| Comando                   | Descrição                     |\n| ------------------------- | ----------------------------- |\n| `picoclaw onboard`        | Inicializar configuração & workspace |\n| `picoclaw agent -m \"...\"` | Conversar com o agente        |\n| `picoclaw agent`          | Modo de chat interativo       |\n| `picoclaw gateway`        | Iniciar o gateway             |\n| `picoclaw status`         | Mostrar status                |\n| `picoclaw version`        | Mostrar informações de versão |\n| `picoclaw cron list`      | Listar todas as tarefas agendadas |\n| `picoclaw cron add ...`   | Adicionar uma tarefa agendada |\n| `picoclaw cron disable`   | Desabilitar uma tarefa agendada |\n| `picoclaw cron remove`    | Remover uma tarefa agendada   |\n| `picoclaw skills list`    | Listar skills instaladas      |\n| `picoclaw skills install` | Instalar uma skill            |\n| `picoclaw migrate`        | Migrar dados de versões anteriores |\n| `picoclaw auth login`     | Autenticar com provedores     |\n\n### Tarefas Agendadas / Lembretes\n\nO PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:\n\n* **Lembretes únicos**: \"Me lembre em 10 minutos\" → dispara uma vez após 10min\n* **Tarefas recorrentes**: \"Me lembre a cada 2 horas\" → dispara a cada 2 horas\n* **Expressões Cron**: \"Me lembre às 9h todos os dias\" → usa expressão cron\n\n## 🤝 Contribuir & Roadmap\n\nPRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗\n\nVeja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completo.\n\nGrupo de desenvolvedores em formação. Junte-se após seu primeiro PR com merge!\n\nGrupos de usuários:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.vi.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n  <h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>\n\n  <h3>Phần cứng $10 · <10MB RAM · Khởi động <1 giây · Nào, xuất phát!</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.\n\n🦐 PicoClaw là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng Go thông qua quá trình \"tự khởi tạo\" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.\n\n⚡️ Chạy trên phần cứng chỉ $10 với RAM <10MB: Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n      </p>\n    </td>\n    <td align=\"center\" valign=\"top\">\n      <p align=\"center\">\n        <img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n      </p>\n    </td>\n  </tr>\n</table>\n\n> [!CAUTION]\n> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**\n>\n> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.\n>\n> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**\n> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký.\n> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.\n> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.\n\n## 📢 Tin tức\n\n2026-03-17 🚀 **v0.2.3 Phát hành!** Giao diện khay hệ thống (Windows & Linux), theo dõi trạng thái sub-agent (`spawn_status`), hot-reload gateway thử nghiệm, cổng bảo mật cron và 2 bản vá bảo mật. PicoClaw đạt **25K ⭐**!\n\n2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất!** Hỗ trợ giao thức MCP, 4 kênh mới (Matrix/IRC/WeCom/Discord Proxy), 3 nhà cung cấp mới (Kimi/Minimax/Avian), pipeline xử lý hình ảnh, bộ nhớ JSONL và định tuyến mô hình.\n\n2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và launcher Web UI.\n\n2026-02-26 🎉 PicoClaw đạt **20K stars** chỉ trong 17 ngày! Tự động điều phối kênh và giao diện năng lực đã được triển khai.\n\n<details>\n<summary>Tin tức cũ hơn...</summary>\n\n2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Vai trò maintainer cộng đồng và [roadmap](ROADMAP.md) đã được công bố chính thức.\n\n2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Lộ trình dự án và Nhóm phát triển đang được thiết lập.\n\n2026-02-09 🎉 **PicoClaw chính thức ra mắt!** Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!\n\n</details>\n\n## ✨ Tính năng nổi bật\n\n🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với OpenClaw (chức năng cốt lõi).*\n\n💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.\n\n⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong <1 giây ngay cả trên CPU đơn nhân 0.6GHz.\n\n🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!\n\n🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.\n\n🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ máy chủ MCP nào để mở rộng khả năng của agent.\n\n👁️ **Pipeline Xử lý Hình ảnh**: Gửi hình ảnh và tệp trực tiếp cho agent — tự động mã hóa base64 cho các LLM đa phương thức.\n\n🧠 **Định tuyến Thông minh**: Định tuyến mô hình dựa trên quy tắc — truy vấn đơn giản chuyển đến mô hình nhẹ, tiết kiệm chi phí API.\n\n_*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính năng nhanh chóng. Tối ưu tài nguyên đang được lên kế hoạch. So sánh thời gian khởi động dựa trên benchmark đơn nhân 0.8GHz (xem bảng bên dưới)._\n\n|                               | OpenClaw      | NanoBot                  | **PicoClaw**                              |\n| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |\n| **Ngôn ngữ**                  | TypeScript    | Python                   | **Go**                                    |\n| **RAM**                       | >1GB          | >100MB                   | **< 10MB***                               |\n| **Thời gian khởi động**</br>(CPU 0.8GHz) | >500s         | >30s                     | **<1s**                                   |\n| **Chi phí**                   | Mac Mini $599 | Hầu hết SBC Linux ~$50  | **Mọi bo mạch Linux**</br>**Chỉ từ $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 Demo\n\n### 🛠️ Quy trình trợ lý tiêu chuẩn\n\n<table align=\"center\">\n  <tr align=\"center\">\n    <th><p align=\"center\">🧩 Lập trình Full-Stack</p></th>\n    <th><p align=\"center\">🗂️ Quản lý Nhật ký & Kế hoạch</p></th>\n    <th><p align=\"center\">🔎 Tìm kiếm Web & Học hỏi</p></th>\n  </tr>\n  <tr>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n    <td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n  </tr>\n  <tr>\n    <td align=\"center\">Phát triển • Triển khai • Mở rộng</td>\n    <td align=\"center\">Lên lịch • Tự động hóa • Ghi nhớ</td>\n    <td align=\"center\">Khám phá • Phân tích • Xu hướng</td>\n  </tr>\n</table>\n\n### 📱 Chạy trên điện thoại Android cũ\n\nHãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thành trợ lý AI thông minh với PicoClaw. Bắt đầu nhanh:\n\n1. **Cài đặt [Termux](https://github.com/termux/termux-app)** (Tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm trên F-Droid / Google Play).\n2. **Chạy các lệnh**\n\n```bash\n# Tải phiên bản mới nhất từ https://github.com/sipeed/picoclaw/releases\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\nSau đó làm theo hướng dẫn trong phần \"Bắt đầu nhanh\" để hoàn tất cấu hình!\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu\n\nPicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!\n\n- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), dùng làm Trợ lý Gia đình tối giản\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) dùng cho quản trị Server tự động\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) dùng cho Giám sát thông minh\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!\n\n## 📦 Cài đặt\n\n### Cài đặt bằng binary biên dịch sẵn\n\nTải file binary cho nền tảng của bạn từ [trang Releases](https://github.com/sipeed/picoclaw/releases).\n\n### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# Build (không cần cài đặt)\nmake build\n\n# Build cho nhiều nền tảng\nmake build-all\n\n# Build cho Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)\nmake build-pi-zero\n\n# Build và cài đặt\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để build cả hai.\n\n## 📚 Tài liệu\n\nĐể xem hướng dẫn chi tiết, tham khảo tài liệu bên dưới. README này chỉ bao gồm phần bắt đầu nhanh.\n\n| Chủ đề | Mô tả |\n|--------|-------|\n| 🐳 [Docker & Bắt đầu nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent, cấu hình Bắt đầu nhanh |\n| 💬 [Ứng dụng Chat](docs/vi/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom và nhiều hơn |\n| ⚙️ [Cấu hình](docs/vi/configuration.md) | Biến môi trường, cấu trúc workspace, nguồn skill, sandbox bảo mật, heartbeat |\n| 🔌 [Nhà cung cấp & Mô hình](docs/vi/providers.md) | 20+ nhà cung cấp LLM, định tuyến mô hình, cấu hình model_list, kiến trúc nhà cung cấp |\n| 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |\n| 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |\n| 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> Tham gia Mạng xã hội Agent\n\nKết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.\n\n**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ Tham chiếu CLI\n\n| Lệnh                      | Mô tả                         |\n| -------------------------- | ------------------------------ |\n| `picoclaw onboard`         | Khởi tạo cấu hình & workspace |\n| `picoclaw agent -m \"...\"`  | Trò chuyện với agent           |\n| `picoclaw agent`           | Chế độ chat tương tác          |\n| `picoclaw gateway`         | Khởi động gateway              |\n| `picoclaw status`          | Hiển thị trạng thái            |\n| `picoclaw version`         | Hiển thị thông tin phiên bản   |\n| `picoclaw cron list`       | Liệt kê tất cả tác vụ định kỳ |\n| `picoclaw cron add ...`    | Thêm tác vụ định kỳ           |\n| `picoclaw cron disable`    | Tắt tác vụ định kỳ            |\n| `picoclaw cron remove`     | Xóa tác vụ định kỳ            |\n| `picoclaw skills list`     | Liệt kê các skill đã cài      |\n| `picoclaw skills install`  | Cài đặt một skill              |\n| `picoclaw migrate`         | Di chuyển dữ liệu từ phiên bản cũ |\n| `picoclaw auth login`      | Xác thực với nhà cung cấp     |\n\n### Tác vụ định kỳ / Nhắc nhở\n\nPicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:\n\n* **Nhắc nhở một lần**: \"Nhắc tôi sau 10 phút\" → kích hoạt một lần sau 10 phút\n* **Tác vụ lặp lại**: \"Nhắc tôi mỗi 2 giờ\" → kích hoạt mỗi 2 giờ\n* **Biểu thức Cron**: \"Nhắc tôi lúc 9 giờ sáng mỗi ngày\" → sử dụng biểu thức cron\n\n## 🤝 Đóng góp & Lộ trình\n\nChào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗\n\nXem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) đầy đủ.\n\nNhóm phát triển đang được xây dựng. Tham gia sau khi có PR đầu tiên được merge!\n\nNhóm người dùng:\n\ndiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "README.zh.md",
    "content": "<div align=\"center\">\n<img src=\"assets/logo.webp\" alt=\"PicoClaw\" width=\"512\">\n\n<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>\n\n<h3>$10 硬件 · <10MB 内存 · <1s 启动 · 皮皮虾，我们走！</h3>\n  <p>\n    <img src=\"https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white\" alt=\"Go\">\n    <img src=\"https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue\" alt=\"Hardware\">\n    <img src=\"https://img.shields.io/badge/license-MIT-green\" alt=\"License\">\n    <br>\n    <a href=\"https://picoclaw.io\"><img src=\"https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white\" alt=\"Website\"></a>\n    <a href=\"https://docs.picoclaw.io/\"><img src=\"https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white\" alt=\"Docs\"></a>\n    <a href=\"https://deepwiki.com/sipeed/picoclaw\"><img src=\"https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white\" alt=\"Wiki\"></a>\n    <br>\n    <a href=\"https://x.com/SipeedIO\"><img src=\"https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white\" alt=\"Twitter\"></a>\n    <a href=\"./assets/wechat.png\"><img src=\"https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white\"></a>\n    <a href=\"https://discord.gg/V4sAZ9XWpN\"><img src=\"https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white\" alt=\"Discord\"></a>\n  </p>\n\n**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)\n\n</div>\n\n---\n\n> **PicoClaw** 是由 [矽速科技 (Sipeed)](https://sipeed.com) 发起的独立开源项目，完全使用 **Go 语言**从零编写——不是 OpenClaw、NanoBot 或其他项目的分支。\n\n🦐 **PicoClaw** 是一个受 [NanoBot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构，经历了一个\"自举\"过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。\n\n⚡️ **极致轻量**：可在 **10 美元** 的硬件上运行，内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存，比 Mac mini 便宜 98%！\n\n<table align=\"center\">\n<tr align=\"center\">\n<td align=\"center\" valign=\"top\">\n<p align=\"center\">\n<img src=\"assets/picoclaw_mem.gif\" width=\"360\" height=\"240\">\n</p>\n</td>\n<td align=\"center\" valign=\"top\">\n<p align=\"center\">\n<img src=\"assets/licheervnano.png\" width=\"400\" height=\"240\">\n</p>\n</td>\n</tr>\n</table>\n\n> [!CAUTION]\n> **🚨 安全声明**\n>\n> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。\n> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**，公司官网是 **[sipeed.com](https://sipeed.com)**。\n> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注，请勿轻信。\n> - **注意:** PicoClaw 正在初期的快速功能开发阶段，可能有尚未修复的网络安全问题，在 1.0 正式版发布前，请不要将其部署到生产环境中。\n> - **注意:** PicoClaw 最近合并了大量 PR，近期版本可能内存占用较大 (10~20MB)，我们将在功能较为收敛后进行资源占用优化。\n\n## 📢 新闻\n\n2026-03-17 🚀 **v0.2.3 发布！** 系统托盘 UI（Windows & Linux）、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控，以及 2 项安全修复。PicoClaw 已达 **25K ⭐**！\n\n2026-03-09 🎉 **v0.2.1 — 史上最大更新！** MCP 协议支持、4 个新频道 (Matrix/IRC/WeCom/Discord Proxy)、3 个新 Provider (Kimi/Minimax/Avian)、视觉管线、JSONL 记忆存储、模型路由。\n\n2026-02-28 📦 **v0.2.0** 发布，支持 Docker Compose 和 Web UI 启动器。\n\n2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**！频道自动编排和能力接口上线。\n\n<details>\n<summary>更早的新闻...</summary>\n\n2026-02-16 🎉 PicoClaw 一周内突破 12K Stars！社区维护者角色和 [路线图](ROADMAP.md) 正式发布。\n\n2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars！项目路线图和开发者群组筹建中。\n\n2026-02-09 🎉 **PicoClaw 正式发布！** 仅用 1 天构建，将 AI Agent 带入 $10 硬件与 <10MB 内存的世界。🦐 皮皮虾，我们走！\n\n</details>\n\n## ✨ 特性\n\n🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 OpenClaw 小 99%。*\n\n💰 **极低成本**: 高效到足以在 $10 的硬件上运行 — 比 Mac mini 便宜 98%。\n\n⚡️ **闪电启动**: 启动速度快 400 倍，即使在 0.6GHz 单核处理器上也能在 1 秒内启动。\n\n🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件，一键运行！\n\n🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成，并经由\"人机回环\"微调。\n\n🔌 **MCP 支持**: 原生 [Model Context Protocol](https://modelcontextprotocol.io/) 集成 — 连接任意 MCP 服务器扩展 Agent 能力。\n\n👁️ **视觉管线**: 直接向 Agent 发送图片和文件 — 自动 base64 编码对接多模态 LLM。\n\n🧠 **智能路由**: 基于规则的模型路由 — 简单查询走轻量模型，节省 API 成本。\n\n_*近期版本因快速合并 PR 可能占用 10–20MB，资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测（见下方对比表）。_\n\n|                                | OpenClaw      | NanoBot                  | **PicoClaw**                           |\n| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |\n| **语言**                       | TypeScript    | Python                   | **Go**                                 |\n| **RAM**                        | >1GB          | >100MB                   | **< 10MB***                            |\n| **启动时间**</br>(0.8GHz core) | >500s         | >30s                     | **<1s**                                |\n| **成本**                       | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |\n\n<img src=\"assets/compare.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n## 🦾 演示\n\n### 🛠️ 标准助手工作流\n\n<table align=\"center\">\n<tr align=\"center\">\n<th><p align=\"center\">🧩 全栈工程师模式</p></th>\n<th><p align=\"center\">🗂️ 日志与规划管理</p></th>\n<th><p align=\"center\">🔎 网络搜索与学习</p></th>\n</tr>\n<tr>\n<td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_code.gif\" width=\"240\" height=\"180\"></p></td>\n<td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_memory.gif\" width=\"240\" height=\"180\"></p></td>\n<td align=\"center\"><p align=\"center\"><img src=\"assets/picoclaw_search.gif\" width=\"240\" height=\"180\"></p></td>\n</tr>\n<tr>\n<td align=\"center\">开发 • 部署 • 扩展</td>\n<td align=\"center\">日程 • 自动化 • 记忆</td>\n<td align=\"center\">发现 • 洞察 • 趋势</td>\n</tr>\n</table>\n\n### 📱 在手机上轻松运行\n\nPicoClaw 可以将你 10 年前的老旧手机废物利用，变身成为你的 AI 助理！快速指南：\n\n1. 安装 [Termux](https://github.com/termux/termux-app)（可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载，或在 F-Droid 等应用商店搜索）\n2. 打开后执行指令\n\n```bash\n# 从 Release 页面下载最新版本\nwget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz\ntar xzf picoclaw_Linux_arm64.tar.gz\npkg install proot\ntermux-chroot ./picoclaw onboard\n```\n\n然后跟随下面的\"快速开始\"章节继续配置 PicoClaw 即可使用！\n\n<img src=\"assets/termux.jpg\" alt=\"PicoClaw\" width=\"512\">\n\n### 🐜 创新的低占用部署\n\nPicoClaw 几乎可以部署在任何 Linux 设备上！\n\n- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本，用于极简家庭助手\n- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html)，或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html)，用于自动化服务器运维\n- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera)，用于智能监控\n\n<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>\n\n🌟 更多部署案例敬请期待！\n\n## 📦 安装\n\n### 使用预编译二进制文件安装\n\n从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。\n\n### 从源码安装（获取最新特性，开发推荐）\n\n```bash\ngit clone https://github.com/sipeed/picoclaw.git\n\ncd picoclaw\nmake deps\n\n# 构建（无需安装）\nmake build\n\n# 为多平台构建\nmake build-all\n\n# 为 Raspberry Pi Zero 2 W 构建（32位: make build-linux-arm; 64位: make build-linux-arm64）\nmake build-pi-zero\n\n# 构建并安装\nmake install\n```\n\n**Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件：32 位 Raspberry Pi OS → `make build-linux-arm`；64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。\n\n## 📚 文档\n\n详细指南请参阅以下文档，README 仅涵盖快速入门。\n\n| 主题 | 说明 |\n|------|------|\n| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |\n| 💬 [聊天应用配置](docs/zh/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、钉钉、LINE、飞书、企业微信等 |\n| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、技能来源、安全沙箱、心跳任务 |\n| 🔌 [提供商与模型配置](docs/zh/providers.md) | 20+ LLM 提供商、模型路由、model_list 配置、Provider 架构 |\n| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |\n| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |\n| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 |\n\n## <img src=\"assets/clawdchat-icon.png\" width=\"24\" height=\"24\" alt=\"ClawdChat\"> 加入 Agent 社交网络\n\n通过 CLI 或任何已集成的聊天应用发送一条消息，即可将 PicoClaw 连接到 Agent 社交网络。\n\n**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)**\n\n## 🖥️ CLI 命令行参考\n\n| 命令                       | 说明                   |\n| ------------------------- | ---------------------- |\n| `picoclaw onboard`        | 初始化配置与工作区       |\n| `picoclaw agent -m \"...\"` | 与 Agent 对话           |\n| `picoclaw agent`          | 交互式对话模式           |\n| `picoclaw gateway`        | 启动网关                |\n| `picoclaw status`         | 查看状态                |\n| `picoclaw version`        | 查看版本信息             |\n| `picoclaw cron list`      | 列出所有定时任务         |\n| `picoclaw cron add ...`   | 添加定时任务             |\n| `picoclaw cron disable`   | 禁用定时任务             |\n| `picoclaw cron remove`    | 删除定时任务             |\n| `picoclaw skills list`    | 列出已安装技能           |\n| `picoclaw skills install` | 安装技能                |\n| `picoclaw migrate`        | 从旧版本迁移数据         |\n| `picoclaw auth login`     | 认证提供商               |\n\n### 定时任务 / 提醒\n\nPicoClaw 通过 `cron` 工具支持定时提醒和重复任务：\n\n* **一次性提醒**: \"10分钟后提醒我\" → 10分钟后触发一次\n* **重复任务**: \"每2小时提醒我\" → 每2小时触发\n* **Cron 表达式**: \"每天上午9点提醒我\" → 使用 cron 表达式\n\n## 🤝 贡献与路线图\n\n欢迎提交 PR！代码库刻意保持小巧和可读。🤗\n\n查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)。\n\n开发者群组正在组建中，入群门槛：至少合并过 1 个 PR。\n\n用户群组：\n\nDiscord: <https://discord.gg/V4sAZ9XWpN>\n\n<img src=\"assets/wechat.png\" alt=\"PicoClaw\" width=\"512\">\n"
  },
  {
    "path": "ROADMAP.md",
    "content": "\n# 🦐 PicoClaw Roadmap\n\n> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity\n\n---\n\n## 🚀 1. Core Optimization: Extreme Lightweight\n\n*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*\n\n* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346) \n  * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.\n  * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.\n  * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.\n\n\n## 🛡️ 2. Security Hardening: Defense in Depth\n\n*Paying off early technical debt. We invite security experts to help build a \"Secure-by-Default\" agent.*\n\n* **Input Defense & Permission Control**\n  * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.\n  * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.\n  * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).\n\n\n* **Sandboxing & Isolation**\n  * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.\n  * **Context Isolation**: Prevent data leakage between different user sessions or channels.\n  * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.\n\n\n* **Authentication & Secrets**\n  * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.\n  * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.\n\n\n\n## 🔌 3. Connectivity: Protocol-First Architecture\n\n*Connect every model, reach every platform.*\n\n* **Provider**\n  * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from \"Vendor-based\" to \"Protocol-based\" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*\n  * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).\n  * **Online Models**: Continued support for frontier closed-source models.\n\n\n* **Channel**\n  * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...\n  * **Standards**: Support for the **OneBot** protocol.\n  * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.\n\n\n* **Skill Marketplace**\n  * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.\n\n\n\n## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI\n\n*Beyond conversation—focusing on action and collaboration.*\n\n* **Operations**\n  * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.\n  * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.\n  * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).\n\n\n* **Multi-Agent Collaboration**\n  * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement\n  * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): \"Smart Routing\" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).\n  * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.\n  * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.\n\n\n\n## 📚 5. Developer Experience (DevEx) & Documentation\n\n*Lowering the barrier to entry so anyone can deploy in minutes.*\n\n* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)\n  * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.\n\n\n* **Comprehensive Documentation**\n  * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.\n  * **Step-by-Step Tutorials**: \"Babysitter-level\" guides for configuring Providers and Channels.\n  * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).\n\n\n\n## 🤖 6. Engineering: AI-Powered Open Source\n\n*Born from Vibe Coding, we continue to use AI to accelerate development.*\n\n* **AI-Enhanced CI/CD**\n  * Integrate AI for automated Code Review, Linting, and PR Labeling.\n  * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.\n  * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.\n\n\n\n## 🎨 7. Brand & Community\n\n* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!\n  * *Concept*: Needs to reflect \"Small but Mighty\" and \"Lightning Fast Strikes.\"\n\n\n\n---\n\n### 🤝 Call for Contributions\n\nWe welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!"
  },
  {
    "path": "cmd/picoclaw/internal/agent/command.go",
    "content": "package agent\n\nimport (\n\t\"github.com/spf13/cobra\"\n)\n\nfunc NewAgentCommand() *cobra.Command {\n\tvar (\n\t\tmessage    string\n\t\tsessionKey string\n\t\tmodel      string\n\t\tdebug      bool\n\t)\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"agent\",\n\t\tShort: \"Interact with the agent directly\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn agentCmd(message, sessionKey, model, debug)\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&debug, \"debug\", \"d\", false, \"Enable debug logging\")\n\tcmd.Flags().StringVarP(&message, \"message\", \"m\", \"\", \"Send a single message (non-interactive mode)\")\n\tcmd.Flags().StringVarP(&sessionKey, \"session\", \"s\", \"cli:default\", \"Session key\")\n\tcmd.Flags().StringVarP(&model, \"model\", \"\", \"\", \"Model to use\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/agent/command_test.go",
    "content": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewAgentCommand(t *testing.T) {\n\tcmd := NewAgentCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"agent\", cmd.Use)\n\tassert.Equal(t, \"Interact with the agent directly\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 0)\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.True(t, cmd.HasFlags())\n\n\tassert.NotNil(t, cmd.Flags().Lookup(\"debug\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"message\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"session\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"model\"))\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/agent/helpers.go",
    "content": "package agent\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/ergochat/readline\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/agent\"\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nfunc agentCmd(message, sessionKey, model string, debug bool) error {\n\tif sessionKey == \"\" {\n\t\tsessionKey = \"cli:default\"\n\t}\n\n\tif debug {\n\t\tlogger.SetLevel(logger.DEBUG)\n\t\tfmt.Println(\"🔍 Debug mode enabled\")\n\t}\n\n\tcfg, err := internal.LoadConfig()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error loading config: %w\", err)\n\t}\n\n\tif model != \"\" {\n\t\tcfg.Agents.Defaults.ModelName = model\n\t}\n\n\tprovider, modelID, err := providers.CreateProvider(cfg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating provider: %w\", err)\n\t}\n\n\t// Use the resolved model ID from provider creation\n\tif modelID != \"\" {\n\t\tcfg.Agents.Defaults.ModelName = modelID\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tdefer msgBus.Close()\n\tagentLoop := agent.NewAgentLoop(cfg, msgBus, provider)\n\tdefer agentLoop.Close()\n\n\t// Print agent startup info (only for interactive mode)\n\tstartupInfo := agentLoop.GetStartupInfo()\n\tlogger.InfoCF(\"agent\", \"Agent initialized\",\n\t\tmap[string]any{\n\t\t\t\"tools_count\":      startupInfo[\"tools\"].(map[string]any)[\"count\"],\n\t\t\t\"skills_total\":     startupInfo[\"skills\"].(map[string]any)[\"total\"],\n\t\t\t\"skills_available\": startupInfo[\"skills\"].(map[string]any)[\"available\"],\n\t\t})\n\n\tif message != \"\" {\n\t\tctx := context.Background()\n\t\tresponse, err := agentLoop.ProcessDirect(ctx, message, sessionKey)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error processing message: %w\", err)\n\t\t}\n\t\tfmt.Printf(\"\\n%s %s\\n\", internal.Logo, response)\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"%s Interactive mode (Ctrl+C to exit)\\n\\n\", internal.Logo)\n\tinteractiveMode(agentLoop, sessionKey)\n\n\treturn nil\n}\n\nfunc interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {\n\tprompt := fmt.Sprintf(\"%s You: \", internal.Logo)\n\n\trl, err := readline.NewEx(&readline.Config{\n\t\tPrompt:          prompt,\n\t\tHistoryFile:     filepath.Join(os.TempDir(), \".picoclaw_history\"),\n\t\tHistoryLimit:    100,\n\t\tInterruptPrompt: \"^C\",\n\t\tEOFPrompt:       \"exit\",\n\t})\n\tif err != nil {\n\t\tfmt.Printf(\"Error initializing readline: %v\\n\", err)\n\t\tfmt.Println(\"Falling back to simple input mode...\")\n\t\tsimpleInteractiveMode(agentLoop, sessionKey)\n\t\treturn\n\t}\n\tdefer rl.Close()\n\n\tfor {\n\t\tline, err := rl.Readline()\n\t\tif err != nil {\n\t\t\tif err == readline.ErrInterrupt || err == io.EOF {\n\t\t\t\tfmt.Println(\"\\nGoodbye!\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Printf(\"Error reading input: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tinput := strings.TrimSpace(line)\n\t\tif input == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif input == \"exit\" || input == \"quit\" {\n\t\t\tfmt.Println(\"Goodbye!\")\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.Background()\n\t\tresponse, err := agentLoop.ProcessDirect(ctx, input, sessionKey)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfmt.Printf(\"\\n%s %s\\n\\n\", internal.Logo, response)\n\t}\n}\n\nfunc simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {\n\treader := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tfmt.Print(fmt.Sprintf(\"%s You: \", internal.Logo))\n\t\tline, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tfmt.Println(\"\\nGoodbye!\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Printf(\"Error reading input: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tinput := strings.TrimSpace(line)\n\t\tif input == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif input == \"exit\" || input == \"quit\" {\n\t\t\tfmt.Println(\"Goodbye!\")\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.Background()\n\t\tresponse, err := agentLoop.ProcessDirect(ctx, input, sessionKey)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Error: %v\\n\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfmt.Printf(\"\\n%s %s\\n\\n\", internal.Logo, response)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/command.go",
    "content": "package auth\n\nimport \"github.com/spf13/cobra\"\n\nfunc NewAuthCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"auth\",\n\t\tShort: \"Manage authentication (login, logout, status)\",\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn cmd.Help()\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\tnewLoginCommand(),\n\t\tnewLogoutCommand(),\n\t\tnewStatusCommand(),\n\t\tnewModelsCommand(),\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/command_test.go",
    "content": "package auth\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewAuthCommand(t *testing.T) {\n\tcmd := NewAuthCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"auth\", cmd.Use)\n\tassert.Equal(t, \"Manage authentication (login, logout, status)\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 0)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.False(t, cmd.HasFlags())\n\tassert.True(t, cmd.HasSubCommands())\n\n\tallowedCommands := []string{\n\t\t\"login\",\n\t\t\"logout\",\n\t\t\"status\",\n\t\t\"models\",\n\t}\n\n\tsubcommands := cmd.Commands()\n\tassert.Len(t, subcommands, len(allowedCommands))\n\n\tfor _, subcmd := range subcommands {\n\t\tfound := slices.Contains(allowedCommands, subcmd.Name())\n\t\tassert.True(t, found, \"unexpected subcommand %q\", subcmd.Name())\n\n\t\tassert.Len(t, subcmd.Aliases, 0)\n\t\tassert.False(t, subcmd.Hidden)\n\n\t\tassert.False(t, subcmd.HasSubCommands())\n\n\t\tassert.Nil(t, subcmd.Run)\n\t\tassert.NotNil(t, subcmd.RunE)\n\n\t\tassert.Nil(t, subcmd.PersistentPreRun)\n\t\tassert.Nil(t, subcmd.PersistentPostRun)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/helpers.go",
    "content": "package auth\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nconst (\n\tsupportedProvidersMsg = \"supported providers: openai, anthropic, google-antigravity\"\n\tdefaultAnthropicModel = \"claude-sonnet-4.6\"\n)\n\nfunc authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {\n\tswitch provider {\n\tcase \"openai\":\n\t\treturn authLoginOpenAI(useDeviceCode)\n\tcase \"anthropic\":\n\t\treturn authLoginAnthropic(useOauth)\n\tcase \"google-antigravity\", \"antigravity\":\n\t\treturn authLoginGoogleAntigravity()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported provider: %s (%s)\", provider, supportedProvidersMsg)\n\t}\n}\n\nfunc authLoginOpenAI(useDeviceCode bool) error {\n\tcfg := auth.OpenAIOAuthConfig()\n\n\tvar cred *auth.AuthCredential\n\tvar err error\n\n\tif useDeviceCode {\n\t\tcred, err = auth.LoginDeviceCode(cfg)\n\t} else {\n\t\tcred, err = auth.LoginBrowser(cfg)\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"login failed: %w\", err)\n\t}\n\n\tif err = auth.SetCredential(\"openai\", cred); err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\tappCfg, err := internal.LoadConfig()\n\tif err == nil {\n\t\t// Update Providers (legacy format)\n\t\tappCfg.Providers.OpenAI.AuthMethod = \"oauth\"\n\n\t\t// Update or add openai in ModelList\n\t\tfoundOpenAI := false\n\t\tfor i := range appCfg.ModelList {\n\t\t\tif isOpenAIModel(appCfg.ModelList[i].Model) {\n\t\t\t\tappCfg.ModelList[i].AuthMethod = \"oauth\"\n\t\t\t\tfoundOpenAI = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// If no openai in ModelList, add it\n\t\tif !foundOpenAI {\n\t\t\tappCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{\n\t\t\t\tModelName:  \"gpt-5.4\",\n\t\t\t\tModel:      \"openai/gpt-5.4\",\n\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t})\n\t\t}\n\n\t\t// Update default model to use OpenAI\n\t\tappCfg.Agents.Defaults.ModelName = \"gpt-5.4\"\n\n\t\tif err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {\n\t\t\treturn fmt.Errorf(\"could not update config: %w\", err)\n\t\t}\n\t}\n\n\tfmt.Println(\"Login successful!\")\n\tif cred.AccountID != \"\" {\n\t\tfmt.Printf(\"Account: %s\\n\", cred.AccountID)\n\t}\n\tfmt.Println(\"Default model set to: gpt-5.4\")\n\n\treturn nil\n}\n\nfunc authLoginGoogleAntigravity() error {\n\tcfg := auth.GoogleAntigravityOAuthConfig()\n\n\tcred, err := auth.LoginBrowser(cfg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"login failed: %w\", err)\n\t}\n\n\tcred.Provider = \"google-antigravity\"\n\n\t// Fetch user email from Google userinfo\n\temail, err := fetchGoogleUserEmail(cred.AccessToken)\n\tif err != nil {\n\t\tfmt.Printf(\"Warning: could not fetch email: %v\\n\", err)\n\t} else {\n\t\tcred.Email = email\n\t\tfmt.Printf(\"Email: %s\\n\", email)\n\t}\n\n\t// Fetch Cloud Code Assist project ID\n\tprojectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)\n\tif err != nil {\n\t\tfmt.Printf(\"Warning: could not fetch project ID: %v\\n\", err)\n\t\tfmt.Println(\"You may need Google Cloud Code Assist enabled on your account.\")\n\t} else {\n\t\tcred.ProjectID = projectID\n\t\tfmt.Printf(\"Project: %s\\n\", projectID)\n\t}\n\n\tif err = auth.SetCredential(\"google-antigravity\", cred); err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\tappCfg, err := internal.LoadConfig()\n\tif err == nil {\n\t\t// Update Providers (legacy format, for backward compatibility)\n\t\tappCfg.Providers.Antigravity.AuthMethod = \"oauth\"\n\n\t\t// Update or add antigravity in ModelList\n\t\tfoundAntigravity := false\n\t\tfor i := range appCfg.ModelList {\n\t\t\tif isAntigravityModel(appCfg.ModelList[i].Model) {\n\t\t\t\tappCfg.ModelList[i].AuthMethod = \"oauth\"\n\t\t\t\tfoundAntigravity = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// If no antigravity in ModelList, add it\n\t\tif !foundAntigravity {\n\t\t\tappCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{\n\t\t\t\tModelName:  \"gemini-flash\",\n\t\t\t\tModel:      \"antigravity/gemini-3-flash\",\n\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t})\n\t\t}\n\n\t\t// Update default model\n\t\tappCfg.Agents.Defaults.ModelName = \"gemini-flash\"\n\n\t\tif err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {\n\t\t\tfmt.Printf(\"Warning: could not update config: %v\\n\", err)\n\t\t}\n\t}\n\n\tfmt.Println(\"\\n✓ Google Antigravity login successful!\")\n\tfmt.Println(\"Default model set to: gemini-flash\")\n\tfmt.Println(\"Try it: picoclaw agent -m \\\"Hello world\\\"\")\n\n\treturn nil\n}\n\nfunc authLoginAnthropic(useOauth bool) error {\n\tif useOauth {\n\t\treturn authLoginAnthropicSetupToken()\n\t}\n\n\tfmt.Println(\"Anthropic login method:\")\n\tfmt.Println(\"  1) Setup token (from `claude setup-token`) (Recommended)\")\n\tfmt.Println(\"  2) API key (from console.anthropic.com)\")\n\n\tscanner := bufio.NewScanner(os.Stdin)\n\tfor {\n\t\tfmt.Print(\"Choose [1]: \")\n\t\tchoice := \"1\"\n\t\tif scanner.Scan() {\n\t\t\ttext := strings.TrimSpace(scanner.Text())\n\t\t\tif text != \"\" {\n\t\t\t\tchoice = text\n\t\t\t}\n\t\t}\n\n\t\tswitch choice {\n\t\tcase \"1\":\n\t\t\treturn authLoginAnthropicSetupToken()\n\t\tcase \"2\":\n\t\t\treturn authLoginPasteToken(\"anthropic\")\n\t\tdefault:\n\t\t\tfmt.Printf(\"Invalid choice: %s. Please enter 1 or 2.\\n\", choice)\n\t\t}\n\t}\n}\n\nfunc authLoginAnthropicSetupToken() error {\n\tcred, err := auth.LoginSetupToken(os.Stdin)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"login failed: %w\", err)\n\t}\n\n\tif err = auth.SetCredential(\"anthropic\", cred); err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\tappCfg, err := internal.LoadConfig()\n\tif err == nil {\n\t\tappCfg.Providers.Anthropic.AuthMethod = \"oauth\"\n\n\t\tfound := false\n\t\tfor i := range appCfg.ModelList {\n\t\t\tif isAnthropicModel(appCfg.ModelList[i].Model) {\n\t\t\t\tappCfg.ModelList[i].AuthMethod = \"oauth\"\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tappCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{\n\t\t\t\tModelName:  defaultAnthropicModel,\n\t\t\t\tModel:      \"anthropic/\" + defaultAnthropicModel,\n\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t})\n\t\t\t// Only set default model if user has no default configured yet\n\t\t\tif appCfg.Agents.Defaults.GetModelName() == \"\" {\n\t\t\t\tappCfg.Agents.Defaults.ModelName = defaultAnthropicModel\n\t\t\t}\n\t\t}\n\n\t\tif err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {\n\t\t\treturn fmt.Errorf(\"could not update config: %w\", err)\n\t\t}\n\t}\n\n\tfmt.Println(\"Setup token saved for Anthropic!\")\n\n\treturn nil\n}\n\nfunc fetchGoogleUserEmail(accessToken string) (string, error) {\n\treq, err := http.NewRequest(\"GET\", \"https://www.googleapis.com/oauth2/v2/userinfo\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"reading userinfo response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"userinfo request failed: %s\", string(body))\n\t}\n\n\tvar userInfo struct {\n\t\tEmail string `json:\"email\"`\n\t}\n\tif err := json.Unmarshal(body, &userInfo); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn userInfo.Email, nil\n}\n\nfunc authLoginPasteToken(provider string) error {\n\tcred, err := auth.LoginPasteToken(provider, os.Stdin)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"login failed: %w\", err)\n\t}\n\n\tif err = auth.SetCredential(provider, cred); err != nil {\n\t\treturn fmt.Errorf(\"failed to save credentials: %w\", err)\n\t}\n\n\tappCfg, err := internal.LoadConfig()\n\tif err == nil {\n\t\tswitch provider {\n\t\tcase \"anthropic\":\n\t\t\tappCfg.Providers.Anthropic.AuthMethod = \"token\"\n\t\t\t// Update ModelList\n\t\t\tfound := false\n\t\t\tfor i := range appCfg.ModelList {\n\t\t\t\tif isAnthropicModel(appCfg.ModelList[i].Model) {\n\t\t\t\t\tappCfg.ModelList[i].AuthMethod = \"token\"\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tappCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{\n\t\t\t\t\tModelName:  defaultAnthropicModel,\n\t\t\t\t\tModel:      \"anthropic/\" + defaultAnthropicModel,\n\t\t\t\t\tAuthMethod: \"token\",\n\t\t\t\t})\n\t\t\t\tappCfg.Agents.Defaults.ModelName = defaultAnthropicModel\n\t\t\t}\n\t\tcase \"openai\":\n\t\t\tappCfg.Providers.OpenAI.AuthMethod = \"token\"\n\t\t\t// Update ModelList\n\t\t\tfound := false\n\t\t\tfor i := range appCfg.ModelList {\n\t\t\t\tif isOpenAIModel(appCfg.ModelList[i].Model) {\n\t\t\t\t\tappCfg.ModelList[i].AuthMethod = \"token\"\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tappCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{\n\t\t\t\t\tModelName:  \"gpt-5.4\",\n\t\t\t\t\tModel:      \"openai/gpt-5.4\",\n\t\t\t\t\tAuthMethod: \"token\",\n\t\t\t\t})\n\t\t\t}\n\t\t\t// Update default model\n\t\t\tappCfg.Agents.Defaults.ModelName = \"gpt-5.4\"\n\t\t}\n\t\tif err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {\n\t\t\treturn fmt.Errorf(\"could not update config: %w\", err)\n\t\t}\n\t}\n\n\tfmt.Printf(\"Token saved for %s!\\n\", provider)\n\n\tif appCfg != nil {\n\t\tfmt.Printf(\"Default model set to: %s\\n\", appCfg.Agents.Defaults.GetModelName())\n\t}\n\n\treturn nil\n}\n\nfunc authLogoutCmd(provider string) error {\n\tif provider != \"\" {\n\t\tif err := auth.DeleteCredential(provider); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to remove credentials: %w\", err)\n\t\t}\n\n\t\tappCfg, err := internal.LoadConfig()\n\t\tif err == nil {\n\t\t\t// Clear AuthMethod in ModelList\n\t\t\tfor i := range appCfg.ModelList {\n\t\t\t\tswitch provider {\n\t\t\t\tcase \"openai\":\n\t\t\t\t\tif isOpenAIModel(appCfg.ModelList[i].Model) {\n\t\t\t\t\t\tappCfg.ModelList[i].AuthMethod = \"\"\n\t\t\t\t\t}\n\t\t\t\tcase \"anthropic\":\n\t\t\t\t\tif isAnthropicModel(appCfg.ModelList[i].Model) {\n\t\t\t\t\t\tappCfg.ModelList[i].AuthMethod = \"\"\n\t\t\t\t\t}\n\t\t\t\tcase \"google-antigravity\", \"antigravity\":\n\t\t\t\t\tif isAntigravityModel(appCfg.ModelList[i].Model) {\n\t\t\t\t\t\tappCfg.ModelList[i].AuthMethod = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Clear AuthMethod in Providers (legacy)\n\t\t\tswitch provider {\n\t\t\tcase \"openai\":\n\t\t\t\tappCfg.Providers.OpenAI.AuthMethod = \"\"\n\t\t\tcase \"anthropic\":\n\t\t\t\tappCfg.Providers.Anthropic.AuthMethod = \"\"\n\t\t\tcase \"google-antigravity\", \"antigravity\":\n\t\t\t\tappCfg.Providers.Antigravity.AuthMethod = \"\"\n\t\t\t}\n\t\t\tconfig.SaveConfig(internal.GetConfigPath(), appCfg)\n\t\t}\n\n\t\tfmt.Printf(\"Logged out from %s\\n\", provider)\n\n\t\treturn nil\n\t}\n\n\tif err := auth.DeleteAllCredentials(); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove credentials: %w\", err)\n\t}\n\n\tappCfg, err := internal.LoadConfig()\n\tif err == nil {\n\t\t// Clear all AuthMethods in ModelList\n\t\tfor i := range appCfg.ModelList {\n\t\t\tappCfg.ModelList[i].AuthMethod = \"\"\n\t\t}\n\t\t// Clear all AuthMethods in Providers (legacy)\n\t\tappCfg.Providers.OpenAI.AuthMethod = \"\"\n\t\tappCfg.Providers.Anthropic.AuthMethod = \"\"\n\t\tappCfg.Providers.Antigravity.AuthMethod = \"\"\n\t\tconfig.SaveConfig(internal.GetConfigPath(), appCfg)\n\t}\n\n\tfmt.Println(\"Logged out from all providers\")\n\n\treturn nil\n}\n\nfunc authStatusCmd() error {\n\tstore, err := auth.LoadStore()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to load auth store: %w\", err)\n\t}\n\n\tif len(store.Credentials) == 0 {\n\t\tfmt.Println(\"No authenticated providers.\")\n\t\tfmt.Println(\"Run: picoclaw auth login --provider <name>\")\n\t\treturn nil\n\t}\n\n\tfmt.Println(\"\\nAuthenticated Providers:\")\n\tfmt.Println(\"------------------------\")\n\tfor provider, cred := range store.Credentials {\n\t\tstatus := \"active\"\n\t\tif cred.IsExpired() {\n\t\t\tstatus = \"expired\"\n\t\t} else if cred.NeedsRefresh() {\n\t\t\tstatus = \"needs refresh\"\n\t\t}\n\n\t\tfmt.Printf(\"  %s:\\n\", provider)\n\t\tfmt.Printf(\"    Method: %s\\n\", cred.AuthMethod)\n\t\tfmt.Printf(\"    Status: %s\\n\", status)\n\t\tif cred.AccountID != \"\" {\n\t\t\tfmt.Printf(\"    Account: %s\\n\", cred.AccountID)\n\t\t}\n\t\tif cred.Email != \"\" {\n\t\t\tfmt.Printf(\"    Email: %s\\n\", cred.Email)\n\t\t}\n\t\tif cred.ProjectID != \"\" {\n\t\t\tfmt.Printf(\"    Project: %s\\n\", cred.ProjectID)\n\t\t}\n\t\tif !cred.ExpiresAt.IsZero() {\n\t\t\tfmt.Printf(\"    Expires: %s\\n\", cred.ExpiresAt.Format(\"2006-01-02 15:04\"))\n\t\t}\n\n\t\tif provider == \"anthropic\" && cred.AuthMethod == \"oauth\" {\n\t\t\tusage, err := auth.FetchAnthropicUsage(cred.AccessToken)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"    Usage: unavailable (%v)\\n\", err)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"    Usage (5h):  %.1f%%\\n\", usage.FiveHourUtilization*100)\n\t\t\t\tfmt.Printf(\"    Usage (7d):  %.1f%%\\n\", usage.SevenDayUtilization*100)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc authModelsCmd() error {\n\tcred, err := auth.GetCredential(\"google-antigravity\")\n\tif err != nil || cred == nil {\n\t\treturn fmt.Errorf(\n\t\t\t\"not logged in to Google Antigravity.\\nrun: picoclaw auth login --provider google-antigravity\",\n\t\t)\n\t}\n\n\t// Refresh token if needed\n\tif cred.NeedsRefresh() && cred.RefreshToken != \"\" {\n\t\toauthCfg := auth.GoogleAntigravityOAuthConfig()\n\t\trefreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)\n\t\tif refreshErr == nil {\n\t\t\tcred = refreshed\n\t\t\t_ = auth.SetCredential(\"google-antigravity\", cred)\n\t\t}\n\t}\n\n\tprojectID := cred.ProjectID\n\tif projectID == \"\" {\n\t\treturn fmt.Errorf(\"no project id stored. Try logging in again\")\n\t}\n\n\tfmt.Printf(\"Fetching models for project: %s\\n\\n\", projectID)\n\n\tmodels, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error fetching models: %w\", err)\n\t}\n\n\tif len(models) == 0 {\n\t\treturn fmt.Errorf(\"no models available\")\n\t}\n\n\tfmt.Println(\"Available Antigravity Models:\")\n\tfmt.Println(\"-----------------------------\")\n\tfor _, m := range models {\n\t\tstatus := \"✓\"\n\t\tif m.IsExhausted {\n\t\t\tstatus = \"✗ (quota exhausted)\"\n\t\t}\n\t\tname := m.ID\n\t\tif m.DisplayName != \"\" {\n\t\t\tname = fmt.Sprintf(\"%s (%s)\", m.ID, m.DisplayName)\n\t\t}\n\t\tfmt.Printf(\"  %s %s\\n\", status, name)\n\t}\n\n\treturn nil\n}\n\n// isAntigravityModel checks if a model string belongs to antigravity provider\nfunc isAntigravityModel(model string) bool {\n\treturn model == \"antigravity\" ||\n\t\tmodel == \"google-antigravity\" ||\n\t\tstrings.HasPrefix(model, \"antigravity/\") ||\n\t\tstrings.HasPrefix(model, \"google-antigravity/\")\n}\n\n// isOpenAIModel checks if a model string belongs to openai provider\nfunc isOpenAIModel(model string) bool {\n\treturn model == \"openai\" ||\n\t\tstrings.HasPrefix(model, \"openai/\")\n}\n\n// isAnthropicModel checks if a model string belongs to anthropic provider\nfunc isAnthropicModel(model string) bool {\n\treturn model == \"anthropic\" ||\n\t\tstrings.HasPrefix(model, \"anthropic/\")\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/login.go",
    "content": "package auth\n\nimport \"github.com/spf13/cobra\"\n\nfunc newLoginCommand() *cobra.Command {\n\tvar (\n\t\tprovider      string\n\t\tuseDeviceCode bool\n\t\tuseOauth      bool\n\t)\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"login\",\n\t\tShort: \"Login via OAuth or paste token\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn authLoginCmd(provider, useDeviceCode, useOauth)\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&provider, \"provider\", \"p\", \"\", \"Provider to login with (openai, anthropic)\")\n\tcmd.Flags().BoolVar(&useDeviceCode, \"device-code\", false, \"Use device code flow (for headless environments)\")\n\tcmd.Flags().BoolVar(\n\t\t&useOauth, \"setup-token\", false,\n\t\t\"Use setup-token flow for Anthropic (from `claude setup-token`)\",\n\t)\n\t_ = cmd.MarkFlagRequired(\"provider\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/login_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewLoginSubCommand(t *testing.T) {\n\tcmd := newLoginCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"Login via OAuth or paste token\", cmd.Short)\n\n\tassert.True(t, cmd.HasFlags())\n\n\tassert.NotNil(t, cmd.Flags().Lookup(\"device-code\"))\n\n\tproviderFlag := cmd.Flags().Lookup(\"provider\")\n\trequire.NotNil(t, providerFlag)\n\n\tval, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]\n\trequire.True(t, found)\n\trequire.NotEmpty(t, val)\n\tassert.Equal(t, \"true\", val[0])\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/logout.go",
    "content": "package auth\n\nimport \"github.com/spf13/cobra\"\n\nfunc newLogoutCommand() *cobra.Command {\n\tvar provider string\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"logout\",\n\t\tShort: \"Remove stored credentials\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn authLogoutCmd(provider)\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&provider, \"provider\", \"p\", \"\", \"Provider to logout from (openai, anthropic); empty = all\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/logout_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewLogoutSubcommand(t *testing.T) {\n\tcmd := newLogoutCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"Remove stored credentials\", cmd.Short)\n\n\tassert.True(t, cmd.HasFlags())\n\n\tassert.NotNil(t, cmd.Flags().Lookup(\"provider\"))\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/models.go",
    "content": "package auth\n\nimport \"github.com/spf13/cobra\"\n\nfunc newModelsCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"models\",\n\t\tShort: \"Show available models\",\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\treturn authModelsCmd()\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/models_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewModelsCommand(t *testing.T) {\n\tcmd := newModelsCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"models\", cmd.Use)\n\tassert.Equal(t, \"Show available models\", cmd.Short)\n\n\tassert.False(t, cmd.HasFlags())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/status.go",
    "content": "package auth\n\nimport \"github.com/spf13/cobra\"\n\nfunc newStatusCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"status\",\n\t\tShort: \"Show current auth status\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn authStatusCmd()\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/auth/status_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewStatusSubcommand(t *testing.T) {\n\tcmd := newStatusCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"Show current auth status\", cmd.Short)\n\n\tassert.False(t, cmd.HasFlags())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/add.go",
    "content": "package cron\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/pkg/cron\"\n)\n\nfunc newAddCommand(storePath func() string) *cobra.Command {\n\tvar (\n\t\tname    string\n\t\tmessage string\n\t\tevery   int64\n\t\tcronExp string\n\t\tdeliver bool\n\t\tchannel string\n\t\tto      string\n\t)\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"add\",\n\t\tShort: \"Add a new scheduled job\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tif every <= 0 && cronExp == \"\" {\n\t\t\t\treturn fmt.Errorf(\"either --every or --cron must be specified\")\n\t\t\t}\n\n\t\t\tvar schedule cron.CronSchedule\n\t\t\tif every > 0 {\n\t\t\t\teveryMS := every * 1000\n\t\t\t\tschedule = cron.CronSchedule{Kind: \"every\", EveryMS: &everyMS}\n\t\t\t} else {\n\t\t\t\tschedule = cron.CronSchedule{Kind: \"cron\", Expr: cronExp}\n\t\t\t}\n\n\t\t\tcs := cron.NewCronService(storePath(), nil)\n\t\t\tjob, err := cs.AddJob(name, schedule, message, deliver, channel, to)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error adding job: %w\", err)\n\t\t\t}\n\n\t\t\tfmt.Printf(\"✓ Added job '%s' (%s)\\n\", job.Name, job.ID)\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&name, \"name\", \"n\", \"\", \"Job name\")\n\tcmd.Flags().StringVarP(&message, \"message\", \"m\", \"\", \"Message for agent\")\n\tcmd.Flags().Int64VarP(&every, \"every\", \"e\", 0, \"Run every N seconds\")\n\tcmd.Flags().StringVarP(&cronExp, \"cron\", \"c\", \"\", \"Cron expression (e.g. '0 9 * * *')\")\n\tcmd.Flags().BoolVarP(&deliver, \"deliver\", \"d\", false, \"Deliver response to channel\")\n\tcmd.Flags().StringVar(&to, \"to\", \"\", \"Recipient for delivery\")\n\tcmd.Flags().StringVar(&channel, \"channel\", \"\", \"Channel for delivery\")\n\n\t_ = cmd.MarkFlagRequired(\"name\")\n\t_ = cmd.MarkFlagRequired(\"message\")\n\tcmd.MarkFlagsMutuallyExclusive(\"every\", \"cron\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/add_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewAddSubcommand(t *testing.T) {\n\tfn := func() string { return \"\" }\n\tcmd := newAddCommand(fn)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"add\", cmd.Use)\n\tassert.Equal(t, \"Add a new scheduled job\", cmd.Short)\n\n\tassert.True(t, cmd.HasFlags())\n\n\tassert.NotNil(t, cmd.Flags().Lookup(\"every\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"cron\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"deliver\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"to\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"channel\"))\n\n\tnameFlag := cmd.Flags().Lookup(\"name\")\n\trequire.NotNil(t, nameFlag)\n\n\tmessageFlag := cmd.Flags().Lookup(\"message\")\n\trequire.NotNil(t, messageFlag)\n\n\tval, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]\n\trequire.True(t, found)\n\trequire.NotEmpty(t, val)\n\tassert.Equal(t, \"true\", val[0])\n\n\tval, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]\n\trequire.True(t, found)\n\trequire.NotEmpty(t, val)\n\tassert.Equal(t, \"true\", val[0])\n}\n\nfunc TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {\n\tcmd := newAddCommand(func() string { return \"testing\" })\n\n\tcmd.SetArgs([]string{\n\t\t\"--name\", \"job\",\n\t\t\"--message\", \"hello\",\n\t\t\"--every\", \"10\",\n\t\t\"--cron\", \"0 9 * * *\",\n\t})\n\n\terr := cmd.Execute()\n\trequire.Error(t, err)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/command.go",
    "content": "package cron\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n)\n\nfunc NewCronCommand() *cobra.Command {\n\tvar storePath string\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"cron\",\n\t\tAliases: []string{\"c\"},\n\t\tShort:   \"Manage scheduled tasks\",\n\t\tArgs:    cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn cmd.Help()\n\t\t},\n\t\t// Resolve storePath at execution time so it reflects the current config\n\t\t// and is shared across all subcommands.\n\t\tPersistentPreRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tcfg, err := internal.LoadConfig()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error loading config: %w\", err)\n\t\t\t}\n\t\t\tstorePath = filepath.Join(cfg.WorkspacePath(), \"cron\", \"jobs.json\")\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\tnewListCommand(func() string { return storePath }),\n\t\tnewAddCommand(func() string { return storePath }),\n\t\tnewRemoveCommand(func() string { return storePath }),\n\t\tnewEnableCommand(func() string { return storePath }),\n\t\tnewDisableCommand(func() string { return storePath }),\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/command_test.go",
    "content": "package cron\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewCronCommand(t *testing.T) {\n\tcmd := NewCronCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"Manage scheduled tasks\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 1)\n\tassert.True(t, cmd.HasAlias(\"c\"))\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.NotNil(t, cmd.PersistentPreRunE)\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.True(t, cmd.HasSubCommands())\n\n\tallowedCommands := []string{\n\t\t\"list\",\n\t\t\"add\",\n\t\t\"remove\",\n\t\t\"enable\",\n\t\t\"disable\",\n\t}\n\n\tsubcommands := cmd.Commands()\n\tassert.Len(t, subcommands, len(allowedCommands))\n\n\tfor _, subcmd := range subcommands {\n\t\tfound := slices.Contains(allowedCommands, subcmd.Name())\n\t\tassert.True(t, found, \"unexpected subcommand %q\", subcmd.Name())\n\n\t\tassert.Len(t, subcmd.Aliases, 0)\n\t\tassert.False(t, subcmd.Hidden)\n\n\t\tassert.False(t, subcmd.HasSubCommands())\n\n\t\tassert.Nil(t, subcmd.Run)\n\t\tassert.NotNil(t, subcmd.RunE)\n\n\t\tassert.Nil(t, subcmd.PersistentPreRun)\n\t\tassert.Nil(t, subcmd.PersistentPostRun)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/disable.go",
    "content": "package cron\n\nimport \"github.com/spf13/cobra\"\n\nfunc newDisableCommand(storePath func() string) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:     \"disable\",\n\t\tShort:   \"Disable a job\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tExample: `picoclaw cron disable 1`,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tcronSetJobEnabled(storePath(), args[0], false)\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/disable_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDisableSubcommand(t *testing.T) {\n\tfn := func() string { return \"\" }\n\tcmd := newDisableCommand(fn)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"disable\", cmd.Use)\n\tassert.Equal(t, \"Disable a job\", cmd.Short)\n\n\tassert.True(t, cmd.HasExample())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/enable.go",
    "content": "package cron\n\nimport \"github.com/spf13/cobra\"\n\nfunc newEnableCommand(storePath func() string) *cobra.Command {\n\treturn &cobra.Command{\n\t\tUse:     \"enable\",\n\t\tShort:   \"Enable a job\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tExample: `picoclaw cron enable 1`,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tcronSetJobEnabled(storePath(), args[0], true)\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/enable_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestEnableSubcommand(t *testing.T) {\n\tfn := func() string { return \"\" }\n\tcmd := newEnableCommand(fn)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"enable\", cmd.Use)\n\tassert.Equal(t, \"Enable a job\", cmd.Short)\n\n\tassert.True(t, cmd.HasExample())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/helpers.go",
    "content": "package cron\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/cron\"\n)\n\nfunc cronListCmd(storePath string) {\n\tcs := cron.NewCronService(storePath, nil)\n\tjobs := cs.ListJobs(true) // Show all jobs, including disabled\n\n\tif len(jobs) == 0 {\n\t\tfmt.Println(\"No scheduled jobs.\")\n\t\treturn\n\t}\n\n\tfmt.Println(\"\\nScheduled Jobs:\")\n\tfmt.Println(\"----------------\")\n\tfor _, job := range jobs {\n\t\tvar schedule string\n\t\tif job.Schedule.Kind == \"every\" && job.Schedule.EveryMS != nil {\n\t\t\tschedule = fmt.Sprintf(\"every %ds\", *job.Schedule.EveryMS/1000)\n\t\t} else if job.Schedule.Kind == \"cron\" {\n\t\t\tschedule = job.Schedule.Expr\n\t\t} else {\n\t\t\tschedule = \"one-time\"\n\t\t}\n\n\t\tnextRun := \"scheduled\"\n\t\tif job.State.NextRunAtMS != nil {\n\t\t\tnextTime := time.UnixMilli(*job.State.NextRunAtMS)\n\t\t\tnextRun = nextTime.Format(\"2006-01-02 15:04\")\n\t\t}\n\n\t\tstatus := \"enabled\"\n\t\tif !job.Enabled {\n\t\t\tstatus = \"disabled\"\n\t\t}\n\n\t\tfmt.Printf(\"  %s (%s)\\n\", job.Name, job.ID)\n\t\tfmt.Printf(\"    Schedule: %s\\n\", schedule)\n\t\tfmt.Printf(\"    Status: %s\\n\", status)\n\t\tfmt.Printf(\"    Next run: %s\\n\", nextRun)\n\t}\n}\n\nfunc cronRemoveCmd(storePath, jobID string) {\n\tcs := cron.NewCronService(storePath, nil)\n\tif cs.RemoveJob(jobID) {\n\t\tfmt.Printf(\"✓ Removed job %s\\n\", jobID)\n\t} else {\n\t\tfmt.Printf(\"✗ Job %s not found\\n\", jobID)\n\t}\n}\n\nfunc cronSetJobEnabled(storePath, jobID string, enabled bool) {\n\tcs := cron.NewCronService(storePath, nil)\n\tjob := cs.EnableJob(jobID, enabled)\n\tif job != nil {\n\t\tfmt.Printf(\"✓ Job '%s' enabled\\n\", job.Name)\n\t} else {\n\t\tfmt.Printf(\"✗ Job %s not found\\n\", jobID)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/list.go",
    "content": "package cron\n\nimport \"github.com/spf13/cobra\"\n\nfunc newListCommand(storePath func() string) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"list\",\n\t\tShort: \"List all scheduled jobs\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tcronListCmd(storePath())\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/list_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewListSubcommand(t *testing.T) {\n\tfn := func() string { return \"\" }\n\tcmd := newListCommand(fn)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"List all scheduled jobs\", cmd.Short)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/remove.go",
    "content": "package cron\n\nimport \"github.com/spf13/cobra\"\n\nfunc newRemoveCommand(storePath func() string) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"remove\",\n\t\tShort:   \"Remove a job by ID\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tExample: `picoclaw cron remove 1`,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tcronRemoveCmd(storePath(), args[0])\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/cron/remove_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewRemoveSubcommand(t *testing.T) {\n\tfn := func() string { return \"\" }\n\tcmd := newRemoveCommand(fn)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"Remove a job by ID\", cmd.Short)\n\n\tassert.True(t, cmd.HasExample())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/gateway/command.go",
    "content": "package gateway\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/gateway\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nfunc NewGatewayCommand() *cobra.Command {\n\tvar debug bool\n\tvar noTruncate bool\n\tvar allowEmpty bool\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"gateway\",\n\t\tAliases: []string{\"g\"},\n\t\tShort:   \"Start picoclaw gateway\",\n\t\tArgs:    cobra.NoArgs,\n\t\tPreRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tif noTruncate && !debug {\n\t\t\t\treturn fmt.Errorf(\"the --no-truncate option can only be used in conjunction with --debug (-d)\")\n\t\t\t}\n\n\t\t\tif noTruncate {\n\t\t\t\tutils.SetDisableTruncation(true)\n\t\t\t\tlogger.Info(\"String truncation is globally disabled via 'no-truncate' flag\")\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\treturn gateway.Run(debug, internal.GetConfigPath(), allowEmpty)\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&debug, \"debug\", \"d\", false, \"Enable debug logging\")\n\tcmd.Flags().BoolVarP(&noTruncate, \"no-truncate\", \"T\", false, \"Disable string truncation in debug logs\")\n\tcmd.Flags().BoolVarP(\n\t\t&allowEmpty,\n\t\t\"allow-empty\",\n\t\t\"E\",\n\t\tfalse,\n\t\t\"Continue starting even when no default model is configured\",\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/gateway/command_test.go",
    "content": "package gateway\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewGatewayCommand(t *testing.T) {\n\tcmd := NewGatewayCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"gateway\", cmd.Use)\n\tassert.Equal(t, \"Start picoclaw gateway\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 1)\n\tassert.True(t, cmd.HasAlias(\"g\"))\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.True(t, cmd.HasFlags())\n\tassert.NotNil(t, cmd.Flags().Lookup(\"debug\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"allow-empty\"))\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/helpers.go",
    "content": "package internal\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nconst Logo = \"🦞\"\n\n// GetPicoclawHome returns the picoclaw home directory.\n// Priority: $PICOCLAW_HOME > ~/.picoclaw\nfunc GetPicoclawHome() string {\n\tif home := os.Getenv(config.EnvHome); home != \"\" {\n\t\treturn home\n\t}\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".picoclaw\")\n}\n\nfunc GetConfigPath() string {\n\tif configPath := os.Getenv(config.EnvConfig); configPath != \"\" {\n\t\treturn configPath\n\t}\n\treturn filepath.Join(GetPicoclawHome(), \"config.json\")\n}\n\nfunc LoadConfig() (*config.Config, error) {\n\treturn config.LoadConfig(GetConfigPath())\n}\n\n// FormatVersion returns the version string with optional git commit\n// Deprecated: Use pkg/config.FormatVersion instead\nfunc FormatVersion() string {\n\treturn config.FormatVersion()\n}\n\n// FormatBuildInfo returns build time and go version info\n// Deprecated: Use pkg/config.FormatBuildInfo instead\nfunc FormatBuildInfo() (string, string) {\n\treturn config.FormatBuildInfo()\n}\n\n// GetVersion returns the version string\n// Deprecated: Use pkg/config.GetVersion instead\nfunc GetVersion() string {\n\treturn config.GetVersion()\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/helpers_test.go",
    "content": "package internal\n\nimport (\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetConfigPath(t *testing.T) {\n\tt.Setenv(\"HOME\", \"/tmp/home\")\n\n\tgot := GetConfigPath()\n\twant := filepath.Join(\"/tmp/home\", \".picoclaw\", \"config.json\")\n\n\tassert.Equal(t, want, got)\n}\n\nfunc TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_HOME\", \"/custom/picoclaw\")\n\tt.Setenv(\"HOME\", \"/tmp/home\")\n\n\tgot := GetConfigPath()\n\twant := filepath.Join(\"/custom/picoclaw\", \"config.json\")\n\n\tassert.Equal(t, want, got)\n}\n\nfunc TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_CONFIG\", \"/custom/config.json\")\n\tt.Setenv(\"PICOCLAW_HOME\", \"/custom/picoclaw\")\n\tt.Setenv(\"HOME\", \"/tmp/home\")\n\n\tgot := GetConfigPath()\n\twant := \"/custom/config.json\"\n\n\tassert.Equal(t, want, got)\n}\n\nfunc TestGetConfigPath_Windows(t *testing.T) {\n\tif runtime.GOOS != \"windows\" {\n\t\tt.Skip(\"windows-specific HOME behavior varies; run on windows\")\n\t}\n\n\ttestUserProfilePath := `C:\\Users\\Test`\n\tt.Setenv(\"USERPROFILE\", testUserProfilePath)\n\n\tgot := GetConfigPath()\n\twant := filepath.Join(testUserProfilePath, \".picoclaw\", \"config.json\")\n\n\trequire.True(t, strings.EqualFold(got, want), \"GetConfigPath() = %q, want %q\", got, want)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/migrate/command.go",
    "content": "package migrate\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/pkg/migrate\"\n)\n\nfunc NewMigrateCommand() *cobra.Command {\n\tvar opts migrate.Options\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"migrate\",\n\t\tShort: \"Migrate from xxxclaw(openclaw, etc.) to picoclaw\",\n\t\tArgs:  cobra.NoArgs,\n\t\tExample: `  picoclaw migrate\n  picoclaw migrate --from openclaw\n  picoclaw migrate --dry-run\n  picoclaw migrate --refresh\n  picoclaw migrate --force`,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tm := migrate.NewMigrateInstance(opts)\n\t\t\tresult, err := m.Run(opts)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif !opts.DryRun {\n\t\t\t\tm.PrintSummary(result)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVar(&opts.DryRun, \"dry-run\", false,\n\t\t\"Show what would be migrated without making changes\")\n\tcmd.Flags().StringVar(&opts.Source, \"from\", \"openclaw\",\n\t\t\"Source to migrate from (e.g., openclaw)\")\n\tcmd.Flags().BoolVar(&opts.Refresh, \"refresh\", false,\n\t\t\"Re-sync workspace files from OpenClaw (repeatable)\")\n\tcmd.Flags().BoolVar(&opts.ConfigOnly, \"config-only\", false,\n\t\t\"Only migrate config, skip workspace files\")\n\tcmd.Flags().BoolVar(&opts.WorkspaceOnly, \"workspace-only\", false,\n\t\t\"Only migrate workspace files, skip config\")\n\tcmd.Flags().BoolVar(&opts.Force, \"force\", false,\n\t\t\"Skip confirmation prompts\")\n\tcmd.Flags().StringVar(&opts.SourceHome, \"source-home\", \"\",\n\t\t\"Override source home directory (default: ~/.openclaw)\")\n\tcmd.Flags().StringVar(&opts.TargetHome, \"target-home\", \"\",\n\t\t\"Override target home directory (default: ~/.picoclaw)\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/migrate/command_test.go",
    "content": "package migrate\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewMigrateCommand(t *testing.T) {\n\tcmd := NewMigrateCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"migrate\", cmd.Use)\n\tassert.Equal(t, \"Migrate from xxxclaw(openclaw, etc.) to picoclaw\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 0)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.True(t, cmd.HasFlags())\n\n\tassert.NotNil(t, cmd.Flags().Lookup(\"dry-run\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"refresh\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"config-only\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"workspace-only\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"force\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"source-home\"))\n\tassert.NotNil(t, cmd.Flags().Lookup(\"target-home\"))\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/model/command.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// LocalModel is a special model name that indicates that the model is local and with or without api_key.\nconst LocalModel = \"local-model\"\n\nfunc NewModelCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"model [model_name]\",\n\t\tShort: \"Show or change the default model\",\n\t\tLong: `Show or change the default model configuration.\n\nIf no argument is provided, shows the current default model.\nIf a model name is provided, sets it as the default model.\n\nExamples:\n  picoclaw model                    # Show current default model\n  picoclaw model gpt-5.2           # Set gpt-5.2 as default\n  picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default\n  picoclaw model local-model       # Set local VLLM server as default\n\nNote: 'local-model' is a special value for using a local VLLM server\n(running at localhost:8000 by default) which does not require an API key.`,\n\t\tArgs: cobra.MaximumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tconfigPath := internal.GetConfigPath()\n\n\t\t\t// Load current config\n\t\t\tcfg, err := config.LoadConfig(configPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to load config: %w\", err)\n\t\t\t}\n\n\t\t\tif len(args) == 0 {\n\t\t\t\t// Show current default model\n\t\t\t\tshowCurrentModel(cfg)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// Set new default model\n\t\t\tmodelName := args[0]\n\t\t\treturn setDefaultModel(configPath, cfg, modelName)\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc showCurrentModel(cfg *config.Config) {\n\tdefaultModel := cfg.Agents.Defaults.ModelName\n\tif defaultModel == \"\" {\n\t\tdefaultModel = cfg.Agents.Defaults.Model\n\t}\n\n\tif defaultModel == \"\" {\n\t\tfmt.Println(\"No default model is currently set.\")\n\t\tfmt.Println(\"\\nAvailable models in your config:\")\n\t\tlistAvailableModels(cfg)\n\t} else {\n\t\tfmt.Printf(\"Current default model: %s\\n\", defaultModel)\n\t\tfmt.Println(\"\\nAvailable models in your config:\")\n\t\tlistAvailableModels(cfg)\n\t}\n}\n\nfunc listAvailableModels(cfg *config.Config) {\n\tif len(cfg.ModelList) == 0 {\n\t\tfmt.Println(\"  No models configured in model_list\")\n\t\treturn\n\t}\n\n\tdefaultModel := cfg.Agents.Defaults.ModelName\n\tif defaultModel == \"\" {\n\t\tdefaultModel = cfg.Agents.Defaults.Model\n\t}\n\n\tfor _, model := range cfg.ModelList {\n\t\tmarker := \"  \"\n\t\tif model.ModelName == defaultModel {\n\t\t\tmarker = \"> \"\n\t\t}\n\t\tif model.APIKey == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Printf(\"%s- %s (%s)\\n\", marker, model.ModelName, model.Model)\n\t}\n}\n\nfunc setDefaultModel(configPath string, cfg *config.Config, modelName string) error {\n\t// Validate that the model exists in model_list\n\tmodelFound := false\n\tfor _, model := range cfg.ModelList {\n\t\tif model.APIKey != \"\" && model.ModelName == modelName {\n\t\t\tmodelFound = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !modelFound && modelName != LocalModel {\n\t\treturn fmt.Errorf(\"cannot found model '%s' in config\", modelName)\n\t}\n\n\t// Update the default model\n\t// Clear old model field and set new model_name\n\toldModel := cfg.Agents.Defaults.ModelName\n\tif oldModel == \"\" {\n\t\toldModel = cfg.Agents.Defaults.Model\n\t}\n\n\tcfg.Agents.Defaults.ModelName = modelName\n\tcfg.Agents.Defaults.Model = \"\" // Clear deprecated field\n\n\t// Save config back to file\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\treturn fmt.Errorf(\"failed to save config: %w\", err)\n\t}\n\n\tfmt.Printf(\"✓ Default model changed from '%s' to '%s'\\n\",\n\t\tformatModelName(oldModel), modelName)\n\tfmt.Println(\"\\nThe new default model will be used for all agent interactions.\")\n\n\treturn nil\n}\n\nfunc formatModelName(name string) string {\n\tif name == \"\" {\n\t\treturn \"(none)\"\n\t}\n\treturn name\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/model/command_test.go",
    "content": "package model\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nvar configPath = \"\"\n\nfunc initTest(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath = filepath.Join(tmpDir, \"config.json\")\n\t_ = os.Setenv(\"PICOCLAW_CONFIG\", configPath)\n}\n\n// captureStdout captures stdout during the execution of fn and returns the captured output\nfunc captureStdout(fn func()) string {\n\toldStdout := os.Stdout\n\tr, w, _ := os.Pipe()\n\tos.Stdout = w\n\n\tfn()\n\n\tw.Close()\n\tos.Stdout = oldStdout\n\n\tvar buf bytes.Buffer\n\tio.Copy(&buf, r)\n\treturn buf.String()\n}\n\nfunc TestNewModelCommand(t *testing.T) {\n\tcmd := NewModelCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"model [model_name]\", cmd.Use)\n\tassert.Equal(t, \"Show or change the default model\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 0)\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRunE)\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n}\n\nfunc TestShowCurrentModel_WithDefaultModel(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"gpt-4\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"gpt-4\", Model: \"openai/gpt-4\", APIKey: \"test\"},\n\t\t\t{ModelName: \"claude-3\", Model: \"anthropic/claude-3\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tshowCurrentModel(cfg)\n\t})\n\n\tassert.Contains(t, output, \"Current default model: gpt-4\")\n\tassert.Contains(t, output, \"Available models in your config:\")\n\tassert.Contains(t, output, \"gpt-4\")\n\tassert.Contains(t, output, \"claude-3\")\n}\n\nfunc TestShowCurrentModel_NoDefaultModel(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"\",\n\t\t\t\tModel:     \"\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"gpt-4\", Model: \"openai/gpt-4\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tshowCurrentModel(cfg)\n\t})\n\n\tassert.Contains(t, output, \"No default model is currently set.\")\n\tassert.Contains(t, output, \"Available models in your config:\")\n}\n\nfunc TestShowCurrentModel_BackwardCompatibility(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModel: \"legacy-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tshowCurrentModel(cfg)\n\t})\n\n\tassert.Contains(t, output, \"Current default model: legacy-model\")\n}\n\nfunc TestListAvailableModels_Empty(t *testing.T) {\n\tcfg := &config.Config{\n\t\tModelList: []config.ModelConfig{},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tlistAvailableModels(cfg)\n\t})\n\n\tassert.Contains(t, output, \"No models configured in model_list\")\n}\n\nfunc TestListAvailableModels_WithModels(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"gpt-4\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"gpt-4\", Model: \"openai/gpt-4\", APIKey: \"test\"},\n\t\t\t{ModelName: \"claude-3\", Model: \"anthropic/claude-3\", APIKey: \"test\"},\n\t\t\t{ModelName: \"no-key-model\", Model: \"openai/test\", APIKey: \"\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tlistAvailableModels(cfg)\n\t})\n\n\tassert.NotEmpty(t, output)\n\tassert.Contains(t, output, \"> - gpt-4 (openai/gpt-4)\")\n\tassert.Contains(t, output, \"claude-3 (anthropic/claude-3)\")\n\tassert.NotContains(t, output, \"no-key-model\")\n}\n\nfunc TestSetDefaultModel_ValidModel(t *testing.T) {\n\tinitTest(t)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"old-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"new-model\", Model: \"openai/new-model\", APIKey: \"test\"},\n\t\t\t{ModelName: \"old-model\", Model: \"openai/old-model\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\terr := setDefaultModel(configPath, cfg, \"new-model\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tassert.Contains(t, output, \"Default model changed from 'old-model' to 'new-model'\")\n\n\t// Verify config was updated\n\tupdatedCfg, err := config.LoadConfig(configPath)\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"new-model\", updatedCfg.Agents.Defaults.ModelName)\n\tassert.Empty(t, updatedCfg.Agents.Defaults.Model)\n}\n\nfunc TestSetDefaultModel_LegacyModelField(t *testing.T) {\n\tinitTest(t)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModel: \"legacy-old\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"new-model\", Model: \"openai/new-model\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\terr := setDefaultModel(configPath, cfg, \"new-model\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tassert.Contains(t, output, \"Default model changed from 'legacy-old' to 'new-model'\")\n}\n\nfunc TestSetDefaultModel_InvalidModel(t *testing.T) {\n\tinitTest(t)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"existing-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"existing-model\", Model: \"openai/existing\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\tassert.Error(t, setDefaultModel(configPath, cfg, \"nonexistent-model\"))\n}\n\nfunc TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {\n\tinitTest(t)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"existing-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"existing-model\", Model: \"openai/existing\", APIKey: \"test\"},\n\t\t\t{ModelName: \"no-key-model\", Model: \"openai/nokey\", APIKey: \"\"},\n\t\t},\n\t}\n\n\tassert.Error(t, setDefaultModel(configPath, cfg, \"no-key-model\"))\n}\n\nfunc TestSetDefaultModel_SaveConfigError(t *testing.T) {\n\t// Use an invalid path to trigger save error\n\tinvalidPath := \"/nonexistent/directory/config.json\"\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"old-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"new-model\", Model: \"openai/new-model\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\terr := setDefaultModel(invalidPath, cfg, \"new-model\")\n\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to save config\")\n}\n\nfunc TestFormatModelName(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"empty string\", \"\", \"(none)\"},\n\t\t{\"simple model\", \"gpt-4\", \"gpt-4\"},\n\t\t{\"model with version\", \"claude-sonnet-4.6\", \"claude-sonnet-4.6\"},\n\t\t{\"model with spaces\", \"my model\", \"my model\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := formatModelName(tt.input)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestModelCommandExecution_Show(t *testing.T) {\n\tinitTest(t)\n\n\t// Create a test config\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"test-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"test-model\", Model: \"openai/test\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\terr := config.SaveConfig(configPath, cfg)\n\trequire.NoError(t, err)\n\n\tcmd := NewModelCommand()\n\n\toutput := captureStdout(func() {\n\t\terr = cmd.RunE(cmd, []string{})\n\t\tassert.NoError(t, err)\n\t})\n\n\tassert.Contains(t, output, \"Current default model: test-model\")\n}\n\nfunc TestModelCommandExecution_Set(t *testing.T) {\n\tinitTest(t)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"old-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"old-model\", Model: \"openai/old\", APIKey: \"test\"},\n\t\t\t{ModelName: \"new-model\", Model: \"openai/new\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\terr := config.SaveConfig(configPath, cfg)\n\trequire.NoError(t, err)\n\n\tcmd := NewModelCommand()\n\n\toutput := captureStdout(func() {\n\t\terr = cmd.RunE(cmd, []string{\"new-model\"})\n\t\tassert.NoError(t, err)\n\t})\n\n\tassert.Contains(t, output, \"Default model changed from 'old-model' to 'new-model'\")\n}\n\nfunc TestModelCommandExecution_TooManyArgs(t *testing.T) {\n\tcmd := NewModelCommand()\n\n\terr := cmd.RunE(cmd, []string{\"model1\", \"model2\"})\n\n\tassert.Error(t, err)\n}\n\nfunc TestListAvailableModels_MarkerLogic(t *testing.T) {\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tModelName: \"middle-model\",\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{ModelName: \"first-model\", Model: \"openai/first\", APIKey: \"test\"},\n\t\t\t{ModelName: \"middle-model\", Model: \"openai/middle\", APIKey: \"test\"},\n\t\t\t{ModelName: \"last-model\", Model: \"openai/last\", APIKey: \"test\"},\n\t\t},\n\t}\n\n\toutput := captureStdout(func() {\n\t\tlistAvailableModels(cfg)\n\t})\n\n\tassert.Contains(t, output, \"  - first-model (openai/first)\")\n\tassert.Contains(t, output, \"> - middle-model (openai/middle)\")\n\tassert.Contains(t, output, \"  - last-model (openai/last)\")\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/onboard/command.go",
    "content": "package onboard\n\nimport (\n\t\"embed\"\n\n\t\"github.com/spf13/cobra\"\n)\n\n//go:generate cp -r ../../../../workspace .\n//go:embed workspace\nvar embeddedFiles embed.FS\n\nfunc NewOnboardCommand() *cobra.Command {\n\tvar encrypt bool\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"onboard\",\n\t\tAliases: []string{\"o\"},\n\t\tShort:   \"Initialize picoclaw configuration and workspace\",\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tonboard(encrypt)\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVar(&encrypt, \"enc\", false,\n\t\t\"Enable credential encryption (generates SSH key and prompts for passphrase)\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/onboard/command_test.go",
    "content": "package onboard\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewOnboardCommand(t *testing.T) {\n\tcmd := NewOnboardCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"onboard\", cmd.Use)\n\tassert.Equal(t, \"Initialize picoclaw configuration and workspace\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 1)\n\tassert.True(t, cmd.HasAlias(\"o\"))\n\n\tassert.NotNil(t, cmd.Run)\n\tassert.Nil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tassert.True(t, cmd.HasFlags())\n\tencFlag := cmd.Flags().Lookup(\"enc\")\n\trequire.NotNil(t, encFlag, \"expected --enc flag to be registered\")\n\tassert.Equal(t, \"false\", encFlag.DefValue, \"--enc should default to false\")\n\tassert.False(t, cmd.HasSubCommands())\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/onboard/helpers.go",
    "content": "package onboard\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"golang.org/x/term\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/credential\"\n)\n\nfunc onboard(encrypt bool) {\n\tconfigPath := internal.GetConfigPath()\n\n\tconfigExists := false\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tconfigExists = true\n\t\tif encrypt {\n\t\t\t// Only ask for confirmation when *both* config and SSH key already exist,\n\t\t\t// indicating a full re-onboard that would reset the config to defaults.\n\t\t\tsshKeyPath, _ := credential.DefaultSSHKeyPath()\n\t\t\tif _, err := os.Stat(sshKeyPath); err == nil {\n\t\t\t\t// Both exist — confirm a full reset.\n\t\t\t\tfmt.Printf(\"Config already exists at %s\\n\", configPath)\n\t\t\t\tfmt.Print(\"Overwrite config with defaults? (y/n): \")\n\t\t\t\tvar response string\n\t\t\t\tfmt.Scanln(&response)\n\t\t\t\tif response != \"y\" {\n\t\t\t\t\tfmt.Println(\"Aborted.\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconfigExists = false // user agreed to reset; treat as fresh\n\t\t\t}\n\t\t\t// Config exists but SSH key is missing — keep existing config, only add SSH key.\n\t\t}\n\t}\n\n\tvar err error\n\tif encrypt {\n\t\tfmt.Println(\"\\nSet up credential encryption\")\n\t\tfmt.Println(\"-----------------------------\")\n\t\tpassphrase, pErr := promptPassphrase()\n\t\tif pErr != nil {\n\t\t\tfmt.Printf(\"Error: %v\\n\", pErr)\n\t\t\tos.Exit(1)\n\t\t}\n\t\t// Expose the passphrase to credential.PassphraseProvider (which calls\n\t\t// os.Getenv by default) so that SaveConfig can encrypt api_keys.\n\t\t// This process is a one-shot CLI tool; the env var is never exposed outside\n\t\t// the current process and disappears when it exits.\n\t\tos.Setenv(credential.PassphraseEnvVar, passphrase)\n\n\t\tif err = setupSSHKey(); err != nil {\n\t\t\tfmt.Printf(\"Error generating SSH key: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tvar cfg *config.Config\n\tif configExists {\n\t\t// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.\n\t\tcfg, err = config.LoadConfig(configPath)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Error loading existing config: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t} else {\n\t\tcfg = config.DefaultConfig()\n\t}\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tfmt.Printf(\"Error saving config: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tworkspace := cfg.WorkspacePath()\n\tcreateWorkspaceTemplates(workspace)\n\n\tfmt.Printf(\"\\n%s picoclaw is ready!\\n\", internal.Logo)\n\tfmt.Println(\"\\nNext steps:\")\n\tif encrypt {\n\t\tfmt.Println(\"  1. Set your encryption passphrase before starting picoclaw:\")\n\t\tfmt.Println(\"       export PICOCLAW_KEY_PASSPHRASE=<your-passphrase>   # Linux/macOS\")\n\t\tfmt.Println(\"       set PICOCLAW_KEY_PASSPHRASE=<your-passphrase>      # Windows cmd\")\n\t\tfmt.Println(\"\")\n\t\tfmt.Println(\"  2. Add your API key to\", configPath)\n\t} else {\n\t\tfmt.Println(\"  1. Add your API key to\", configPath)\n\t}\n\tfmt.Println(\"\")\n\tfmt.Println(\"     Recommended:\")\n\tfmt.Println(\"     - OpenRouter: https://openrouter.ai/keys (access 100+ models)\")\n\tfmt.Println(\"     - Ollama:     https://ollama.com (local, free)\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"     See README.md for 17+ supported providers.\")\n\tfmt.Println(\"\")\n\tfmt.Println(\"  3. Chat: picoclaw agent -m \\\"Hello!\\\"\")\n}\n\n// promptPassphrase reads the encryption passphrase twice from the terminal\n// (with echo disabled) and returns it. Returns an error if the passphrase is\n// empty or if the two inputs do not match.\nfunc promptPassphrase() (string, error) {\n\tfmt.Print(\"Enter passphrase for credential encryption: \")\n\tp1, err := term.ReadPassword(int(os.Stdin.Fd()))\n\tfmt.Println()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"reading passphrase: %w\", err)\n\t}\n\tif len(p1) == 0 {\n\t\treturn \"\", fmt.Errorf(\"passphrase must not be empty\")\n\t}\n\n\tfmt.Print(\"Confirm passphrase: \")\n\tp2, err := term.ReadPassword(int(os.Stdin.Fd()))\n\tfmt.Println()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"reading passphrase confirmation: %w\", err)\n\t}\n\n\tif string(p1) != string(p2) {\n\t\treturn \"\", fmt.Errorf(\"passphrases do not match\")\n\t}\n\treturn string(p1), nil\n}\n\n// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.\n// If the key already exists the user is warned and asked to confirm overwrite.\n// Answering anything other than \"y\" keeps the existing key (not an error).\nfunc setupSSHKey() error {\n\tkeyPath, err := credential.DefaultSSHKeyPath()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"cannot determine SSH key path: %w\", err)\n\t}\n\n\tif _, err := os.Stat(keyPath); err == nil {\n\t\tfmt.Printf(\"\\n⚠️  WARNING: %s already exists.\\n\", keyPath)\n\t\tfmt.Println(\"    Overwriting will invalidate any credentials previously encrypted with this key.\")\n\t\tfmt.Print(\"    Overwrite? (y/n): \")\n\t\tvar response string\n\t\tfmt.Scanln(&response)\n\t\tif response != \"y\" {\n\t\t\tfmt.Println(\"Keeping existing SSH key.\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err := credential.GenerateSSHKey(keyPath); err != nil {\n\t\treturn err\n\t}\n\tfmt.Printf(\"SSH key generated: %s\\n\", keyPath)\n\treturn nil\n}\n\nfunc createWorkspaceTemplates(workspace string) {\n\terr := copyEmbeddedToTarget(workspace)\n\tif err != nil {\n\t\tfmt.Printf(\"Error copying workspace templates: %v\\n\", err)\n\t}\n}\n\nfunc copyEmbeddedToTarget(targetDir string) error {\n\t// Ensure target directory exists\n\tif err := os.MkdirAll(targetDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"Failed to create target directory: %w\", err)\n\t}\n\n\t// Walk through all files in embed.FS\n\terr := fs.WalkDir(embeddedFiles, \"workspace\", func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Skip directories\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Read embedded file\n\t\tdata, err := embeddedFiles.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Failed to read embedded file %s: %w\", path, err)\n\t\t}\n\n\t\tnew_path, err := filepath.Rel(\"workspace\", path)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"Failed to get relative path for %s: %v\\n\", path, err)\n\t\t}\n\n\t\t// Build target file path\n\t\ttargetPath := filepath.Join(targetDir, new_path)\n\n\t\t// Ensure target file's directory exists\n\t\tif err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {\n\t\t\treturn fmt.Errorf(\"Failed to create directory %s: %w\", filepath.Dir(targetPath), err)\n\t\t}\n\n\t\t// Write file\n\t\tif err := os.WriteFile(targetPath, data, 0o644); err != nil {\n\t\t\treturn fmt.Errorf(\"Failed to write file %s: %w\", targetPath, err)\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn err\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/onboard/helpers_test.go",
    "content": "package onboard\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {\n\ttargetDir := t.TempDir()\n\n\tif err := copyEmbeddedToTarget(targetDir); err != nil {\n\t\tt.Fatalf(\"copyEmbeddedToTarget() error = %v\", err)\n\t}\n\n\tagentsPath := filepath.Join(targetDir, \"AGENTS.md\")\n\tif _, err := os.Stat(agentsPath); err != nil {\n\t\tt.Fatalf(\"expected %s to exist: %v\", agentsPath, err)\n\t}\n\n\tlegacyPath := filepath.Join(targetDir, \"AGENT.md\")\n\tif _, err := os.Stat(legacyPath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"expected legacy file %s to be absent, got err=%v\", legacyPath, err)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/command.go",
    "content": "package skills\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\ntype deps struct {\n\tworkspace    string\n\tinstaller    *skills.SkillInstaller\n\tskillsLoader *skills.SkillsLoader\n}\n\nfunc NewSkillsCommand() *cobra.Command {\n\tvar d deps\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"skills\",\n\t\tShort: \"Manage skills\",\n\t\tPersistentPreRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tcfg, err := internal.LoadConfig()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error loading config: %w\", err)\n\t\t\t}\n\n\t\t\td.workspace = cfg.WorkspacePath()\n\t\t\tinstaller, err := skills.NewSkillInstaller(\n\t\t\t\td.workspace,\n\t\t\t\tcfg.Tools.Skills.Github.Token,\n\t\t\t\tcfg.Tools.Skills.Github.Proxy,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error creating skills installer: %w\", err)\n\t\t\t}\n\t\t\td.installer = installer\n\n\t\t\t// get global config directory and builtin skills directory\n\t\t\tglobalDir := filepath.Dir(internal.GetConfigPath())\n\t\t\tglobalSkillsDir := filepath.Join(globalDir, \"skills\")\n\t\t\tbuiltinSkillsDir := filepath.Join(globalDir, \"picoclaw\", \"skills\")\n\t\t\td.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)\n\n\t\t\treturn nil\n\t\t},\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\treturn cmd.Help()\n\t\t},\n\t}\n\n\tinstallerFn := func() (*skills.SkillInstaller, error) {\n\t\tif d.installer == nil {\n\t\t\treturn nil, fmt.Errorf(\"skills installer is not initialized\")\n\t\t}\n\t\treturn d.installer, nil\n\t}\n\n\tloaderFn := func() (*skills.SkillsLoader, error) {\n\t\tif d.skillsLoader == nil {\n\t\t\treturn nil, fmt.Errorf(\"skills loader is not initialized\")\n\t\t}\n\t\treturn d.skillsLoader, nil\n\t}\n\n\tworkspaceFn := func() (string, error) {\n\t\tif d.workspace == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"workspace is not initialized\")\n\t\t}\n\t\treturn d.workspace, nil\n\t}\n\n\tcmd.AddCommand(\n\t\tnewListCommand(loaderFn),\n\t\tnewInstallCommand(installerFn),\n\t\tnewInstallBuiltinCommand(workspaceFn),\n\t\tnewListBuiltinCommand(),\n\t\tnewRemoveCommand(installerFn),\n\t\tnewSearchCommand(),\n\t\tnewShowCommand(loaderFn),\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/command_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewSkillsCommand(t *testing.T) {\n\tcmd := NewSkillsCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"skills\", cmd.Use)\n\tassert.Equal(t, \"Manage skills\", cmd.Short)\n\n\tassert.Len(t, cmd.Aliases, 0)\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.NotNil(t, cmd.PersistentPreRunE)\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/helpers.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst skillsSearchMaxResults = 20\n\nfunc skillsListCmd(loader *skills.SkillsLoader) {\n\tallSkills := loader.ListSkills()\n\n\tif len(allSkills) == 0 {\n\t\tfmt.Println(\"No skills installed.\")\n\t\treturn\n\t}\n\n\tfmt.Println(\"\\nInstalled Skills:\")\n\tfmt.Println(\"------------------\")\n\tfor _, skill := range allSkills {\n\t\tfmt.Printf(\"  ✓ %s (%s)\\n\", skill.Name, skill.Source)\n\t\tif skill.Description != \"\" {\n\t\t\tfmt.Printf(\"    %s\\n\", skill.Description)\n\t\t}\n\t}\n}\n\nfunc skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {\n\tfmt.Printf(\"Installing skill from %s...\\n\", repo)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := installer.InstallFromGitHub(ctx, repo); err != nil {\n\t\treturn fmt.Errorf(\"failed to install skill: %w\", err)\n\t}\n\n\tfmt.Printf(\"\\u2713 Skill '%s' installed successfully!\\n\", filepath.Base(repo))\n\n\treturn nil\n}\n\n// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).\nfunc skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {\n\terr := utils.ValidateSkillIdentifier(registryName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"✗  invalid registry name: %w\", err)\n\t}\n\n\terr = utils.ValidateSkillIdentifier(slug)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"✗  invalid slug: %w\", err)\n\t}\n\n\tfmt.Printf(\"Installing skill '%s' from %s registry...\\n\", slug, registryName)\n\n\tregistryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{\n\t\tMaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,\n\t\tClawHub:               skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),\n\t})\n\n\tregistry := registryMgr.GetRegistry(registryName)\n\tif registry == nil {\n\t\treturn fmt.Errorf(\"✗  registry '%s' not found or not enabled. check your config.json.\", registryName)\n\t}\n\n\tworkspace := cfg.WorkspacePath()\n\ttargetDir := filepath.Join(workspace, \"skills\", slug)\n\n\tif _, err = os.Stat(targetDir); err == nil {\n\t\treturn fmt.Errorf(\"\\u2717 skill '%s' already installed at %s\", slug, targetDir)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tif err = os.MkdirAll(filepath.Join(workspace, \"skills\"), 0o755); err != nil {\n\t\treturn fmt.Errorf(\"\\u2717 failed to create skills directory: %v\", err)\n\t}\n\n\tresult, err := registry.DownloadAndInstall(ctx, slug, \"\", targetDir)\n\tif err != nil {\n\t\trmErr := os.RemoveAll(targetDir)\n\t\tif rmErr != nil {\n\t\t\tfmt.Printf(\"\\u2717 Failed to remove partial install: %v\\n\", rmErr)\n\t\t}\n\t\treturn fmt.Errorf(\"✗ failed to install skill: %w\", err)\n\t}\n\n\tif result.IsMalwareBlocked {\n\t\trmErr := os.RemoveAll(targetDir)\n\t\tif rmErr != nil {\n\t\t\tfmt.Printf(\"\\u2717 Failed to remove partial install: %v\\n\", rmErr)\n\t\t}\n\n\t\treturn fmt.Errorf(\"\\u2717 Skill '%s' is flagged as malicious and cannot be installed.\\n\", slug)\n\t}\n\n\tif result.IsSuspicious {\n\t\tfmt.Printf(\"\\u26a0\\ufe0f  Warning: skill '%s' is flagged as suspicious.\\n\", slug)\n\t}\n\n\tfmt.Printf(\"\\u2713 Skill '%s' v%s installed successfully!\\n\", slug, result.Version)\n\tif result.Summary != \"\" {\n\t\tfmt.Printf(\"  %s\\n\", result.Summary)\n\t}\n\n\treturn nil\n}\n\nfunc skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {\n\tfmt.Printf(\"Removing skill '%s'...\\n\", skillName)\n\n\tif err := installer.Uninstall(skillName); err != nil {\n\t\tfmt.Printf(\"✗ Failed to remove skill: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tfmt.Printf(\"✓ Skill '%s' removed successfully!\\n\", skillName)\n}\n\nfunc skillsInstallBuiltinCmd(workspace string) {\n\tbuiltinSkillsDir := \"./picoclaw/skills\"\n\tworkspaceSkillsDir := filepath.Join(workspace, \"skills\")\n\n\tfmt.Printf(\"Copying builtin skills to workspace...\\n\")\n\n\tskillsToInstall := []string{\n\t\t\"weather\",\n\t\t\"news\",\n\t\t\"stock\",\n\t\t\"calculator\",\n\t}\n\n\tfor _, skillName := range skillsToInstall {\n\t\tbuiltinPath := filepath.Join(builtinSkillsDir, skillName)\n\t\tworkspacePath := filepath.Join(workspaceSkillsDir, skillName)\n\n\t\tif _, err := os.Stat(builtinPath); err != nil {\n\t\t\tfmt.Printf(\"⊘ Builtin skill '%s' not found: %v\\n\", skillName, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := os.MkdirAll(workspacePath, 0o755); err != nil {\n\t\t\tfmt.Printf(\"✗ Failed to create directory for %s: %v\\n\", skillName, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := copyDirectory(builtinPath, workspacePath); err != nil {\n\t\t\tfmt.Printf(\"✗ Failed to copy %s: %v\\n\", skillName, err)\n\t\t}\n\t}\n\n\tfmt.Println(\"\\n✓ All builtin skills installed!\")\n\tfmt.Println(\"Now you can use them in your workspace.\")\n}\n\nfunc skillsListBuiltinCmd() {\n\tcfg, err := internal.LoadConfig()\n\tif err != nil {\n\t\tfmt.Printf(\"Error loading config: %v\\n\", err)\n\t\treturn\n\t}\n\tbuiltinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), \"picoclaw\", \"skills\")\n\n\tfmt.Println(\"\\nAvailable Builtin Skills:\")\n\tfmt.Println(\"-----------------------\")\n\n\tentries, err := os.ReadDir(builtinSkillsDir)\n\tif err != nil {\n\t\tfmt.Printf(\"Error reading builtin skills: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif len(entries) == 0 {\n\t\tfmt.Println(\"No builtin skills available.\")\n\t\treturn\n\t}\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tskillName := entry.Name()\n\t\t\tskillFile := filepath.Join(builtinSkillsDir, skillName, \"SKILL.md\")\n\n\t\t\tdescription := \"No description\"\n\t\t\tif _, err := os.Stat(skillFile); err == nil {\n\t\t\t\tdata, err := os.ReadFile(skillFile)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcontent := string(data)\n\t\t\t\t\tif idx := strings.Index(content, \"\\n\"); idx > 0 {\n\t\t\t\t\t\tfirstLine := content[:idx]\n\t\t\t\t\t\tif strings.Contains(firstLine, \"description:\") {\n\t\t\t\t\t\t\tdescLine := strings.Index(content[idx:], \"\\n\")\n\t\t\t\t\t\t\tif descLine > 0 {\n\t\t\t\t\t\t\t\tdescription = strings.TrimSpace(content[idx+descLine : idx+descLine])\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tstatus := \"✓\"\n\t\t\tfmt.Printf(\"  %s  %s\\n\", status, entry.Name())\n\t\t\tif description != \"\" {\n\t\t\t\tfmt.Printf(\"     %s\\n\", description)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc skillsSearchCmd(query string) {\n\tfmt.Println(\"Searching for available skills...\")\n\n\tcfg, err := internal.LoadConfig()\n\tif err != nil {\n\t\tfmt.Printf(\"✗ Failed to load config: %v\\n\", err)\n\t\treturn\n\t}\n\n\tregistryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{\n\t\tMaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,\n\t\tClawHub:               skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),\n\t})\n\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tresults, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)\n\tif err != nil {\n\t\tfmt.Printf(\"✗ Failed to fetch skills list: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif len(results) == 0 {\n\t\tfmt.Println(\"No skills available.\")\n\t\treturn\n\t}\n\n\tfmt.Printf(\"\\nAvailable Skills (%d):\\n\", len(results))\n\tfmt.Println(\"--------------------\")\n\tfor _, result := range results {\n\t\tfmt.Printf(\"  📦 %s\\n\", result.DisplayName)\n\t\tfmt.Printf(\"     %s\\n\", result.Summary)\n\t\tfmt.Printf(\"     Slug: %s\\n\", result.Slug)\n\t\tfmt.Printf(\"     Registry: %s\\n\", result.RegistryName)\n\t\tif result.Version != \"\" {\n\t\t\tfmt.Printf(\"     Version: %s\\n\", result.Version)\n\t\t}\n\t\tfmt.Println()\n\t}\n}\n\nfunc skillsShowCmd(loader *skills.SkillsLoader, skillName string) {\n\tcontent, ok := loader.LoadSkill(skillName)\n\tif !ok {\n\t\tfmt.Printf(\"✗ Skill '%s' not found\\n\", skillName)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"\\n📦 Skill: %s\\n\", skillName)\n\tfmt.Println(\"----------------------\")\n\tfmt.Println(content)\n}\n\nfunc copyDirectory(src, dst string) error {\n\treturn filepath.Walk(src, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trelPath, err := filepath.Rel(src, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdstPath := filepath.Join(dst, relPath)\n\n\t\tif info.IsDir() {\n\t\t\treturn os.MkdirAll(dstPath, info.Mode())\n\t\t}\n\n\t\tsrcFile, err := os.Open(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer srcFile.Close()\n\n\t\tdstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer dstFile.Close()\n\n\t\t_, err = io.Copy(dstFile, srcFile)\n\t\treturn err\n\t})\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/install.go",
    "content": "package skills\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {\n\tvar registry string\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"install\",\n\t\tShort: \"Install skill from GitHub\",\n\t\tExample: `\npicoclaw skills install sipeed/picoclaw-skills/weather\npicoclaw skills install --registry clawhub github\n`,\n\t\tArgs: func(cmd *cobra.Command, args []string) error {\n\t\t\tif registry != \"\" {\n\t\t\t\tif len(args) != 1 {\n\t\t\t\t\treturn fmt.Errorf(\"when --registry is set, exactly 1 argument is required: <slug>\")\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif len(args) != 1 {\n\t\t\t\treturn fmt.Errorf(\"exactly 1 argument is required: <github>\")\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tinstaller, err := installerFn()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif registry != \"\" {\n\t\t\t\tcfg, err := internal.LoadConfig()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\treturn skillsInstallFromRegistry(cfg, registry, args[0])\n\t\t\t}\n\n\t\t\treturn skillsInstallCmd(installer, args[0])\n\t\t},\n\t}\n\n\tcmd.Flags().StringVar(&registry, \"registry\", \"\", \"Install from registry: --registry <name> <slug>\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/install_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewInstallSubcommand(t *testing.T) {\n\tcmd := newInstallCommand(nil)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"install\", cmd.Use)\n\tassert.Equal(t, \"Install skill from GitHub\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.True(t, cmd.HasFlags())\n\tassert.NotNil(t, cmd.Flags().Lookup(\"registry\"))\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n\nfunc TestInstallCommandArgs(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\targs        []string\n\t\tregistry    string\n\t\texpectError bool\n\t\terrorMsg    string\n\t}{\n\t\t{\n\t\t\tname:        \"no registry, one arg\",\n\t\t\targs:        []string{\"sipeed/picoclaw-skills/weather\"},\n\t\t\tregistry:    \"\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"no registry, no args\",\n\t\t\targs:        []string{},\n\t\t\tregistry:    \"\",\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"exactly 1 argument is required: <github>\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no registry, too many args\",\n\t\t\targs:        []string{\"arg1\", \"arg2\"},\n\t\t\tregistry:    \"\",\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"exactly 1 argument is required: <github>\",\n\t\t},\n\t\t{\n\t\t\tname:        \"with registry, one arg\",\n\t\t\targs:        []string{\"weather-skill\"},\n\t\t\tregistry:    \"clawhub\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"with registry, no args\",\n\t\t\targs:        []string{},\n\t\t\tregistry:    \"clawhub\",\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"when --registry is set, exactly 1 argument is required: <slug>\",\n\t\t},\n\t\t{\n\t\t\tname:        \"with registry, too many args\",\n\t\t\targs:        []string{\"arg1\", \"arg2\"},\n\t\t\tregistry:    \"clawhub\",\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"when --registry is set, exactly 1 argument is required: <slug>\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcmd := newInstallCommand(nil)\n\n\t\t\tif tt.registry != \"\" {\n\t\t\t\trequire.NoError(t, cmd.Flags().Set(\"registry\", tt.registry))\n\t\t\t}\n\n\t\t\terr := cmd.Args(cmd, tt.args)\n\t\t\tif tt.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.Equal(t, tt.errorMsg, err.Error())\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/installbuiltin.go",
    "content": "package skills\n\nimport \"github.com/spf13/cobra\"\n\nfunc newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"install-builtin\",\n\t\tShort:   \"Install all builtin skills to workspace\",\n\t\tExample: `picoclaw skills install-builtin`,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tworkspace, err := workspaceFn()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tskillsInstallBuiltinCmd(workspace)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/installbuiltin_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewInstallbuiltinSubcommand(t *testing.T) {\n\tcmd := newInstallBuiltinCommand(nil)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"install-builtin\", cmd.Use)\n\tassert.Equal(t, \"Install all builtin skills to workspace\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/list.go",
    "content": "package skills\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"list\",\n\t\tShort:   \"List installed skills\",\n\t\tExample: `picoclaw skills list`,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tloader, err := loaderFn()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tskillsListCmd(loader)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/list_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewListSubcommand(t *testing.T) {\n\tcmd := newListCommand(nil)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"list\", cmd.Use)\n\tassert.Equal(t, \"List installed skills\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/listbuiltin.go",
    "content": "package skills\n\nimport \"github.com/spf13/cobra\"\n\nfunc newListBuiltinCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"list-builtin\",\n\t\tShort:   \"List available builtin skills\",\n\t\tExample: `picoclaw skills list-builtin`,\n\t\tRun: func(_ *cobra.Command, _ []string) {\n\t\t\tskillsListBuiltinCmd()\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/listbuiltin_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewListbuiltinSubcommand(t *testing.T) {\n\tcmd := newListBuiltinCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"list-builtin\", cmd.Use)\n\tassert.Equal(t, \"List available builtin skills\", cmd.Short)\n\n\tassert.NotNil(t, cmd.Run)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/remove.go",
    "content": "package skills\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"remove\",\n\t\tAliases: []string{\"rm\", \"uninstall\"},\n\t\tShort:   \"Remove installed skill\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tExample: `picoclaw skills remove weather`,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tinstaller, err := installerFn()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tskillsRemoveCmd(installer, args[0])\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/remove_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewRemoveSubcommand(t *testing.T) {\n\tcmd := newRemoveCommand(nil)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"remove\", cmd.Use)\n\tassert.Equal(t, \"Remove installed skill\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 2)\n\tassert.True(t, cmd.HasAlias(\"rm\"))\n\tassert.True(t, cmd.HasAlias(\"uninstall\"))\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/search.go",
    "content": "package skills\n\nimport (\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newSearchCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"search [query]\",\n\t\tShort: \"Search available skills\",\n\t\tArgs:  cobra.MaximumNArgs(1),\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tquery := \"\"\n\t\t\tif len(args) == 1 {\n\t\t\t\tquery = args[0]\n\t\t\t}\n\t\t\tskillsSearchCmd(query)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/search_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewSearchSubcommand(t *testing.T) {\n\tcmd := newSearchCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"search [query]\", cmd.Use)\n\tassert.Equal(t, \"Search available skills\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.False(t, cmd.HasSubCommands())\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/show.go",
    "content": "package skills\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"show\",\n\t\tShort:   \"Show skill details\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tExample: `picoclaw skills show weather`,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tloader, err := loaderFn()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tskillsShowCmd(loader, args[0])\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/skills/show_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewShowSubcommand(t *testing.T) {\n\tcmd := newShowCommand(nil)\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"show\", cmd.Use)\n\tassert.Equal(t, \"Show skill details\", cmd.Short)\n\n\tassert.Nil(t, cmd.Run)\n\tassert.NotNil(t, cmd.RunE)\n\n\tassert.True(t, cmd.HasExample())\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Len(t, cmd.Aliases, 0)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/status/command.go",
    "content": "package status\n\nimport (\n\t\"github.com/spf13/cobra\"\n)\n\nfunc NewStatusCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"status\",\n\t\tAliases: []string{\"s\"},\n\t\tShort:   \"Show picoclaw status\",\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tstatusCmd()\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/status/command_test.go",
    "content": "package status\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewStatusCommand(t *testing.T) {\n\tcmd := NewStatusCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"status\", cmd.Use)\n\n\tassert.Len(t, cmd.Aliases, 1)\n\tassert.True(t, cmd.HasAlias(\"s\"))\n\n\tassert.Equal(t, \"Show picoclaw status\", cmd.Short)\n\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.NotNil(t, cmd.Run)\n\tassert.Nil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/status/helpers.go",
    "content": "package status\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc statusCmd() {\n\tcfg, err := internal.LoadConfig()\n\tif err != nil {\n\t\tfmt.Printf(\"Error loading config: %v\\n\", err)\n\t\treturn\n\t}\n\n\tconfigPath := internal.GetConfigPath()\n\n\tfmt.Printf(\"%s picoclaw Status\\n\", internal.Logo)\n\tfmt.Printf(\"Version: %s\\n\", config.FormatVersion())\n\tbuild, _ := config.FormatBuildInfo()\n\tif build != \"\" {\n\t\tfmt.Printf(\"Build: %s\\n\", build)\n\t}\n\tfmt.Println()\n\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tfmt.Println(\"Config:\", configPath, \"✓\")\n\t} else {\n\t\tfmt.Println(\"Config:\", configPath, \"✗\")\n\t}\n\n\tworkspace := cfg.WorkspacePath()\n\tif _, err := os.Stat(workspace); err == nil {\n\t\tfmt.Println(\"Workspace:\", workspace, \"✓\")\n\t} else {\n\t\tfmt.Println(\"Workspace:\", workspace, \"✗\")\n\t}\n\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tfmt.Printf(\"Model: %s\\n\", cfg.Agents.Defaults.GetModelName())\n\n\t\thasOpenRouter := cfg.Providers.OpenRouter.APIKey != \"\"\n\t\thasAnthropic := cfg.Providers.Anthropic.APIKey != \"\"\n\t\thasOpenAI := cfg.Providers.OpenAI.APIKey != \"\"\n\t\thasGemini := cfg.Providers.Gemini.APIKey != \"\"\n\t\thasZhipu := cfg.Providers.Zhipu.APIKey != \"\"\n\t\thasQwen := cfg.Providers.Qwen.APIKey != \"\"\n\t\thasGroq := cfg.Providers.Groq.APIKey != \"\"\n\t\thasVLLM := cfg.Providers.VLLM.APIBase != \"\"\n\t\thasMoonshot := cfg.Providers.Moonshot.APIKey != \"\"\n\t\thasDeepSeek := cfg.Providers.DeepSeek.APIKey != \"\"\n\t\thasVolcEngine := cfg.Providers.VolcEngine.APIKey != \"\"\n\t\thasNvidia := cfg.Providers.Nvidia.APIKey != \"\"\n\t\thasOllama := cfg.Providers.Ollama.APIBase != \"\"\n\n\t\tstatus := func(enabled bool) string {\n\t\t\tif enabled {\n\t\t\t\treturn \"✓\"\n\t\t\t}\n\t\t\treturn \"not set\"\n\t\t}\n\t\tfmt.Println(\"OpenRouter API:\", status(hasOpenRouter))\n\t\tfmt.Println(\"Anthropic API:\", status(hasAnthropic))\n\t\tfmt.Println(\"OpenAI API:\", status(hasOpenAI))\n\t\tfmt.Println(\"Gemini API:\", status(hasGemini))\n\t\tfmt.Println(\"Zhipu API:\", status(hasZhipu))\n\t\tfmt.Println(\"Qwen API:\", status(hasQwen))\n\t\tfmt.Println(\"Groq API:\", status(hasGroq))\n\t\tfmt.Println(\"Moonshot API:\", status(hasMoonshot))\n\t\tfmt.Println(\"DeepSeek API:\", status(hasDeepSeek))\n\t\tfmt.Println(\"VolcEngine API:\", status(hasVolcEngine))\n\t\tfmt.Println(\"Nvidia API:\", status(hasNvidia))\n\t\tif hasVLLM {\n\t\t\tfmt.Printf(\"vLLM/Local: ✓ %s\\n\", cfg.Providers.VLLM.APIBase)\n\t\t} else {\n\t\t\tfmt.Println(\"vLLM/Local: not set\")\n\t\t}\n\t\tif hasOllama {\n\t\t\tfmt.Printf(\"Ollama: ✓ %s\\n\", cfg.Providers.Ollama.APIBase)\n\t\t} else {\n\t\t\tfmt.Println(\"Ollama: not set\")\n\t\t}\n\n\t\tstore, _ := auth.LoadStore()\n\t\tif store != nil && len(store.Credentials) > 0 {\n\t\t\tfmt.Println(\"\\nOAuth/Token Auth:\")\n\t\t\tfor provider, cred := range store.Credentials {\n\t\t\t\tstatus := \"authenticated\"\n\t\t\t\tif cred.IsExpired() {\n\t\t\t\t\tstatus = \"expired\"\n\t\t\t\t} else if cred.NeedsRefresh() {\n\t\t\t\t\tstatus = \"needs refresh\"\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"  %s (%s): %s\\n\", provider, cred.AuthMethod, status)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/version/command.go",
    "content": "package version\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc NewVersionCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"version\",\n\t\tAliases: []string{\"v\"},\n\t\tShort:   \"Show version information\",\n\t\tRun: func(_ *cobra.Command, _ []string) {\n\t\t\tprintVersion()\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc printVersion() {\n\tfmt.Printf(\"%s picoclaw %s\\n\", internal.Logo, config.FormatVersion())\n\tbuild, goVer := config.FormatBuildInfo()\n\tif build != \"\" {\n\t\tfmt.Printf(\"  Build: %s\\n\", build)\n\t}\n\tif goVer != \"\" {\n\t\tfmt.Printf(\"  Go: %s\\n\", goVer)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/internal/version/command_test.go",
    "content": "package version\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewVersionCommand(t *testing.T) {\n\tcmd := NewVersionCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tassert.Equal(t, \"version\", cmd.Use)\n\n\tassert.Len(t, cmd.Aliases, 1)\n\tassert.True(t, cmd.HasAlias(\"v\"))\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Equal(t, \"Show version information\", cmd.Short)\n\n\tassert.False(t, cmd.HasSubCommands())\n\n\tassert.NotNil(t, cmd.Run)\n\tassert.Nil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n}\n"
  },
  {
    "path": "cmd/picoclaw/main.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status\"\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc NewPicoclawCommand() *cobra.Command {\n\tshort := fmt.Sprintf(\"%s picoclaw - Personal AI Assistant v%s\\n\\n\", internal.Logo, config.GetVersion())\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"picoclaw\",\n\t\tShort:   short,\n\t\tExample: \"picoclaw version\",\n\t}\n\n\tcmd.AddCommand(\n\t\tonboard.NewOnboardCommand(),\n\t\tagent.NewAgentCommand(),\n\t\tauth.NewAuthCommand(),\n\t\tgateway.NewGatewayCommand(),\n\t\tstatus.NewStatusCommand(),\n\t\tcron.NewCronCommand(),\n\t\tmigrate.NewMigrateCommand(),\n\t\tskills.NewSkillsCommand(),\n\t\tmodel.NewModelCommand(),\n\t\tversion.NewVersionCommand(),\n\t)\n\n\treturn cmd\n}\n\nconst (\n\tcolorBlue = \"\\033[1;38;2;62;93;185m\"\n\tcolorRed  = \"\\033[1;38;2;213;70;70m\"\n\tbanner    = \"\\r\\n\" +\n\t\tcolorBlue + \"██████╗ ██╗ ██████╗ ██████╗ \" + colorRed + \" ██████╗██╗      █████╗ ██╗    ██╗\\n\" +\n\t\tcolorBlue + \"██╔══██╗██║██╔════╝██╔═══██╗\" + colorRed + \"██╔════╝██║     ██╔══██╗██║    ██║\\n\" +\n\t\tcolorBlue + \"██████╔╝██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ███████║██║ █╗ ██║\\n\" +\n\t\tcolorBlue + \"██╔═══╝ ██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ██╔══██║██║███╗██║\\n\" +\n\t\tcolorBlue + \"██║     ██║╚██████╗╚██████╔╝\" + colorRed + \"╚██████╗███████╗██║  ██║╚███╔███╔╝\\n\" +\n\t\tcolorBlue + \"╚═╝     ╚═╝ ╚═════╝ ╚═════╝ \" + colorRed + \" ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝\\n \" +\n\t\t\"\\033[0m\\r\\n\"\n)\n\nfunc main() {\n\tfmt.Printf(\"%s\", banner)\n\tcmd := NewPicoclawCommand()\n\tif err := cmd.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw/main_test.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestNewPicoclawCommand(t *testing.T) {\n\tcmd := NewPicoclawCommand()\n\n\trequire.NotNil(t, cmd)\n\n\tshort := fmt.Sprintf(\"%s picoclaw - Personal AI Assistant v%s\\n\\n\", internal.Logo, config.GetVersion())\n\n\tassert.Equal(t, \"picoclaw\", cmd.Use)\n\tassert.Equal(t, short, cmd.Short)\n\n\tassert.True(t, cmd.HasSubCommands())\n\tassert.True(t, cmd.HasAvailableSubCommands())\n\n\tassert.False(t, cmd.HasFlags())\n\n\tassert.Nil(t, cmd.Run)\n\tassert.Nil(t, cmd.RunE)\n\n\tassert.Nil(t, cmd.PersistentPreRun)\n\tassert.Nil(t, cmd.PersistentPostRun)\n\n\tallowedCommands := []string{\n\t\t\"agent\",\n\t\t\"auth\",\n\t\t\"cron\",\n\t\t\"gateway\",\n\t\t\"migrate\",\n\t\t\"model\",\n\t\t\"onboard\",\n\t\t\"skills\",\n\t\t\"status\",\n\t\t\"version\",\n\t}\n\n\tsubcommands := cmd.Commands()\n\tassert.Len(t, subcommands, len(allowedCommands))\n\n\tfor _, subcmd := range subcommands {\n\t\tfound := slices.Contains(allowedCommands, subcmd.Name())\n\t\tassert.True(t, found, \"unexpected subcommand %q\", subcmd.Name())\n\n\t\tassert.False(t, subcmd.Hidden)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/config/store.go",
    "content": "package configstore\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\n\tpicoclawconfig \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nconst (\n\tconfigDirName  = \".picoclaw\"\n\tconfigFileName = \"config.json\"\n)\n\nfunc ConfigPath() (string, error) {\n\tdir, err := ConfigDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(dir, configFileName), nil\n}\n\nfunc ConfigDir() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, configDirName), nil\n}\n\nfunc Load() (*picoclawconfig.Config, error) {\n\tpath, err := ConfigPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn picoclawconfig.LoadConfig(path)\n}\n\nfunc Save(cfg *picoclawconfig.Config) error {\n\tif cfg == nil {\n\t\treturn errors.New(\"config is nil\")\n\t}\n\tpath, err := ConfigPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn picoclawconfig.SaveConfig(path, cfg)\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/app.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/gdamore/tcell/v2\"\n\t\"github.com/rivo/tview\"\n\n\tconfigstore \"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config\"\n\tpicoclawconfig \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\ntype appState struct {\n\tapp         *tview.Application\n\tpages       *tview.Pages\n\tstack       []string\n\tconfig      *picoclawconfig.Config\n\tconfigPath  string\n\tgatewayCmd  *exec.Cmd\n\tmenus       map[string]*Menu\n\toriginal    []byte\n\thasOriginal bool\n\tbackupPath  string\n\tdirty       bool\n\tlogPath     string\n}\n\nfunc Run() error {\n\tapplyStyles()\n\tcfg, err := configstore.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tpath, err := configstore.ConfigPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif cfg == nil {\n\t\tcfg = picoclawconfig.DefaultConfig()\n\t}\n\n\toriginalData, hasOriginal := loadOriginalConfig(path)\n\tbackupPath := path + \".bak\"\n\tif hasOriginal {\n\t\t_ = writeBackupConfig(backupPath, originalData)\n\t}\n\n\tlogPath := filepath.Join(filepath.Dir(path), \"gateway.log\")\n\tstate := &appState{\n\t\tapp:         tview.NewApplication(),\n\t\tpages:       tview.NewPages(),\n\t\tconfig:      cfg,\n\t\tconfigPath:  path,\n\t\tmenus:       map[string]*Menu{},\n\t\toriginal:    originalData,\n\t\thasOriginal: hasOriginal,\n\t\tbackupPath:  backupPath,\n\t\tlogPath:     logPath,\n\t}\n\n\tstate.push(\"main\", state.mainMenu())\n\n\troot := tview.NewFlex().SetDirection(tview.FlexRow)\n\troot.AddItem(bannerView(), 6, 0, false)\n\troot.AddItem(state.pages, 0, 1, true)\n\troot.AddItem(footerView(), 1, 0, false)\n\n\tif err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *appState) push(name string, primitive tview.Primitive) {\n\ts.pages.AddPage(name, primitive, true, true)\n\ts.stack = append(s.stack, name)\n\ts.pages.SwitchToPage(name)\n\tif menu, ok := primitive.(*Menu); ok {\n\t\ts.menus[name] = menu\n\t}\n}\n\nfunc (s *appState) pop() {\n\tif len(s.stack) == 0 {\n\t\treturn\n\t}\n\tlast := s.stack[len(s.stack)-1]\n\ts.pages.RemovePage(last)\n\ts.stack = s.stack[:len(s.stack)-1]\n\tif len(s.stack) == 0 {\n\t\ts.app.Stop()\n\t\treturn\n\t}\n\tcurrent := s.stack[len(s.stack)-1]\n\ts.pages.SwitchToPage(current)\n\tif menu, ok := s.menus[current]; ok {\n\t\ts.refreshMenu(current, menu)\n\t}\n}\n\nfunc (s *appState) mainMenu() tview.Primitive {\n\tmenu := NewMenu(\"Menu\", nil)\n\trefreshMainMenu(menu, s)\n\tmenu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tswitch event.Key() {\n\t\tcase tcell.KeyEsc:\n\t\t\ts.requestExit()\n\t\t\treturn nil\n\t\t}\n\n\t\treturn event\n\t})\n\n\treturn menu\n}\n\nfunc (s *appState) refreshMenu(name string, menu *Menu) {\n\tswitch name {\n\tcase \"main\":\n\t\trefreshMainMenu(menu, s)\n\tcase \"model\":\n\t\trefreshModelMenuFromState(menu, s)\n\tcase \"channel\":\n\t\trefreshChannelMenuFromState(menu, s)\n\t}\n}\n\nfunc (s *appState) countChannels() (enabled int, total int) {\n\tc := s.config.Channels\n\tentries := []bool{\n\t\tc.Telegram.Enabled,\n\t\tc.Discord.Enabled,\n\t\tc.QQ.Enabled,\n\t\tc.MaixCam.Enabled,\n\t\tc.WhatsApp.Enabled,\n\t\tc.Feishu.Enabled,\n\t\tc.DingTalk.Enabled,\n\t\tc.Slack.Enabled,\n\t\tc.Matrix.Enabled,\n\t\tc.LINE.Enabled,\n\t\tc.OneBot.Enabled,\n\t\tc.WeCom.Enabled,\n\t\tc.WeComApp.Enabled,\n\t}\n\ttotal = len(entries)\n\tfor _, v := range entries {\n\t\tif v {\n\t\t\tenabled++\n\t\t}\n\t}\n\treturn enabled, total\n}\n\nfunc refreshMainMenuIfPresent(s *appState) {\n\tif menu, ok := s.menus[\"main\"]; ok {\n\t\trefreshMainMenu(menu, s)\n\t}\n}\n\nfunc refreshMainMenu(menu *Menu, s *appState) {\n\tselectedModel := s.selectedModelName()\n\tmodelReady := selectedModel != \"\"\n\tchannelReady := s.hasEnabledChannel()\n\tenabledCount, totalChannels := s.countChannels()\n\tgatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()\n\n\tgatewayLabel := \"Start Gateway\"\n\tgatewayDescription := \"Launch gateway for channels\"\n\tif gatewayRunning {\n\t\tgatewayLabel = \"Stop Gateway\"\n\t\tgatewayDescription = \"Gateway running\"\n\t}\n\n\titems := []MenuItem{\n\t\t{\n\t\t\tLabel:       rootModelLabel(selectedModel),\n\t\t\tDescription: rootModelDescription(),\n\t\t\tAction: func() {\n\t\t\t\ts.push(\"model\", s.modelMenu())\n\t\t\t},\n\t\t\tMainColor: func() *tcell.Color {\n\t\t\t\tif modelReady {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tcolor := tcell.ColorGray\n\t\t\t\treturn &color\n\t\t\t}(),\n\t\t},\n\t\t{\n\t\t\tLabel:       rootChannelLabel(channelReady),\n\t\t\tDescription: fmt.Sprintf(\"%d/%d enabled\", enabledCount, totalChannels),\n\t\t\tAction: func() {\n\t\t\t\ts.push(\"channel\", s.channelMenu())\n\t\t\t},\n\t\t\tMainColor: func() *tcell.Color {\n\t\t\t\tif channelReady {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tcolor := tcell.ColorGray\n\t\t\t\treturn &color\n\t\t\t}(),\n\t\t},\n\t\t{\n\t\t\tLabel:       \"Start Talk\",\n\t\t\tDescription: \"Open picoclaw agent in terminal\",\n\t\t\tAction: func() {\n\t\t\t\ts.requestStartTalk()\n\t\t\t},\n\t\t\tDisabled: !modelReady,\n\t\t},\n\t\t{\n\t\t\tLabel:       gatewayLabel,\n\t\t\tDescription: gatewayDescription,\n\t\t\tAction: func() {\n\t\t\t\tif gatewayRunning {\n\t\t\t\t\ts.stopGateway()\n\t\t\t\t} else {\n\t\t\t\t\ts.requestStartGateway()\n\t\t\t\t}\n\t\t\t\trefreshMainMenu(menu, s)\n\t\t\t},\n\t\t\tDisabled: !gatewayRunning && (!modelReady || !channelReady),\n\t\t},\n\t\t{\n\t\t\tLabel:       \"View Gateway Log\",\n\t\t\tDescription: \"Open gateway.log\",\n\t\t\tAction: func() {\n\t\t\t\ts.viewGatewayLog()\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tLabel:       \"Exit\",\n\t\t\tDescription: \"Exit the TUI\",\n\t\t\tAction: func() {\n\t\t\t\ts.requestExit()\n\t\t\t},\n\t\t},\n\t}\n\tmenu.applyItems(items)\n}\n\nfunc (s *appState) applyChangesValidated() bool {\n\tif err := s.config.ValidateModelList(); err != nil {\n\t\ts.showMessage(\"Validation failed\", err.Error())\n\t\treturn false\n\t}\n\tif err := s.validateAgentModel(); err != nil {\n\t\ts.showMessage(\"Validation failed\", err.Error())\n\t\treturn false\n\t}\n\tif err := configstore.Save(s.config); err != nil {\n\t\ts.showMessage(\"Save failed\", err.Error())\n\t\treturn false\n\t}\n\tif data, err := os.ReadFile(s.configPath); err == nil {\n\t\ts.original = data\n\t\ts.hasOriginal = true\n\t\t_ = writeBackupConfig(s.backupPath, data)\n\t}\n\treturn true\n}\n\nfunc (s *appState) requestExit() {\n\tif s.dirty {\n\t\ts.confirmApplyOrDiscard(func() {\n\t\t\ts.app.Stop()\n\t\t}, func() {\n\t\t\ts.discardChanges()\n\t\t\ts.app.Stop()\n\t\t})\n\t\treturn\n\t}\n\ts.app.Stop()\n}\n\nfunc (s *appState) requestStartTalk() {\n\tif s.dirty {\n\t\ts.confirmApplyOrDiscard(func() {\n\t\t\ts.startTalk()\n\t\t}, func() {\n\t\t\ts.startTalk()\n\t\t})\n\t\treturn\n\t}\n\ts.startTalk()\n}\n\nfunc (s *appState) requestStartGateway() {\n\tif s.dirty {\n\t\ts.confirmApplyOrDiscard(func() {\n\t\t\ts.startGateway()\n\t\t}, func() {\n\t\t\ts.startGateway()\n\t\t})\n\t\treturn\n\t}\n\ts.startGateway()\n}\n\nfunc (s *appState) viewGatewayLog() {\n\tdata, err := os.ReadFile(s.logPath)\n\tif err != nil {\n\t\ts.showMessage(\"Log not found\", \"gateway.log not found\")\n\t\treturn\n\t}\n\ttext := tview.NewTextView()\n\ttext.SetBorder(true).SetTitle(\"Gateway Log\")\n\ttext.SetText(string(data))\n\ttext.SetDoneFunc(func(key tcell.Key) {\n\t\ts.pages.RemovePage(\"log\")\n\t})\n\ttext.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tif event.Key() == tcell.KeyEsc {\n\t\t\ts.pages.RemovePage(\"log\")\n\t\t\treturn nil\n\t\t}\n\t\treturn event\n\t})\n\ts.pages.AddPage(\"log\", text, true, true)\n}\n\nfunc (s *appState) selectedModelName() string {\n\tmodelName := strings.TrimSpace(s.config.Agents.Defaults.Model)\n\tif modelName == \"\" {\n\t\treturn \"\"\n\t}\n\tif !s.isActiveModelValid() {\n\t\treturn \"\"\n\t}\n\treturn modelName\n}\n\nfunc rootModelLabel(selected string) string {\n\tif selected == \"\" {\n\t\treturn \"Model (None)\"\n\t}\n\treturn \"Model (\" + selected + \")\"\n}\n\nfunc rootModelDescription() string {\n\treturn \"Using SPACE to choose your model\"\n}\n\nfunc rootChannelLabel(valid bool) string {\n\tif !valid {\n\t\treturn \"Channel (no channel enabled)\"\n\t}\n\treturn \"Channel\"\n}\n\nfunc (s *appState) startTalk() {\n\tif !s.isActiveModelValid() {\n\t\ts.showMessage(\"Model required\", \"Select a valid model before starting talk\")\n\t\treturn\n\t}\n\tif !s.applyChangesValidated() {\n\t\treturn\n\t}\n\ts.app.Suspend(func() {\n\t\tcmd := exec.Command(\"picoclaw\", \"agent\")\n\t\tcmd.Stdin = os.Stdin\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\t\t_ = cmd.Run()\n\t})\n}\n\nfunc (s *appState) startGateway() {\n\tif !s.isActiveModelValid() {\n\t\ts.showMessage(\"Model required\", \"Select a valid model before starting gateway\")\n\t\treturn\n\t}\n\tif !s.hasEnabledChannel() {\n\t\ts.showMessage(\"Channel required\", \"Enable at least one channel before starting gateway\")\n\t\treturn\n\t}\n\tif !s.applyChangesValidated() {\n\t\treturn\n\t}\n\t_ = stopGatewayProcess()\n\tcmd := exec.Command(\"picoclaw\", \"gateway\")\n\tlogFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)\n\tif err != nil {\n\t\ts.showMessage(\"Gateway failed\", err.Error())\n\t\treturn\n\t}\n\tcmd.Stdout = logFile\n\tcmd.Stderr = logFile\n\tif err := cmd.Start(); err != nil {\n\t\ts.showMessage(\"Gateway failed\", err.Error())\n\t\t_ = logFile.Close()\n\t\treturn\n\t}\n\t_ = logFile.Close()\n\ts.gatewayCmd = cmd\n}\n\nfunc (s *appState) stopGateway() {\n\t_ = stopGatewayProcess()\n\tif s.gatewayCmd != nil && s.gatewayCmd.Process != nil {\n\t\t_ = s.gatewayCmd.Process.Kill()\n\t}\n\ts.gatewayCmd = nil\n}\n\nfunc (s *appState) isGatewayRunning() bool {\n\treturn isGatewayProcessRunning()\n}\n\nfunc (s *appState) validateAgentModel() error {\n\tmodelName := strings.TrimSpace(s.config.Agents.Defaults.Model)\n\tif modelName == \"\" {\n\t\treturn nil\n\t}\n\t_, err := s.config.GetModelConfig(modelName)\n\treturn err\n}\n\nfunc (s *appState) isActiveModelValid() bool {\n\tmodelName := strings.TrimSpace(s.config.Agents.Defaults.Model)\n\tif modelName == \"\" {\n\t\treturn false\n\t}\n\tcfg, err := s.config.GetModelConfig(modelName)\n\tif err != nil {\n\t\treturn false\n\t}\n\thasKey := strings.TrimSpace(cfg.APIKey) != \"\" || strings.TrimSpace(cfg.AuthMethod) == \"oauth\"\n\thasModel := strings.TrimSpace(cfg.Model) != \"\"\n\treturn hasKey && hasModel\n}\n\nfunc (s *appState) hasEnabledChannel() bool {\n\tc := s.config.Channels\n\treturn c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||\n\t\tc.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||\n\t\tc.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled\n}\n\nfunc (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {\n\tif s.pages.HasPage(\"apply\") {\n\t\treturn\n\t}\n\tmodal := tview.NewModal().\n\t\tSetText(\"Apply changes or discard before continuing?\").\n\t\tAddButtons([]string{\"Cancel\", \"Discard\", \"Apply\"}).\n\t\tSetDoneFunc(func(buttonIndex int, buttonLabel string) {\n\t\t\ts.pages.RemovePage(\"apply\")\n\t\t\tswitch buttonLabel {\n\t\t\tcase \"Discard\":\n\t\t\t\ts.discardChanges()\n\t\t\t\tif onDiscard != nil {\n\t\t\t\t\tonDiscard()\n\t\t\t\t}\n\t\t\tcase \"Apply\":\n\t\t\t\tif s.applyChangesValidated() {\n\t\t\t\t\ts.dirty = false\n\t\t\t\t\tif onApply != nil {\n\t\t\t\t\t\tonApply()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\tmodal.SetBorder(true)\n\ts.pages.AddPage(\"apply\", modal, true, true)\n}\n\nfunc (s *appState) discardChanges() {\n\tif s.hasOriginal {\n\t\t_ = writeOriginalConfig(s.configPath, s.original)\n\t} else {\n\t\t_ = os.Remove(s.configPath)\n\t}\n\t_ = os.Remove(s.backupPath)\n\tif cfg, err := configstore.Load(); err == nil && cfg != nil {\n\t\ts.config = cfg\n\t}\n\ts.dirty = false\n\trefreshMainMenuIfPresent(s)\n}\n\nfunc (s *appState) showMessage(title, message string) {\n\tif s.pages.HasPage(\"message\") {\n\t\treturn\n\t}\n\tmodal := tview.NewModal().\n\t\tSetText(strings.TrimSpace(message)).\n\t\tAddButtons([]string{\"OK\"}).\n\t\tSetDoneFunc(func(_ int, _ string) {\n\t\t\ts.pages.RemovePage(\"message\")\n\t\t})\n\tmodal.SetTitle(title).SetBorder(true)\n\tmodal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)\n\tmodal.SetTextColor(tview.Styles.PrimaryTextColor)\n\tmodal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))\n\tmodal.SetButtonTextColor(tview.Styles.PrimaryTextColor)\n\ts.pages.AddPage(\"message\", modal, true, true)\n}\n\nfunc loadOriginalConfig(path string) ([]byte, bool) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, false\n\t\t}\n\t\treturn nil, false\n\t}\n\treturn data, true\n}\n\nfunc writeOriginalConfig(path string, data []byte) error {\n\treturn os.WriteFile(path, data, 0o600)\n}\n\nfunc writeBackupConfig(path string, data []byte) error {\n\treturn os.WriteFile(path, data, 0o600)\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/channel.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/gdamore/tcell/v2\"\n\t\"github.com/rivo/tview\"\n\n\tpicoclawconfig \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc (s *appState) buildChannelMenuItems() []MenuItem {\n\treturn []MenuItem{\n\t\tchannelItem(\n\t\t\t\"Telegram\",\n\t\t\t\"Telegram bot settings\",\n\t\t\ts.config.Channels.Telegram.Enabled,\n\t\t\tfunc() { s.push(\"channel-telegram\", s.telegramForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"Discord\",\n\t\t\t\"Discord bot settings\",\n\t\t\ts.config.Channels.Discord.Enabled,\n\t\t\tfunc() { s.push(\"channel-discord\", s.discordForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"QQ\",\n\t\t\t\"QQ bot settings\",\n\t\t\ts.config.Channels.QQ.Enabled,\n\t\t\tfunc() { s.push(\"channel-qq\", s.qqForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"MaixCam\",\n\t\t\t\"MaixCam gateway\",\n\t\t\ts.config.Channels.MaixCam.Enabled,\n\t\t\tfunc() { s.push(\"channel-maixcam\", s.maixcamForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"WhatsApp\",\n\t\t\t\"WhatsApp bridge\",\n\t\t\ts.config.Channels.WhatsApp.Enabled,\n\t\t\tfunc() { s.push(\"channel-whatsapp\", s.whatsappForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"Feishu\",\n\t\t\t\"Feishu bot settings\",\n\t\t\ts.config.Channels.Feishu.Enabled,\n\t\t\tfunc() { s.push(\"channel-feishu\", s.feishuForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"DingTalk\",\n\t\t\t\"DingTalk bot settings\",\n\t\t\ts.config.Channels.DingTalk.Enabled,\n\t\t\tfunc() { s.push(\"channel-dingtalk\", s.dingtalkForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"Slack\",\n\t\t\t\"Slack bot settings\",\n\t\t\ts.config.Channels.Slack.Enabled,\n\t\t\tfunc() { s.push(\"channel-slack\", s.slackForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"Matrix\",\n\t\t\t\"Matrix bot settings\",\n\t\t\ts.config.Channels.Matrix.Enabled,\n\t\t\tfunc() { s.push(\"channel-matrix\", s.matrixForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"LINE\",\n\t\t\t\"LINE bot settings\",\n\t\t\ts.config.Channels.LINE.Enabled,\n\t\t\tfunc() { s.push(\"channel-line\", s.lineForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"OneBot\",\n\t\t\t\"OneBot settings\",\n\t\t\ts.config.Channels.OneBot.Enabled,\n\t\t\tfunc() { s.push(\"channel-onebot\", s.onebotForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"WeCom\",\n\t\t\t\"WeCom bot settings\",\n\t\t\ts.config.Channels.WeCom.Enabled,\n\t\t\tfunc() { s.push(\"channel-wecom\", s.wecomForm()) },\n\t\t),\n\t\tchannelItem(\n\t\t\t\"WeCom App\",\n\t\t\t\"WeCom App settings\",\n\t\t\ts.config.Channels.WeComApp.Enabled,\n\t\t\tfunc() { s.push(\"channel-wecomapp\", s.wecomAppForm()) },\n\t\t),\n\t}\n}\n\nfunc (s *appState) channelMenu() tview.Primitive {\n\tmenu := NewMenu(\"Channels\", s.buildChannelMenuItems())\n\tmenu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tif event.Key() == tcell.KeyEsc {\n\t\t\ts.pop()\n\t\t\treturn nil\n\t\t}\n\t\treturn event\n\t})\n\treturn menu\n}\n\nfunc refreshChannelMenuFromState(menu *Menu, s *appState) {\n\tmenu.applyItems(s.buildChannelMenuItems())\n}\n\nfunc (s *appState) telegramForm() tview.Primitive {\n\tcfg := &s.config.Channels.Telegram\n\tform := baseChannelForm(\"Telegram\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Token\", cfg.Token, 128, nil, func(text string) {\n\t\tcfg.Token = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Proxy\", cfg.Proxy, 128, nil, func(text string) {\n\t\tcfg.Proxy = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) discordForm() tview.Primitive {\n\tcfg := &s.config.Channels.Discord\n\tform := baseChannelForm(\"Discord\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Token\", cfg.Token, 128, nil, func(text string) {\n\t\tcfg.Token = strings.TrimSpace(text)\n\t})\n\tform.AddCheckbox(\"Mention Only\", cfg.MentionOnly, func(checked bool) {\n\t\tcfg.MentionOnly = checked\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) qqForm() tview.Primitive {\n\tcfg := &s.config.Channels.QQ\n\tform := baseChannelForm(\"QQ\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"App ID\", cfg.AppID, 64, nil, func(text string) {\n\t\tcfg.AppID = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"App Secret\", cfg.AppSecret, 128, nil, func(text string) {\n\t\tcfg.AppSecret = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) maixcamForm() tview.Primitive {\n\tcfg := &s.config.Channels.MaixCam\n\tform := baseChannelForm(\"MaixCam\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Host\", cfg.Host, 64, nil, func(text string) {\n\t\tcfg.Host = strings.TrimSpace(text)\n\t})\n\taddIntField(form, \"Port\", cfg.Port, func(value int) { cfg.Port = value })\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) whatsappForm() tview.Primitive {\n\tcfg := &s.config.Channels.WhatsApp\n\tform := baseChannelForm(\"WhatsApp\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Bridge URL\", cfg.BridgeURL, 128, nil, func(text string) {\n\t\tcfg.BridgeURL = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) feishuForm() tview.Primitive {\n\tcfg := &s.config.Channels.Feishu\n\tform := baseChannelForm(\"Feishu\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"App ID\", cfg.AppID, 64, nil, func(text string) {\n\t\tcfg.AppID = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"App Secret\", cfg.AppSecret, 128, nil, func(text string) {\n\t\tcfg.AppSecret = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Encrypt Key\", cfg.EncryptKey, 128, nil, func(text string) {\n\t\tcfg.EncryptKey = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Verification Token\", cfg.VerificationToken, 128, nil, func(text string) {\n\t\tcfg.VerificationToken = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) dingtalkForm() tview.Primitive {\n\tcfg := &s.config.Channels.DingTalk\n\tform := baseChannelForm(\"DingTalk\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Client ID\", cfg.ClientID, 64, nil, func(text string) {\n\t\tcfg.ClientID = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Client Secret\", cfg.ClientSecret, 128, nil, func(text string) {\n\t\tcfg.ClientSecret = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) slackForm() tview.Primitive {\n\tcfg := &s.config.Channels.Slack\n\tform := baseChannelForm(\"Slack\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Bot Token\", cfg.BotToken, 128, nil, func(text string) {\n\t\tcfg.BotToken = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"App Token\", cfg.AppToken, 128, nil, func(text string) {\n\t\tcfg.AppToken = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) lineForm() tview.Primitive {\n\tcfg := &s.config.Channels.LINE\n\tform := baseChannelForm(\"LINE\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Channel Secret\", cfg.ChannelSecret, 128, nil, func(text string) {\n\t\tcfg.ChannelSecret = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Channel Access Token\", cfg.ChannelAccessToken, 128, nil, func(text string) {\n\t\tcfg.ChannelAccessToken = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Webhook Host\", cfg.WebhookHost, 64, nil, func(text string) {\n\t\tcfg.WebhookHost = strings.TrimSpace(text)\n\t})\n\taddIntField(form, \"Webhook Port\", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })\n\tform.AddInputField(\"Webhook Path\", cfg.WebhookPath, 64, nil, func(text string) {\n\t\tcfg.WebhookPath = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) matrixForm() tview.Primitive {\n\tcfg := &s.config.Channels.Matrix\n\tform := baseChannelForm(\"Matrix\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Homeserver\", cfg.Homeserver, 128, nil, func(text string) {\n\t\tcfg.Homeserver = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"User ID\", cfg.UserID, 128, nil, func(text string) {\n\t\tcfg.UserID = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Access Token\", cfg.AccessToken, 128, nil, func(text string) {\n\t\tcfg.AccessToken = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Device ID\", cfg.DeviceID, 128, nil, func(text string) {\n\t\tcfg.DeviceID = strings.TrimSpace(text)\n\t})\n\tform.AddCheckbox(\"Join On Invite\", cfg.JoinOnInvite, func(checked bool) {\n\t\tcfg.JoinOnInvite = checked\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) onebotForm() tview.Primitive {\n\tcfg := &s.config.Channels.OneBot\n\tform := baseChannelForm(\"OneBot\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"WS URL\", cfg.WSUrl, 128, nil, func(text string) {\n\t\tcfg.WSUrl = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Access Token\", cfg.AccessToken, 128, nil, func(text string) {\n\t\tcfg.AccessToken = strings.TrimSpace(text)\n\t})\n\taddIntField(\n\t\tform,\n\t\t\"Reconnect Interval\",\n\t\tcfg.ReconnectInterval,\n\t\tfunc(value int) { cfg.ReconnectInterval = value },\n\t)\n\tform.AddInputField(\n\t\t\"Group Trigger Prefix\",\n\t\tstrings.Join(cfg.GroupTriggerPrefix, \",\"),\n\t\t128,\n\t\tnil,\n\t\tfunc(text string) {\n\t\t\tcfg.GroupTriggerPrefix = splitCSV(text)\n\t\t},\n\t)\n\taddAllowFromField(form, &cfg.AllowFrom)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) wecomForm() tview.Primitive {\n\tcfg := &s.config.Channels.WeCom\n\tform := baseChannelForm(\"WeCom\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Token\", cfg.Token, 128, nil, func(text string) {\n\t\tcfg.Token = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Encoding AES Key\", cfg.EncodingAESKey, 128, nil, func(text string) {\n\t\tcfg.EncodingAESKey = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Webhook URL\", cfg.WebhookURL, 128, nil, func(text string) {\n\t\tcfg.WebhookURL = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Webhook Host\", cfg.WebhookHost, 64, nil, func(text string) {\n\t\tcfg.WebhookHost = strings.TrimSpace(text)\n\t})\n\taddIntField(form, \"Webhook Port\", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })\n\tform.AddInputField(\"Webhook Path\", cfg.WebhookPath, 64, nil, func(text string) {\n\t\tcfg.WebhookPath = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\taddIntField(\n\t\tform,\n\t\t\"Reply Timeout\",\n\t\tcfg.ReplyTimeout,\n\t\tfunc(value int) { cfg.ReplyTimeout = value },\n\t)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) wecomAppForm() tview.Primitive {\n\tcfg := &s.config.Channels.WeComApp\n\tform := baseChannelForm(\"WeCom App\", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))\n\tform.AddInputField(\"Corp ID\", cfg.CorpID, 64, nil, func(text string) {\n\t\tcfg.CorpID = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Corp Secret\", cfg.CorpSecret, 128, nil, func(text string) {\n\t\tcfg.CorpSecret = strings.TrimSpace(text)\n\t})\n\taddInt64Field(form, \"Agent ID\", cfg.AgentID, func(value int64) { cfg.AgentID = value })\n\tform.AddInputField(\"Token\", cfg.Token, 128, nil, func(text string) {\n\t\tcfg.Token = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Encoding AES Key\", cfg.EncodingAESKey, 128, nil, func(text string) {\n\t\tcfg.EncodingAESKey = strings.TrimSpace(text)\n\t})\n\tform.AddInputField(\"Webhook Host\", cfg.WebhookHost, 64, nil, func(text string) {\n\t\tcfg.WebhookHost = strings.TrimSpace(text)\n\t})\n\taddIntField(form, \"Webhook Port\", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })\n\tform.AddInputField(\"Webhook Path\", cfg.WebhookPath, 64, nil, func(text string) {\n\t\tcfg.WebhookPath = strings.TrimSpace(text)\n\t})\n\taddAllowFromField(form, &cfg.AllowFrom)\n\taddIntField(\n\t\tform,\n\t\t\"Reply Timeout\",\n\t\tcfg.ReplyTimeout,\n\t\tfunc(value int) { cfg.ReplyTimeout = value },\n\t)\n\treturn wrapWithBack(form, s)\n}\n\nfunc (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {\n\treturn func(v bool) {\n\t\t*enabledPtr = v\n\t\ts.dirty = true\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"channel\"]; ok {\n\t\t\trefreshChannelMenuFromState(menu, s)\n\t\t}\n\t}\n}\n\nfunc addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {\n\tform.AddInputField(\"Allow From\", strings.Join(*allowFrom, \",\"), 128, nil, func(text string) {\n\t\t*allowFrom = splitCSV(text)\n\t})\n}\n\nfunc baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {\n\tform := tview.NewForm()\n\tform.SetBorder(true).SetTitle(fmt.Sprintf(\"Channel: %s\", title))\n\tform.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))\n\tform.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))\n\tform.AddCheckbox(\"Enabled\", enabled, func(checked bool) {\n\t\tonEnabled(checked)\n\t})\n\treturn form\n}\n\nfunc wrapWithBack(form *tview.Form, s *appState) tview.Primitive {\n\tform.AddButton(\"Back\", func() {\n\t\ts.pop()\n\t})\n\tform.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tif event.Key() == tcell.KeyEsc {\n\t\t\ts.pop()\n\t\t\treturn nil\n\t\t}\n\t\treturn event\n\t})\n\treturn form\n}\n\nfunc splitCSV(input string) picoclawconfig.FlexibleStringSlice {\n\tparts := strings.Split(strings.TrimSpace(input), \",\")\n\tcleaned := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tvalue := strings.TrimSpace(part)\n\t\tif value == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcleaned = append(cleaned, value)\n\t}\n\treturn cleaned\n}\n\nfunc addIntField(form *tview.Form, label string, value int, onChange func(int)) {\n\tform.AddInputField(label, fmt.Sprintf(\"%d\", value), 16, nil, func(text string) {\n\t\tvar parsed int\n\t\tif _, err := fmt.Sscanf(strings.TrimSpace(text), \"%d\", &parsed); err == nil {\n\t\t\tonChange(parsed)\n\t\t}\n\t})\n}\n\nfunc addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {\n\tform.AddInputField(label, fmt.Sprintf(\"%d\", value), 16, nil, func(text string) {\n\t\tvar parsed int64\n\t\tif _, err := fmt.Sscanf(strings.TrimSpace(text), \"%d\", &parsed); err == nil {\n\t\t\tonChange(parsed)\n\t\t}\n\t})\n}\n\nfunc channelItem(label, description string, enabled bool, action MenuAction) MenuItem {\n\titem := MenuItem{\n\t\tLabel:       label,\n\t\tDescription: description,\n\t\tAction:      action,\n\t}\n\tif !enabled {\n\t\tcolor := tcell.ColorGray\n\t\titem.MainColor = &color\n\t}\n\treturn item\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage ui\n\nimport \"os/exec\"\n\nfunc isGatewayProcessRunning() bool {\n\tcmd := exec.Command(\"sh\", \"-c\", \"pgrep -f 'picoclaw\\\\s+gateway' >/dev/null 2>&1\")\n\treturn cmd.Run() == nil\n}\n\nfunc stopGatewayProcess() error {\n\tcmd := exec.Command(\"sh\", \"-c\", \"pkill -f 'picoclaw\\\\s+gateway' >/dev/null 2>&1\")\n\treturn cmd.Run()\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go",
    "content": "//go:build windows\n// +build windows\n\npackage ui\n\nimport \"os/exec\"\n\nfunc isGatewayProcessRunning() bool {\n\tcmd := exec.Command(\"tasklist\", \"/FI\", \"IMAGENAME eq picoclaw.exe\")\n\treturn cmd.Run() == nil\n}\n\nfunc stopGatewayProcess() error {\n\tcmd := exec.Command(\"taskkill\", \"/F\", \"/IM\", \"picoclaw.exe\")\n\treturn cmd.Run()\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/menu.go",
    "content": "package ui\n\nimport (\n\t\"github.com/gdamore/tcell/v2\"\n\t\"github.com/rivo/tview\"\n)\n\ntype MenuAction func()\n\ntype MenuItem struct {\n\tLabel       string\n\tDescription string\n\tAction      MenuAction\n\tDisabled    bool\n\tMainColor   *tcell.Color\n\tDescColor   *tcell.Color\n}\n\ntype Menu struct {\n\t*tview.Table\n\titems []MenuItem\n}\n\nfunc NewMenu(title string, items []MenuItem) *Menu {\n\ttable := tview.NewTable().SetSelectable(true, false)\n\ttable.SetBorder(true).SetTitle(title)\n\ttable.SetBorders(false)\n\tmenu := &Menu{Table: table, items: items}\n\tmenu.applyItems(items)\n\tmenu.SetSelectedFunc(func(row, _ int) {\n\t\tif row < 0 || row >= len(menu.items) {\n\t\t\treturn\n\t\t}\n\t\titem := menu.items[row]\n\t\tif item.Disabled || item.Action == nil {\n\t\t\treturn\n\t\t}\n\t\titem.Action()\n\t})\n\tmenu.SetSelectedStyle(\n\t\ttcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).\n\t\t\tBackground(tcell.NewRGBColor(189, 147, 249)),\n\t)\n\treturn menu\n}\n\nfunc (m *Menu) applyItems(items []MenuItem) {\n\tm.items = items\n\tm.Clear()\n\tfor row, item := range items {\n\t\tlabel := item.Label\n\t\tif item.Disabled && label != \"\" {\n\t\t\tlabel = label + \" (disabled)\"\n\t\t}\n\t\tleft := tview.NewTableCell(label)\n\t\tright := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)\n\t\tif item.MainColor != nil {\n\t\t\tleft.SetTextColor(*item.MainColor)\n\t\t}\n\t\tif item.DescColor != nil {\n\t\t\tright.SetTextColor(*item.DescColor)\n\t\t} else {\n\t\t\tright.SetTextColor(tview.Styles.TertiaryTextColor)\n\t\t}\n\t\tif item.Disabled {\n\t\t\tleft.SetTextColor(tcell.ColorGray)\n\t\t\tright.SetTextColor(tcell.ColorGray)\n\t\t}\n\t\tm.SetCell(row, 0, left)\n\t\tm.SetCell(row, 1, right)\n\t}\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/model.go",
    "content": "package ui\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gdamore/tcell/v2\"\n\t\"github.com/rivo/tview\"\n\n\tpicoclawconfig \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc (s *appState) modelMenu() tview.Primitive {\n\titems := make([]MenuItem, 0, 1+len(s.config.ModelList))\n\tcurrentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)\n\tfor i := range s.config.ModelList {\n\t\tindex := i\n\t\tmodel := s.config.ModelList[i]\n\t\tisValid := isModelValid(model)\n\t\tdesc := model.APIBase\n\t\tif desc == \"\" {\n\t\t\tdesc = model.AuthMethod\n\t\t}\n\t\tif desc == \"\" {\n\t\t\tdesc = \"api_key required\"\n\t\t}\n\t\tlabel := fmt.Sprintf(\"%s (%s)\", model.ModelName, model.Model)\n\t\tif model.ModelName == currentModel && currentModel != \"\" {\n\t\t\tlabel = \"* \" + label\n\t\t}\n\t\tisSelected := model.ModelName == currentModel && currentModel != \"\"\n\t\titems = append(items, MenuItem{\n\t\t\tLabel:       label,\n\t\t\tDescription: desc,\n\t\t\tMainColor:   modelStatusColor(isValid, isSelected),\n\t\t\tAction: func() {\n\t\t\t\ts.push(fmt.Sprintf(\"model-%d\", index), s.modelForm(index))\n\t\t\t},\n\t\t})\n\t}\n\t// Add model entry appended at the end so the models map to rows 1..N\n\titems = append(items,\n\t\tMenuItem{\n\t\t\tLabel:       \"**Add model**\",\n\t\t\tDescription: \"Append a new model entry\",\n\t\t\tAction: func() {\n\t\t\t\tnewName := s.nextAvailableModelName(\"new-model\")\n\t\t\t\ts.addModel(\n\t\t\t\t\tpicoclawconfig.ModelConfig{ModelName: newName, Model: \"openai/gpt-5.4\"},\n\t\t\t\t)\n\t\t\t\ts.push(\n\t\t\t\t\tfmt.Sprintf(\"model-%d\", len(s.config.ModelList)-1),\n\t\t\t\t\ts.modelForm(len(s.config.ModelList)-1),\n\t\t\t\t)\n\t\t\t},\n\t\t},\n\t)\n\n\tmenu := NewMenu(\"Models\", items)\n\tmenu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tif event.Key() == tcell.KeyEsc {\n\t\t\ts.pop()\n\t\t\treturn nil\n\t\t}\n\n\t\tif event.Rune() == ' ' {\n\t\t\trow, _ := menu.GetSelection()\n\t\t\tif row >= 0 && row < len(s.config.ModelList) {\n\t\t\t\tmodel := s.config.ModelList[row]\n\t\t\t\tif !isModelValid(model) {\n\t\t\t\t\ts.showMessage(\n\t\t\t\t\t\t\"Invalid model\",\n\t\t\t\t\t\t\"Select a model with api_key or oauth auth_method\",\n\t\t\t\t\t)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\ts.config.Agents.Defaults.Model = model.ModelName\n\t\t\t\ts.dirty = true\n\t\t\t\trefreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)\n\t\t\t\trefreshMainMenuIfPresent(s)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\treturn event\n\t})\n\treturn menu\n}\n\nfunc (s *appState) modelForm(index int) tview.Primitive {\n\tmodel := &s.config.ModelList[index]\n\tform := tview.NewForm()\n\tform.SetBorder(true).SetTitle(fmt.Sprintf(\"Model: %s\", model.ModelName))\n\n\taddInput(form, \"Model Name\", model.ModelName, func(value string) {\n\t\tif value == \"\" {\n\t\t\ts.showMessage(\"Invalid model name\", \"Model Name cannot be empty\")\n\t\t\treturn\n\t\t}\n\t\tif s.modelNameExists(value, index) {\n\t\t\ts.showMessage(\"Duplicate model name\", fmt.Sprintf(\"Model Name '%s' already exists\", value))\n\t\t\treturn\n\t\t}\n\t\toldName := model.ModelName\n\t\tmodel.ModelName = value\n\t\tif s.config.Agents.Defaults.Model == oldName {\n\t\t\ts.config.Agents.Defaults.Model = value\n\t\t}\n\t\ts.dirty = true\n\t\tform.SetTitle(fmt.Sprintf(\"Model: %s\", model.ModelName))\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"model\"]; ok {\n\t\t\trefreshModelMenuFromState(menu, s)\n\t\t}\n\t})\n\taddInput(form, \"Model\", model.Model, func(value string) {\n\t\tmodel.Model = value\n\t\ts.dirty = true\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"model\"]; ok {\n\t\t\trefreshModelMenuFromState(menu, s)\n\t\t}\n\t})\n\taddInput(form, \"API Base\", model.APIBase, func(value string) {\n\t\tmodel.APIBase = value\n\t\ts.dirty = true\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"model\"]; ok {\n\t\t\trefreshModelMenuFromState(menu, s)\n\t\t}\n\t})\n\taddInput(form, \"API Key\", model.APIKey, func(value string) {\n\t\tmodel.APIKey = value\n\t\ts.dirty = true\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"model\"]; ok {\n\t\t\trefreshModelMenuFromState(menu, s)\n\t\t}\n\t})\n\taddInput(form, \"Proxy\", model.Proxy, func(value string) {\n\t\tmodel.Proxy = value\n\t})\n\taddInput(form, \"Auth Method\", model.AuthMethod, func(value string) {\n\t\tmodel.AuthMethod = value\n\t\ts.dirty = true\n\t\trefreshMainMenuIfPresent(s)\n\t\tif menu, ok := s.menus[\"model\"]; ok {\n\t\t\trefreshModelMenuFromState(menu, s)\n\t\t}\n\t})\n\taddInput(form, \"Connect Mode\", model.ConnectMode, func(value string) {\n\t\tmodel.ConnectMode = value\n\t})\n\taddInput(form, \"Workspace\", model.Workspace, func(value string) {\n\t\tmodel.Workspace = value\n\t})\n\taddInput(form, \"Max Tokens Field\", model.MaxTokensField, func(value string) {\n\t\tmodel.MaxTokensField = value\n\t})\n\taddIntInput(form, \"RPM\", model.RPM, func(value int) {\n\t\tmodel.RPM = value\n\t})\n\taddIntInput(form, \"Request Timeout\", model.RequestTimeout, func(value int) {\n\t\tmodel.RequestTimeout = value\n\t})\n\n\tform.AddButton(\"Delete\", func() {\n\t\tpageName := \"confirm-delete-model\"\n\t\tif s.pages.HasPage(pageName) {\n\t\t\treturn\n\t\t}\n\t\tmodal := tview.NewModal().\n\t\t\tSetText(\"Are you sure you want to delete this model?\").\n\t\t\tAddButtons([]string{\"Cancel\", \"Delete\"}).\n\t\t\tSetDoneFunc(func(buttonIndex int, buttonLabel string) {\n\t\t\t\ts.pages.RemovePage(pageName)\n\t\t\t\tif buttonLabel == \"Delete\" {\n\t\t\t\t\ts.deleteModel(index)\n\t\t\t\t}\n\t\t\t})\n\t\tmodal.SetTitle(\"Confirm Delete\").SetBorder(true)\n\t\ts.pages.AddPage(pageName, modal, true, true)\n\t})\n\tform.AddButton(\"Test\", func() {\n\t\ts.testModel(model)\n\t})\n\tform.AddButton(\"Back\", func() {\n\t\ts.pop()\n\t})\n\n\tform.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {\n\t\tif event.Key() == tcell.KeyEsc {\n\t\t\ts.pop()\n\t\t\treturn nil\n\t\t}\n\t\treturn event\n\t})\n\treturn form\n}\n\nfunc addInput(form *tview.Form, label, value string, onChange func(string)) {\n\tform.AddInputField(label, value, 128, nil, func(text string) {\n\t\tonChange(strings.TrimSpace(text))\n\t})\n}\n\nfunc addIntInput(form *tview.Form, label string, value int, onChange func(int)) {\n\tform.AddInputField(label, fmt.Sprintf(\"%d\", value), 16, nil, func(text string) {\n\t\tvar parsed int\n\t\tif _, err := fmt.Sscanf(strings.TrimSpace(text), \"%d\", &parsed); err == nil {\n\t\t\tonChange(parsed)\n\t\t}\n\t})\n}\n\nfunc (s *appState) addModel(model picoclawconfig.ModelConfig) {\n\ts.config.ModelList = append(s.config.ModelList, model)\n}\n\nfunc (s *appState) deleteModel(index int) {\n\tif index < 0 || index >= len(s.config.ModelList) {\n\t\treturn\n\t}\n\ts.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)\n\ts.pop()\n}\n\nfunc modelStatusColor(valid bool, selected bool) *tcell.Color {\n\tif valid {\n\t\tcolor := tview.Styles.PrimaryTextColor\n\t\treturn &color\n\t}\n\tcolor := tcell.ColorGray\n\treturn &color\n}\n\nfunc refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {\n\tfor i, model := range models {\n\t\trow := i\n\t\tlabel := fmt.Sprintf(\"%s (%s)\", model.ModelName, model.Model)\n\t\tisValid := isModelValid(model)\n\t\tif model.ModelName == currentModel && currentModel != \"\" {\n\t\t\tlabel = \"* \" + label\n\t\t}\n\t\tcell := menu.GetCell(row, 0)\n\t\tif cell != nil {\n\t\t\tcell.SetText(label)\n\t\t\tisSelected := model.ModelName == currentModel && currentModel != \"\"\n\t\t\tcolor := modelStatusColor(isValid, isSelected)\n\t\t\tif color != nil {\n\t\t\t\tcell.SetTextColor(*color)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc refreshModelMenuFromState(menu *Menu, s *appState) {\n\titems := make([]MenuItem, 0, 1+len(s.config.ModelList))\n\tcurrentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)\n\tfor i := range s.config.ModelList {\n\t\tindex := i\n\t\tmodel := s.config.ModelList[i]\n\t\tisValid := isModelValid(model)\n\t\tdesc := model.APIBase\n\t\tif desc == \"\" {\n\t\t\tdesc = model.AuthMethod\n\t\t}\n\t\tif desc == \"\" {\n\t\t\tdesc = \"api_key required\"\n\t\t}\n\t\tlabel := fmt.Sprintf(\"%s (%s)\", model.ModelName, model.Model)\n\t\tif model.ModelName == currentModel && currentModel != \"\" {\n\t\t\tlabel = \"* \" + label\n\t\t}\n\t\tisSelected := model.ModelName == currentModel && currentModel != \"\"\n\t\titems = append(items, MenuItem{\n\t\t\tLabel:       label,\n\t\t\tDescription: desc,\n\t\t\tMainColor:   modelStatusColor(isValid, isSelected),\n\t\t\tAction: func() {\n\t\t\t\ts.push(fmt.Sprintf(\"model-%d\", index), s.modelForm(index))\n\t\t\t},\n\t\t})\n\t}\n\titems = append(items,\n\t\tMenuItem{\n\t\t\tLabel:       \"**Add Model**\",\n\t\t\tDescription: \"Append a new model entry\",\n\t\t\tAction: func() {\n\t\t\t\tnewName := s.nextAvailableModelName(\"new-model\")\n\t\t\t\ts.addModel(\n\t\t\t\t\tpicoclawconfig.ModelConfig{ModelName: newName, Model: \"openai/gpt-5.4\"},\n\t\t\t\t)\n\t\t\t\ts.push(fmt.Sprintf(\"model-%d\", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))\n\t\t\t},\n\t\t},\n\t)\n\tmenu.applyItems(items)\n}\n\nfunc isModelValid(model picoclawconfig.ModelConfig) bool {\n\thasKey := strings.TrimSpace(model.APIKey) != \"\" ||\n\t\tstrings.TrimSpace(model.AuthMethod) == \"oauth\"\n\thasModel := strings.TrimSpace(model.Model) != \"\"\n\treturn hasKey && hasModel\n}\n\nfunc (s *appState) modelNameExists(name string, excludeIndex int) bool {\n\ttarget := strings.TrimSpace(name)\n\tif target == \"\" {\n\t\treturn false\n\t}\n\tfor i := range s.config.ModelList {\n\t\tif i == excludeIndex {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.TrimSpace(s.config.ModelList[i].ModelName) == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (s *appState) nextAvailableModelName(base string) string {\n\tname := strings.TrimSpace(base)\n\tif name == \"\" {\n\t\tname = \"new-model\"\n\t}\n\tif !s.modelNameExists(name, -1) {\n\t\treturn name\n\t}\n\tfor i := 2; ; i++ {\n\t\tcandidate := fmt.Sprintf(\"%s-%d\", name, i)\n\t\tif !s.modelNameExists(candidate, -1) {\n\t\t\treturn candidate\n\t\t}\n\t}\n}\n\nfunc (s *appState) testModel(model *picoclawconfig.ModelConfig) {\n\tif model == nil {\n\t\treturn\n\t}\n\tif strings.TrimSpace(model.APIKey) == \"\" {\n\t\ts.showMessage(\"Missing API Key\", \"Set api_key before testing\")\n\t\treturn\n\t}\n\tbase := strings.TrimSpace(model.APIBase)\n\tif base == \"\" {\n\t\ts.showMessage(\"Missing API Base\", \"Set api_base before testing\")\n\t\treturn\n\t}\n\tmodelID := strings.TrimSpace(model.Model)\n\tif modelID == \"\" {\n\t\ts.showMessage(\"Missing Model\", \"Set model before testing\")\n\t\treturn\n\t}\n\tif !strings.HasPrefix(modelID, \"openai/\") {\n\t\ts.showMessage(\"Unsupported model\", \"Only openai/* models are supported for test\")\n\t\treturn\n\t}\n\tmodelName := strings.TrimPrefix(modelID, \"openai/\")\n\tendpoint := strings.TrimRight(base, \"/\") + \"/chat/completions\"\n\n\tpayload := fmt.Sprintf(\n\t\t`{\"model\":\"%s\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}`,\n\t\tmodelName,\n\t)\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\trequest, err := http.NewRequest(\"POST\", endpoint, strings.NewReader(payload))\n\tif err != nil {\n\t\ts.showMessage(\"Test failed\", err.Error())\n\t\treturn\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+strings.TrimSpace(model.APIKey))\n\n\tresp, err := client.Do(request)\n\tif err != nil {\n\t\ts.showMessage(\"Test failed\", err.Error())\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 200 && resp.StatusCode < 300 {\n\t\ts.showMessage(\"Test OK\", resp.Status)\n\t\treturn\n\t}\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, 2048))\n\tif err != nil {\n\t\ts.showMessage(\"Test failed\", fmt.Sprintf(\"failed to read response: %v\", err))\n\t\treturn\n\t}\n\ts.showMessage(\n\t\t\"Test failed\",\n\t\tfmt.Sprintf(\"%s: %s\", resp.Status, strings.TrimSpace(string(body))),\n\t)\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/internal/ui/style.go",
    "content": "package ui\n\nimport (\n\t\"github.com/gdamore/tcell/v2\"\n\t\"github.com/rivo/tview\"\n)\n\nconst (\n\tcolorBlue = \"[#3e5db9]\"\n\tcolorRed  = \"[#d54646]\"\n\tbanner    = \"\\r\\n[::b]\" +\n\t\tcolorBlue + \"██████╗ ██╗ ██████╗ ██████╗ \" + colorRed + \" ██████╗██╗      █████╗ ██╗    ██╗\\n\" +\n\t\tcolorBlue + \"██╔══██╗██║██╔════╝██╔═══██╗\" + colorRed + \"██╔════╝██║     ██╔══██╗██║    ██║\\n\" +\n\t\tcolorBlue + \"██████╔╝██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ███████║██║ █╗ ██║\\n\" +\n\t\tcolorBlue + \"██╔═══╝ ██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ██╔══██║██║███╗██║\\n\" +\n\t\tcolorBlue + \"██║     ██║╚██████╗╚██████╔╝\" + colorRed + \"╚██████╗███████╗██║  ██║╚███╔███╔╝\\n\" +\n\t\tcolorBlue + \"╚═╝     ╚═╝ ╚═════╝ ╚═════╝ \" + colorRed + \" ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝\\n \" +\n\t\t\"[:]\"\n)\n\nfunc applyStyles() {\n\ttview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)\n\ttview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)\n\ttview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)\n\ttview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)\n\ttview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)\n\ttview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)\n\ttview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)\n\ttview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)\n\ttview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)\n\ttview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)\n\ttview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)\n}\n\nfunc bannerView() *tview.TextView {\n\ttext := tview.NewTextView()\n\ttext.SetDynamicColors(true)\n\ttext.SetTextAlign(tview.AlignCenter)\n\ttext.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)\n\ttext.SetText(banner)\n\ttext.SetBorder(false)\n\treturn text\n}\n\nconst footerText = \"Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch\"\n\nfunc footerView() *tview.TextView {\n\ttext := tview.NewTextView()\n\ttext.SetTextAlign(tview.AlignCenter)\n\ttext.SetText(footerText)\n\ttext.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)\n\ttext.SetTextColor(tview.Styles.PrimaryTextColor)\n\ttext.SetBorder(false)\n\treturn text\n}\n"
  },
  {
    "path": "cmd/picoclaw-launcher-tui/main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui\"\n)\n\nfunc main() {\n\tif err := ui.Run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "config/config.example.json",
    "content": "{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true,\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20,\n      \"summarize_message_threshold\": 20,\n      \"summarize_token_percent\": 75,\n      \"tool_feedback\": {\n        \"enabled\": false,\n        \"max_args_length\": 300\n      }\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\",\n      \"api_base\": \"https://api.openai.com/v1\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\",\n      \"api_base\": \"https://api.anthropic.com/v1\",\n      \"thinking_level\": \"high\"\n    },\n    {\n      \"_comment\": \"Anthropic Messages API - use native format for direct Anthropic API access\",\n      \"model_name\": \"claude-opus-4-6\",\n      \"model\": \"anthropic-messages/claude-opus-4-6\",\n      \"api_key\": \"sk-ant-your-key\",\n      \"api_base\": \"https://api.anthropic.com\"\n    },\n    {\n      \"model_name\": \"gemini\",\n      \"model\": \"antigravity/gemini-2.0-flash\",\n      \"auth_method\": \"oauth\"\n    },\n    {\n      \"model_name\": \"deepseek\",\n      \"model\": \"deepseek/deepseek-chat\",\n      \"api_key\": \"sk-your-deepseek-key\"\n    },\n    {\n      \"model_name\": \"longcat\",\n      \"model\": \"longcat/LongCat-Flash-Thinking\",\n      \"api_key\": \"your-longcat-api-key\"\n    },\n    {\n      \"model_name\": \"modelscope-qwen\",\n      \"model\": \"modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507\",\n      \"api_key\": \"your-modelscope-access-token\",\n      \"api_base\": \"https://api-inference.modelscope.cn/v1\"\n    },\n    {\n      \"model_name\": \"azure-gpt5\",\n      \"model\": \"azure/my-gpt5-deployment\",\n      \"api_key\": \"your-azure-api-key\",\n      \"api_base\": \"https://your-resource.openai.azure.com\"\n    },\n    {\n      \"model_name\": \"loadbalanced-gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-key1\",\n      \"api_base\": \"https://api1.example.com/v1\"\n    },\n    {\n      \"model_name\": \"loadbalanced-gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-key2\",\n      \"api_base\": \"https://api2.example.com/v1\"\n    }\n  ],\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": false,\n      \"token\": \"YOUR_TELEGRAM_BOT_TOKEN\",\n      \"base_url\": \"\",\n      \"proxy\": \"\",\n      \"allow_from\": [\"YOUR_USER_ID\"],\n      \"use_markdown_v2\": false,\n      \"reasoning_channel_id\": \"\"\n    },\n    \"discord\": {\n      \"enabled\": false,\n      \"token\": \"YOUR_DISCORD_BOT_TOKEN\",\n      \"proxy\": \"\",\n      \"allow_from\": [],\n      \"group_trigger\": {\n        \"mention_only\": false\n      },\n      \"reasoning_channel_id\": \"\"\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"YOUR_QQ_APP_ID\",\n      \"app_secret\": \"YOUR_QQ_APP_SECRET\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"maixcam\": {\n      \"enabled\": false,\n      \"host\": \"0.0.0.0\",\n      \"port\": 18790,\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\",\n      \"random_reaction_emoji\": [],\n      \"is_lark\": false\n    },\n    \"dingtalk\": {\n      \"enabled\": false,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"slack\": {\n      \"enabled\": false,\n      \"bot_token\": \"xoxb-YOUR-BOT-TOKEN\",\n      \"app_token\": \"xapp-YOUR-APP-TOKEN\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"matrix\": {\n      \"enabled\": false,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"device_id\": \"\",\n      \"join_on_invite\": true,\n      \"allow_from\": [],\n      \"group_trigger\": {\n        \"mention_only\": true\n      },\n      \"placeholder\": {\n        \"enabled\": true,\n        \"text\": \"Thinking... 💭\"\n      },\n      \"reasoning_channel_id\": \"\"\n    },\n    \"line\": {\n      \"enabled\": false,\n      \"channel_secret\": \"YOUR_LINE_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_LINE_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"onebot\": {\n      \"enabled\": false,\n      \"ws_url\": \"ws://127.0.0.1:3001\",\n      \"access_token\": \"\",\n      \"reconnect_interval\": 5,\n      \"group_trigger_prefix\": [],\n      \"allow_from\": [],\n      \"reasoning_channel_id\": \"\"\n    },\n    \"wecom\": {\n      \"_comment\": \"WeCom Bot - Easier setup, supports group chats\",\n      \"enabled\": false,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": [],\n      \"reply_timeout\": 5,\n      \"reasoning_channel_id\": \"\"\n    },\n    \"wecom_app\": {\n      \"_comment\": \"WeCom App (自建应用) - More features, proactive messaging, private chat only.\",\n      \"enabled\": false,\n      \"corp_id\": \"YOUR_CORP_ID\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": [],\n      \"reply_timeout\": 5,\n      \"reasoning_channel_id\": \"\"\n    },\n    \"wecom_aibot\": {\n      \"_comment\": \"WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.\",\n      \"enabled\": false,\n      \"bot_id\": \"YOUR_BOT_ID\",\n      \"secret\": \"YOUR_SECRET\",\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"max_steps\": 10,\n      \"welcome_message\": \"Hello! I'm your AI assistant. How can I help you today?\",\n      \"reasoning_channel_id\": \"\"\n    },\n    \"irc\": {\n      \"enabled\": false,\n      \"server\": \"irc.libera.chat:6697\",\n      \"tls\": true,\n      \"nick\": \"mybot\",\n      \"user\": \"\",\n      \"real_name\": \"\",\n      \"password\": \"\",\n      \"nickserv_password\": \"\",\n      \"sasl_user\": \"\",\n      \"sasl_password\": \"\",\n      \"channels\": [\n        \"#mychannel\"\n      ],\n      \"request_caps\": [\n        \"server-time\",\n        \"message-tags\"\n      ],\n      \"allow_from\": [],\n      \"group_trigger\": {\n        \"mention_only\": true\n      },\n      \"typing\": {\n        \"enabled\": false\n      },\n      \"reasoning_channel_id\": \"\"\n    }\n  },\n  \"providers\": {\n    \"_comment\": \"DEPRECATED: Use model_list instead. This will be removed in a future version\",\n    \"anthropic\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\"\n    },\n    \"openai\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\",\n      \"web_search\": true\n    },\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\",\n      \"api_base\": \"\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\",\n      \"api_base\": \"\"\n    },\n    \"zhipu\": {\n      \"api_key\": \"YOUR_ZHIPU_API_KEY\",\n      \"api_base\": \"\"\n    },\n    \"gemini\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\"\n    },\n    \"vllm\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\"\n    },\n    \"nvidia\": {\n      \"api_key\": \"nvapi-xxx\",\n      \"api_base\": \"\",\n      \"proxy\": \"http://127.0.0.1:7890\"\n    },\n    \"moonshot\": {\n      \"api_key\": \"sk-xxx\",\n      \"api_base\": \"\"\n    },\n    \"qwen\": {\n      \"api_key\": \"sk-xxx\",\n      \"api_base\": \"\"\n    },\n    \"ollama\": {\n      \"api_key\": \"\",\n      \"api_base\": \"http://localhost:11434/v1\"\n    },\n    \"cerebras\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\"\n    },\n    \"volcengine\": {\n      \"api_key\": \"\",\n      \"api_base\": \"\"\n    },\n    \"mistral\": {\n      \"api_key\": \"\",\n      \"api_base\": \"https://api.mistral.ai/v1\"\n    },\n    \"avian\": {\n      \"api_key\": \"\",\n      \"api_base\": \"https://api.avian.io/v1\"\n    },\n    \"longcat\": {\n      \"api_key\": \"\",\n      \"api_base\": \"https://api.longcat.chat/openai\"\n    },\n    \"modelscope\": {\n      \"api_key\": \"\",\n      \"api_base\": \"https://api-inference.modelscope.cn/v1\"\n    }\n  },\n  \"tools\": {\n    \"allow_read_paths\": null,\n    \"allow_write_paths\": null,\n    \"web\": {\n      \"enabled\": true,\n      \"prefer_native\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"api_keys\": [\n          \"YOUR_BRAVE_API_KEY\"\n        ],\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"base_url\": \"\",\n        \"max_results\": 0\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"pplx-xxx\",\n        \"api_keys\": [\n          \"pplx-xxx\"\n        ],\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      },\n      \"glm_search\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"base_url\": \"https://open.bigmodel.cn/api/paas/v4/web_search\",\n        \"search_engine\": \"search_std\",\n        \"max_results\": 5\n      },\n      \"fetch_limit_bytes\": 10485760,\n      \"private_host_whitelist\": []\n    },\n    \"cron\": {\n      \"enabled\": true,\n      \"exec_timeout_minutes\": 5\n    },\n    \"mcp\": {\n      \"enabled\": false,\n      \"discovery\": {\n        \"enabled\": false,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"context7\": {\n          \"enabled\": false,\n          \"type\": \"http\",\n          \"url\": \"https://mcp.context7.com/mcp\",\n          \"headers\": {\n            \"CONTEXT7_API_KEY\": \"ctx7sk-xx\"\n          }\n        },\n        \"filesystem\": {\n          \"enabled\": false,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        },\n        \"github\": {\n          \"enabled\": false,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"brave-search\": {\n          \"enabled\": false,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-brave-search\"\n          ],\n          \"env\": {\n            \"BRAVE_API_KEY\": \"YOUR_BRAVE_API_KEY\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": false,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": false,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    },\n    \"exec\": {\n      \"enabled\": true,\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": null,\n      \"custom_allow_patterns\": null\n    },\n    \"skills\": {\n      \"enabled\": true,\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"\",\n          \"skills_path\": \"\",\n          \"download_path\": \"\",\n          \"timeout\": 0,\n          \"max_zip_size\": 0,\n          \"max_response_size\": 0\n        }\n      },\n      \"github\": {\n        \"proxy\": \"http://127.0.0.1:7891\",\n        \"token\": \"\"\n      },\n      \"max_concurrent_searches\": 2,\n      \"search_cache\": {\n        \"max_size\": 50,\n        \"ttl_seconds\": 300\n      }\n    },\n    \"media_cleanup\": {\n      \"enabled\": true,\n      \"max_age_minutes\": 30,\n      \"interval_minutes\": 5\n    },\n    \"append_file\": {\n      \"enabled\": true\n    },\n    \"edit_file\": {\n      \"enabled\": true\n    },\n    \"find_skills\": {\n      \"enabled\": true\n    },\n    \"i2c\": {\n      \"enabled\": false\n    },\n    \"install_skill\": {\n      \"enabled\": true\n    },\n    \"list_dir\": {\n      \"enabled\": true\n    },\n    \"message\": {\n      \"enabled\": true\n    },\n    \"read_file\": {\n      \"enabled\": true\n    },\n    \"spawn\": {\n      \"enabled\": true\n    },\n    \"spi\": {\n      \"enabled\": false\n    },\n    \"subagent\": {\n      \"enabled\": true\n    },\n    \"web_fetch\": {\n      \"enabled\": true\n    },\n    \"write_file\": {\n      \"enabled\": true\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  },\n  \"devices\": {\n    \"enabled\": false,\n    \"monitor_usb\": true\n  },\n  \"voice\": {\n    \"echo_transcription\": false\n  },\n  \"gateway\": {\n    \"host\": \"127.0.0.1\",\n    \"port\": 18790,\n    \"hot_reload\": false\n  }\n}\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "# ============================================================\n# Stage 1: Build the picoclaw binary\n# ============================================================\nFROM golang:1.25-alpine AS builder\n\nRUN apk add --no-cache git make\n\nWORKDIR /src\n\n# Cache dependencies\nCOPY go.mod go.sum ./\nRUN go mod download\n\n# Copy source and build\nCOPY . .\nRUN make build\n\n# ============================================================\n# Stage 2: Minimal runtime image\n# ============================================================\nFROM alpine:3.23\n\nRUN apk add --no-cache ca-certificates tzdata curl\n\n# Health check\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n  CMD wget -q --spider http://localhost:18790/health || exit 1\n\n# Copy binary\nCOPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw\n\n# Create non-root user and group\nRUN addgroup -g 1000 picoclaw && \\\n    adduser -D -u 1000 -G picoclaw picoclaw\n\n# Switch to non-root user\nUSER picoclaw\n\n# Run onboard to create initial directories and config\nRUN /usr/local/bin/picoclaw onboard\n\nENTRYPOINT [\"picoclaw\"]\nCMD [\"gateway\"]\n"
  },
  {
    "path": "docker/Dockerfile.full",
    "content": "# ============================================================\n# Stage 1: Build the picoclaw binary\n# ============================================================\nFROM golang:1.26.0-alpine AS builder\n\nRUN apk add --no-cache git make\n\nWORKDIR /src\n\n# Cache dependencies\nCOPY go.mod go.sum ./\nRUN go mod download\n\n# Copy source and build\nCOPY . .\nRUN make build\n\n# ============================================================\n# Stage 2: Node.js-based runtime with full MCP support\n# ============================================================\nFROM node:24-alpine3.23\n\n# Install runtime dependencies\nRUN apk add --no-cache \\\n  ca-certificates \\\n  curl \\\n  git \\\n  python3 \\\n  py3-pip\n\n# Install uv and symlink to system path\nRUN curl -LsSf https://astral.sh/uv/install.sh | sh && \\\n  ln -s /root/.local/bin/uv /usr/local/bin/uv && \\\n  ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \\\n  uv --version\n\n# Copy binary\nCOPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw\n\n# Create picoclaw home directory\nRUN /usr/local/bin/picoclaw onboard\n\nENTRYPOINT [\"picoclaw\"]\nCMD [\"gateway\"]\n"
  },
  {
    "path": "docker/Dockerfile.goreleaser",
    "content": "FROM alpine:3.21\n\nARG TARGETPLATFORM\n\nRUN apk add --no-cache ca-certificates tzdata\n\nCOPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw\nCOPY docker/entrypoint.sh /entrypoint.sh\n\nRUN chmod +x /entrypoint.sh\n\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "docker/Dockerfile.goreleaser.launcher",
    "content": "FROM alpine:3.21\n\nARG TARGETPLATFORM\n\nRUN apk add --no-cache ca-certificates tzdata\n\nCOPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw\nCOPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher\nCOPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui\n\nENTRYPOINT [\"picoclaw-launcher\"]\nCMD [\"-public\", \"-no-browser\"]\n"
  },
  {
    "path": "docker/docker-compose.full.yml",
    "content": "services:\n  # ─────────────────────────────────────────────\n  # PicoClaw Agent (one-shot query) - Full MCP Support\n  #   docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m \"Hello\"\n  # ─────────────────────────────────────────────\n  picoclaw-agent:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile.full\n    container_name: picoclaw-agent-full\n    profiles:\n      - agent\n    volumes:\n      - ../config/config.json:/root/.picoclaw/config.json:ro\n      - picoclaw-workspace:/root/.picoclaw/workspace\n      - picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs\n    entrypoint: [\"picoclaw\", \"agent\"]\n    stdin_open: true\n    tty: true\n\n  # ─────────────────────────────────────────────\n  # PicoClaw Gateway (Long-running Bot) - Full MCP Support\n  #   docker compose -f docker/docker-compose.full.yml --profile gateway up\n  # ─────────────────────────────────────────────\n  picoclaw-gateway:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile.full\n    container_name: picoclaw-gateway-full\n    restart: unless-stopped\n    profiles:\n      - gateway\n    volumes:\n      # Configuration file\n      - ../config/config.json:/root/.picoclaw/config.json:ro\n      # Persistent workspace (sessions, memory, logs)\n      - picoclaw-workspace:/root/.picoclaw/workspace\n      # NPM cache for faster MCP server installs\n      - picoclaw-npm-cache:/root/.npm\n    command: [\"gateway\"]\n\nvolumes:\n  picoclaw-workspace:\n  picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "services:\n  # ─────────────────────────────────────────────\n  # PicoClaw Agent (one-shot query)\n  #   docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"Hello\"\n  # ─────────────────────────────────────────────\n  picoclaw-agent:\n    image: docker.io/sipeed/picoclaw:latest\n    container_name: picoclaw-agent\n    profiles:\n      - agent\n    # Uncomment to access host network; leave commented unless needed.\n    #extra_hosts:\n    #  - \"host.docker.internal:host-gateway\"\n    volumes:\n      - ./data:/root/.picoclaw\n    entrypoint: [\"picoclaw\", \"agent\"]\n    stdin_open: true\n    tty: true\n\n  # ─────────────────────────────────────────────\n  # PicoClaw Gateway (Long-running Bot)\n  #   docker compose -f docker/docker-compose.yml --profile gateway up\n  # ─────────────────────────────────────────────\n  picoclaw-gateway:\n    image: docker.io/sipeed/picoclaw:latest\n    container_name: picoclaw-gateway\n    restart: on-failure\n    profiles:\n      - gateway\n    # Uncomment to access host network; leave commented unless needed.\n    #extra_hosts:\n    #  - \"host.docker.internal:host-gateway\"\n    volumes:\n      - ./data:/root/.picoclaw\n\n  # ─────────────────────────────────────────────\n  # PicoClaw Launcher (Web Console + Gateway)\n  #   docker compose -f docker/docker-compose.yml --profile launcher up\n  # ─────────────────────────────────────────────\n  picoclaw-launcher:\n    image: docker.io/sipeed/picoclaw:launcher\n    container_name: picoclaw-launcher\n    restart: on-failure\n    profiles:\n      - launcher\n    environment:\n      - PICOCLAW_GATEWAY_HOST=0.0.0.0\n    ports:\n      - \"127.0.0.1:18800:18800\"\n      - \"127.0.0.1:18790:18790\"\n    volumes:\n      - ./data:/root/.picoclaw\n"
  },
  {
    "path": "docker/entrypoint.sh",
    "content": "#!/bin/sh\nset -e\n\n# First-run: neither config nor workspace exists.\n# If config.json is already mounted but workspace is missing we skip onboard to\n# avoid the interactive \"Overwrite? (y/n)\" prompt hanging in a non-TTY container.\nif [ ! -d \"${HOME}/.picoclaw/workspace\" ] && [ ! -f \"${HOME}/.picoclaw/config.json\" ]; then\n    picoclaw onboard\n    echo \"\"\n    echo \"First-run setup complete.\"\n    echo \"Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container.\"\n    exit 0\nfi\n\nexec picoclaw gateway \"$@\"\n"
  },
  {
    "path": "docs/ANTIGRAVITY_AUTH.md",
    "content": "# Antigravity Authentication & Integration Guide\n\n## Overview\n\n**Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw.\n\n---\n\n## Table of Contents\n\n1. [Authentication Flow](#authentication-flow)\n2. [OAuth Implementation Details](#oauth-implementation-details)\n3. [Token Management](#token-management)\n4. [Models List Fetching](#models-list-fetching)\n5. [Usage Tracking](#usage-tracking)\n6. [Provider Plugin Structure](#provider-plugin-structure)\n7. [Integration Requirements](#integration-requirements)\n8. [API Endpoints](#api-endpoints)\n9. [Configuration](#configuration)\n10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw)\n\n---\n\n## Authentication Flow\n\n### 1. OAuth 2.0 with PKCE\n\nAntigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication:\n\n```\n┌─────────────┐                                    ┌─────────────────┐\n│   Client    │ ───(1) Generate PKCE Pair────────> │                 │\n│             │ ───(2) Open Auth URL─────────────> │  Google OAuth   │\n│             │                                    │    Server       │\n│             │ <──(3) Redirect with Code───────── │                 │\n│             │                                    └─────────────────┘\n│             │ ───(4) Exchange Code for Tokens──> │   Token URL     │\n│             │                                    │                 │\n│             │ <──(5) Access + Refresh Tokens──── │                 │\n└─────────────┘                                    └─────────────────┘\n```\n\n### 2. Detailed Steps\n\n#### Step 1: Generate PKCE Parameters\n```typescript\nfunction generatePkce(): { verifier: string; challenge: string } {\n  const verifier = randomBytes(32).toString(\"hex\");\n  const challenge = createHash(\"sha256\").update(verifier).digest(\"base64url\");\n  return { verifier, challenge };\n}\n```\n\n#### Step 2: Build Authorization URL\n```typescript\nconst AUTH_URL = \"https://accounts.google.com/o/oauth2/v2/auth\";\nconst REDIRECT_URI = \"http://localhost:51121/oauth-callback\";\n\nfunction buildAuthUrl(params: { challenge: string; state: string }): string {\n  const url = new URL(AUTH_URL);\n  url.searchParams.set(\"client_id\", CLIENT_ID);\n  url.searchParams.set(\"response_type\", \"code\");\n  url.searchParams.set(\"redirect_uri\", REDIRECT_URI);\n  url.searchParams.set(\"scope\", SCOPES.join(\" \"));\n  url.searchParams.set(\"code_challenge\", params.challenge);\n  url.searchParams.set(\"code_challenge_method\", \"S256\");\n  url.searchParams.set(\"state\", params.state);\n  url.searchParams.set(\"access_type\", \"offline\");\n  url.searchParams.set(\"prompt\", \"consent\");\n  return url.toString();\n}\n```\n\n**Required Scopes:**\n```typescript\nconst SCOPES = [\n  \"https://www.googleapis.com/auth/cloud-platform\",\n  \"https://www.googleapis.com/auth/userinfo.email\",\n  \"https://www.googleapis.com/auth/userinfo.profile\",\n  \"https://www.googleapis.com/auth/cclog\",\n  \"https://www.googleapis.com/auth/experimentsandconfigs\",\n];\n```\n\n#### Step 3: Handle OAuth Callback\n\n**Automatic Mode (Local Development):**\n- Start a local HTTP server on port 51121\n- Wait for the redirect from Google\n- Extract the authorization code from the query parameters\n\n**Manual Mode (Remote/Headless):**\n- Display the authorization URL to the user\n- User completes authentication in their browser\n- User pastes the full redirect URL back into the terminal\n- Parse the code from the pasted URL\n\n#### Step 4: Exchange Code for Tokens\n```typescript\nconst TOKEN_URL = \"https://oauth2.googleapis.com/token\";\n\nasync function exchangeCode(params: {\n  code: string;\n  verifier: string;\n}): Promise<{ access: string; refresh: string; expires: number }> {\n  const response = await fetch(TOKEN_URL, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n    body: new URLSearchParams({\n      client_id: CLIENT_ID,\n      client_secret: CLIENT_SECRET,\n      code: params.code,\n      grant_type: \"authorization_code\",\n      redirect_uri: REDIRECT_URI,\n      code_verifier: params.verifier,\n    }),\n  });\n\n  const data = await response.json();\n  \n  return {\n    access: data.access_token,\n    refresh: data.refresh_token,\n    expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer\n  };\n}\n```\n\n#### Step 5: Fetch Additional User Data\n\n**User Email:**\n```typescript\nasync function fetchUserEmail(accessToken: string): Promise<string | undefined> {\n  const response = await fetch(\n    \"https://www.googleapis.com/oauth2/v1/userinfo?alt=json\",\n    { headers: { Authorization: `Bearer ${accessToken}` } }\n  );\n  const data = await response.json();\n  return data.email;\n}\n```\n\n**Project ID (Required for API calls):**\n```typescript\nasync function fetchProjectId(accessToken: string): Promise<string> {\n  const headers = {\n    Authorization: `Bearer ${accessToken}`,\n    \"Content-Type\": \"application/json\",\n    \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n    \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n    \"Client-Metadata\": JSON.stringify({\n      ideType: \"IDE_UNSPECIFIED\",\n      platform: \"PLATFORM_UNSPECIFIED\",\n      pluginType: \"GEMINI\",\n    }),\n  };\n\n  const response = await fetch(\n    \"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist\",\n    {\n      method: \"POST\",\n      headers,\n      body: JSON.stringify({\n        metadata: {\n          ideType: \"IDE_UNSPECIFIED\",\n          platform: \"PLATFORM_UNSPECIFIED\",\n          pluginType: \"GEMINI\",\n        },\n      }),\n    }\n  );\n\n  const data = await response.json();\n  return data.cloudaicompanionProject || \"rising-fact-p41fc\"; // Default fallback\n}\n```\n\n---\n\n## OAuth Implementation Details\n\n### Client Credentials\n\n**Important:** These are base64-encoded in the source code for sync with pi-ai:\n\n```typescript\nconst decode = (s: string) => Buffer.from(s, \"base64\").toString();\n\nconst CLIENT_ID = decode(\n  \"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==\"\n);\nconst CLIENT_SECRET = decode(\"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=\");\n```\n\n### OAuth Flow Modes\n\n1. **Automatic Flow** (Local machines with browser):\n   - Opens browser automatically\n   - Local callback server captures redirect\n   - No user interaction required after initial auth\n\n2. **Manual Flow** (Remote/headless/WSL2):\n   - URL displayed for manual copy-paste\n   - User completes auth in external browser\n   - User pastes full redirect URL back\n\n```typescript\nfunction shouldUseManualOAuthFlow(isRemote: boolean): boolean {\n  return isRemote || isWSL2Sync();\n}\n```\n\n---\n\n## Token Management\n\n### Auth Profile Structure\n\n```typescript\ntype OAuthCredential = {\n  type: \"oauth\";\n  provider: \"google-antigravity\";\n  access: string;           // Access token\n  refresh: string;          // Refresh token\n  expires: number;          // Expiration timestamp (ms since epoch)\n  email?: string;           // User email\n  projectId?: string;       // Google Cloud project ID\n};\n```\n\n### Token Refresh\n\nThe credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions.\n\n---\n\n## Models List Fetching\n\n### Fetch Available Models\n\n```typescript\nconst BASE_URL = \"https://cloudcode-pa.googleapis.com\";\n\nasync function fetchAvailableModels(\n  accessToken: string,\n  projectId: string\n): Promise<Model[]> {\n  const headers = {\n    Authorization: `Bearer ${accessToken}`,\n    \"Content-Type\": \"application/json\",\n    \"User-Agent\": \"antigravity\",\n    \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n  };\n\n  const response = await fetch(\n    `${BASE_URL}/v1internal:fetchAvailableModels`,\n    {\n      method: \"POST\",\n      headers,\n      body: JSON.stringify({ project: projectId }),\n    }\n  );\n\n  const data = await response.json();\n  \n  // Returns models with quota information\n  return Object.entries(data.models).map(([modelId, modelInfo]) => ({\n    id: modelId,\n    displayName: modelInfo.displayName,\n    quotaInfo: {\n      remainingFraction: modelInfo.quotaInfo?.remainingFraction,\n      resetTime: modelInfo.quotaInfo?.resetTime,\n      isExhausted: modelInfo.quotaInfo?.isExhausted,\n    },\n  }));\n}\n```\n\n### Response Format\n\n```typescript\ntype FetchAvailableModelsResponse = {\n  models?: Record<string, {\n    displayName?: string;\n    quotaInfo?: {\n      remainingFraction?: number | string;\n      resetTime?: string;      // ISO 8601 timestamp\n      isExhausted?: boolean;\n    };\n  }>;\n};\n```\n\n---\n\n## Usage Tracking\n\n### Fetch Usage Data\n\n```typescript\nexport async function fetchAntigravityUsage(\n  token: string,\n  timeoutMs: number\n): Promise<ProviderUsageSnapshot> {\n  // 1. Fetch credits and plan info\n  const loadCodeAssistRes = await fetch(\n    `${BASE_URL}/v1internal:loadCodeAssist`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${token}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        metadata: {\n          ideType: \"ANTIGRAVITY\",\n          platform: \"PLATFORM_UNSPECIFIED\",\n          pluginType: \"GEMINI\",\n        },\n      }),\n    }\n  );\n\n  // Extract credits info\n  const { availablePromptCredits, planInfo, currentTier } = data;\n  \n  // 2. Fetch model quotas\n  const modelsRes = await fetch(\n    `${BASE_URL}/v1internal:fetchAvailableModels`,\n    {\n      method: \"POST\",\n      headers: { Authorization: `Bearer ${token}` },\n      body: JSON.stringify({ project: projectId }),\n    }\n  );\n\n  // Build usage windows\n  return {\n    provider: \"google-antigravity\",\n    displayName: \"Google Antigravity\",\n    windows: [\n      { label: \"Credits\", usedPercent: calculateUsedPercent(available, monthly) },\n      // Individual model quotas...\n    ],\n    plan: currentTier?.name || planType,\n  };\n}\n```\n\n### Usage Response Structure\n\n```typescript\ntype ProviderUsageSnapshot = {\n  provider: \"google-antigravity\";\n  displayName: string;\n  windows: UsageWindow[];\n  plan?: string;\n  error?: string;\n};\n\ntype UsageWindow = {\n  label: string;           // \"Credits\" or model ID\n  usedPercent: number;     // 0-100\n  resetAt?: number;        // Timestamp when quota resets\n};\n```\n\n---\n\n## Provider Plugin Structure\n\n### Plugin Definition\n\n```typescript\nconst antigravityPlugin = {\n  id: \"google-antigravity-auth\",\n  name: \"Google Antigravity Auth\",\n  description: \"OAuth flow for Google Antigravity (Cloud Code Assist)\",\n  configSchema: emptyPluginConfigSchema(),\n  \n  register(api: PicoClawPluginApi) {\n    api.registerProvider({\n      id: \"google-antigravity\",\n      label: \"Google Antigravity\",\n      docsPath: \"/providers/models\",\n      aliases: [\"antigravity\"],\n      \n      auth: [\n        {\n          id: \"oauth\",\n          label: \"Google OAuth\",\n          hint: \"PKCE + localhost callback\",\n          kind: \"oauth\",\n          run: async (ctx: ProviderAuthContext) => {\n            // OAuth implementation here\n          },\n        },\n      ],\n    });\n  },\n};\n```\n\n### ProviderAuthContext\n\n```typescript\ntype ProviderAuthContext = {\n  config: PicoClawConfig;\n  agentDir?: string;\n  workspaceDir?: string;\n  prompter: WizardPrompter;      // UI prompts/notifications\n  runtime: RuntimeEnv;           // Logging, etc.\n  isRemote: boolean;             // Whether running remotely\n  openUrl: (url: string) => Promise<void>;  // Browser opener\n  oauth: {\n    createVpsAwareHandlers: Function;\n  };\n};\n```\n\n### ProviderAuthResult\n\n```typescript\ntype ProviderAuthResult = {\n  profiles: Array<{\n    profileId: string;\n    credential: AuthProfileCredential;\n  }>;\n  configPatch?: Partial<PicoClawConfig>;\n  defaultModel?: string;\n  notes?: string[];\n};\n```\n\n---\n\n## Integration Requirements\n\n### 1. Required Environment/Dependencies\n\n- Go ≥ 1.21\n- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)\n- `crypto` and `net/http` standard library packages\n\n### 2. Required Headers for API Calls\n\n```typescript\nconst REQUIRED_HEADERS = {\n  \"Authorization\": `Bearer ${accessToken}`,\n  \"Content-Type\": \"application/json\",\n  \"User-Agent\": \"antigravity\",  // or \"google-api-nodejs-client/9.15.1\"\n  \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n};\n\n// For loadCodeAssist calls, also include:\nconst CLIENT_METADATA = {\n  ideType: \"ANTIGRAVITY\",  // or \"IDE_UNSPECIFIED\"\n  platform: \"PLATFORM_UNSPECIFIED\",\n  pluginType: \"GEMINI\",\n};\n```\n\n### 3. Model Schema Sanitization\n\nAntigravity uses Gemini-compatible models, so tool schemas must be sanitized:\n\n```typescript\nconst GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([\n  \"patternProperties\",\n  \"additionalProperties\",\n  \"$schema\",\n  \"$id\",\n  \"$ref\",\n  \"$defs\",\n  \"definitions\",\n  \"examples\",\n  \"minLength\",\n  \"maxLength\",\n  \"minimum\",\n  \"maximum\",\n  \"multipleOf\",\n  \"pattern\",\n  \"format\",\n  \"minItems\",\n  \"maxItems\",\n  \"uniqueItems\",\n  \"minProperties\",\n  \"maxProperties\",\n]);\n\n// Clean schema before sending\nfunction cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {\n  // Remove unsupported keywords\n  // Ensure top-level has type: \"object\"\n  // Flatten anyOf/oneOf unions\n}\n```\n\n### 4. Thinking Block Handling (Claude Models)\n\nFor Antigravity Claude models, thinking blocks require special handling:\n\n```typescript\nconst ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;\n\nexport function sanitizeAntigravityThinkingBlocks(\n  messages: AgentMessage[]\n): AgentMessage[] {\n  // Validate thinking signatures\n  // Normalize signature fields\n  // Discard unsigned thinking blocks\n}\n```\n\n---\n\n## API Endpoints\n\n### Authentication Endpoints\n\n| Endpoint | Method | Purpose |\n|----------|--------|---------|\n| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization |\n| `https://oauth2.googleapis.com/token` | POST | Token exchange |\n| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) |\n\n### Cloud Code Assist Endpoints\n\n| Endpoint | Method | Purpose |\n|----------|--------|---------|\n| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan |\n| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas |\n| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint |\n\n**API Request Format (Chat):**\nThe `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request:\n\n```json\n{\n  \"project\": \"your-project-id\",\n  \"model\": \"model-id\",\n  \"request\": {\n    \"contents\": [...],\n    \"systemInstruction\": {...},\n    \"generationConfig\": {...},\n    \"tools\": [...]\n  },\n  \"requestType\": \"agent\",\n  \"userAgent\": \"antigravity\",\n  \"requestId\": \"agent-timestamp-random\"\n}\n```\n\n**API Response Format (SSE):**\nEach SSE message (`data: {...}`) is wrapped in a `response` field:\n\n```json\n{\n  \"response\": {\n    \"candidates\": [...],\n    \"usageMetadata\": {...},\n    \"modelVersion\": \"...\",\n    \"responseId\": \"...\"\n  },\n  \"traceId\": \"...\",\n  \"metadata\": {}\n}\n```\n\n---\n\n## Configuration\n\n### config.json Configuration\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gemini-flash\",\n      \"model\": \"antigravity/gemini-3-flash\",\n      \"auth_method\": \"oauth\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gemini-flash\"\n    }\n  }\n}\n```\n\n### Auth Profile Storage\n\nAuth profiles are stored in `~/.picoclaw/auth.json`:\n\n```json\n{\n  \"credentials\": {\n    \"google-antigravity\": {\n      \"access_token\": \"ya29...\",\n      \"refresh_token\": \"1//...\",\n      \"expires_at\": \"2026-01-01T00:00:00Z\",\n      \"provider\": \"google-antigravity\",\n      \"auth_method\": \"oauth\",\n      \"email\": \"user@example.com\",\n      \"project_id\": \"my-project-id\"\n    }\n  }\n}\n```\n\n---\n\n## Creating a New Provider in PicoClaw\n\nPicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:\n\n### Step-by-Step Implementation\n\n#### 1. Create Provider File\n\nCreate a new Go file in `pkg/providers/`:\n\n```\npkg/providers/\n└── your_provider.go\n```\n\n#### 2. Implement the Provider Interface\n\nYour provider must implement the `Provider` interface defined in `pkg/providers/types.go`:\n\n```go\npackage providers\n\ntype YourProvider struct {\n    apiKey  string\n    apiBase string\n}\n\nfunc NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {\n    if apiBase == \"\" {\n        apiBase = \"https://api.your-provider.com/v1\"\n    }\n    return &YourProvider{apiKey: apiKey, apiBase: apiBase}\n}\n\nfunc (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {\n    // Implement chat completion with streaming\n}\n```\n\n#### 3. Register in the Factory\n\nAdd your provider to the protocol switch in `pkg/providers/factory.go`:\n\n```go\ncase \"your-provider\":\n    return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil\n```\n\n#### 4. Add Default Config (Optional)\n\nAdd a default entry in `pkg/config/defaults.go`:\n\n```go\n{\n    ModelName: \"your-model\",\n    Model:     \"your-provider/model-name\",\n    APIKey:    \"\",\n},\n```\n\n#### 5. Add Auth Support (Optional)\n\nIf your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:\n\n```go\ncase \"your-provider\":\n    authLoginYourProvider()\n```\n\n#### 6. Configure via `config.json`\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"your-model\",\n      \"model\": \"your-provider/model-name\",\n      \"api_key\": \"your-api-key\",\n      \"api_base\": \"https://api.your-provider.com/v1\"\n    }\n  ]\n}\n```\n\n---\n\n## Testing Your Implementation\n\n### CLI Commands\n\n```bash\n# Authenticate with a provider\npicoclaw auth login --provider your-provider\n\n# List models (for Antigravity)\npicoclaw auth models\n\n# Start the gateway\npicoclaw gateway\n\n# Run an agent with a specific model\npicoclaw agent -m \"Hello\" --model your-model\n```\n\n### Environment Variables for Testing\n\n```bash\n# Override default model\nexport PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model\n\n# Override provider settings\nexport PICOCLAW_MODEL_LIST='[{\"model_name\":\"your-model\",\"model\":\"your-provider/model-name\",\"api_key\":\"...\"}]'\n```\n\n---\n\n## References\n\n- **Source Files:**\n  - `pkg/providers/antigravity_provider.go` - Antigravity provider implementation\n  - `pkg/auth/oauth.go` - OAuth flow implementation\n  - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)\n  - `pkg/providers/factory.go` - Provider factory and protocol routing\n  - `pkg/providers/types.go` - Provider interface definitions\n  - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands\n\n- **Documentation:**\n  - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide\n  - `docs/migration/model-list-migration.md` - Migration guide\n\n---\n\n## Notes\n\n1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project\n2. **Quotas:** Uses Google Cloud project quotas (not separate billing)\n3. **Model Access:** Available models depend on your Google Cloud project configuration\n4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures\n5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords\n\n---\n\n---\n\n## Common Error Handling\n\n### 1. Rate Limiting (HTTP 429)\n\nAntigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field.\n\n**Example 429 Error:**\n```json\n{\n  \"error\": {\n    \"code\": 429,\n    \"message\": \"You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.\",\n    \"status\": \"RESOURCE_EXHAUSTED\",\n    \"details\": [\n      {\n        \"@type\": \"type.googleapis.com/google.rpc.ErrorInfo\",\n        \"metadata\": {\n          \"quotaResetDelay\": \"4h30m28.060903746s\"\n        }\n      }\n    ]\n  }\n}\n```\n\n### 2. Empty Responses (Restricted Models)\n\nSome models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use.\n\n**Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project.\n\n---\n\n## Troubleshooting\n\n### \"Token expired\"\n- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`\n\n### \"Gemini for Google Cloud is not enabled\"\n- Enable the API in your Google Cloud Console\n\n### \"Project not found\"\n- Ensure your Google Cloud project has the necessary APIs enabled\n- Check that the project ID is correctly fetched during authentication\n\n### Models not appearing in list\n- Verify OAuth authentication completed successfully\n- Check auth profile storage: `~/.picoclaw/auth.json`\n- Re-run `picoclaw auth login --provider antigravity`\n"
  },
  {
    "path": "docs/ANTIGRAVITY_USAGE.md",
    "content": "# Using Antigravity Provider in PicoClaw\n\nThis guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.\n\n## Prerequisites\n\n1.  A Google account.\n2.  Google Cloud Code Assist enabled (usually available via the \"Gemini for Google Cloud\" onboarding).\n\n## 1. Authentication\n\nTo authenticate with Antigravity, run the following command:\n\n```bash\npicoclaw auth login --provider antigravity\n```\n\n### Manual Authentication (Headless/VPS)\nIf you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:\n1.  Run the command above.\n2.  Copy the URL provided and open it in your local browser.\n3.  Complete the login.\n4.  Your browser will redirect to a `localhost:51121` URL (which will fail to load).\n5.  **Copy that final URL** from your browser's address bar.\n6.  **Paste it back into the terminal** where PicoClaw is waiting.\n\nPicoClaw will extract the authorization code and complete the process automatically.\n\n## 2. Managing Models\n\n### List Available Models\nTo see which models your project has access to and check their quotas:\n\n```bash\npicoclaw auth models\n```\n\n### Switch Models\nYou can change the default model in `~/.picoclaw/config.json` or override it via the CLI:\n\n```bash\n# Override for a single command\npicoclaw agent -m \"Hello\" --model claude-opus-4-6-thinking\n```\n\n## 3. Real-world Usage (Coolify/Docker)\n\nIf you are deploying via Coolify or Docker, follow these steps to test:\n\n1.  **Environment Variables**:\n    *   `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`\n2.  **Authentication persistence**: \n    If you've logged in locally, you can copy your credentials to the server:\n    ```bash\n    scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/\n    ```\n    *Alternatively*, run the `auth login` command once on the server if you have terminal access.\n\n## 4. Troubleshooting\n\n*   **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.\n*   **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the \"reset time\" in the error message if you hit a limit.\n*   **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.\n\n## 5. Summary of Working Models\n\nBased on testing, the following models are most reliable:\n*   `gemini-3-flash` (Fast, highly available)\n*   `gemini-2.5-flash-lite` (Lightweight)\n*   `claude-opus-4-6-thinking` (Powerful, includes reasoning)\n"
  },
  {
    "path": "docs/agent-refactor/README.md",
    "content": "# Agent Refactor\n\n## What this directory is for\n\nThis directory is the working area for the current Agent refactor.\n\nThe purpose of this refactor is simple:\n\nthe project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added.\n\nThe codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior.\n\nThis refactor exists to fix that first.\n\n---\n\n## Refactor stance\n\nThis is a maintenance-led consolidation effort.\n\nIt is not a general invitation to expand Agent behavior in parallel.\n\nDuring this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics.\n\nThat means:\n\n- concept clarification before feature expansion\n- boundary tightening before abstraction growth\n- semantic consolidation before new behavior\n\n---\n\n## Core rule: minimum concepts only\n\nThis refactor follows one hard rule:\n\n**do not introduce a new concept unless it is strictly necessary**\n\nMore explicitly:\n\n- if an existing concept can be clarified, reuse it\n- if an existing boundary can be made explicit, do that first\n- if a behavior can be expressed without a new abstraction, do not add one\n- \"future flexibility\" is not enough justification on its own\n\nThe goal of this refactor is not to grow the model.\n\nThe goal is to reduce ambiguity.\n\n---\n\n## What is being clarified\n\nThis refactor is currently concerned with the following questions:\n\n1. what an `Agent` is\n2. what an `AgentLoop` is\n3. what the lifecycle of `AgentLoop` is\n4. what the event surface around `AgentLoop` is\n5. how persona / identity is assembled\n6. how capabilities are represented\n7. how context boundaries and compression work\n8. how subagent coordination works\n\nThese are the current working boundaries.\n\nIf they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code.\n\n---\n\n## Status of this directory\n\nThe documents here are working materials.\n\nThey are not final or immutable.\n\nIf current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete.\n\n---\n\n## Suggested document split\n\nThis directory may eventually contain notes such as:\n\n- `agent-overview.md`\n  - what an Agent is\n- `agent-loop.md`\n  - AgentLoop contract, lifecycle, event surface\n- `persona.md`\n  - persona and identity assembly\n- `capability.md`\n  - tools / skills / MCP capability semantics\n- `context.md`\n  - context scope, history, summary, compression\n- `subagent.md`\n  - subagent coordination rules\n\nThese files should be added only when they help clarify the current refactor work.\n\nThis directory should not turn into a generic architecture dump.\n\n---\n\n## What this directory is not for\n\nThis directory is not intended for:\n\n- broad speculative architecture\n- future multi-node protocol design not required by the current refactor\n- parallel feature planning unrelated to Agent consolidation\n- adding new concepts before current ones are made clear\n\nIf a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet.\n\n---\n\n## Relationship to implementation\n\nImplementation changes should not keep redefining Agent semantics implicitly.\n\nIf a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first.\n\nThis directory is here to make implementation narrower and more disciplined.\n\n---\n\n## Relationship to GitHub tracking\n\nThe umbrella issue for this refactor should point here.\n\nThe issue is the coordination surface.\n\nThis directory is the repository-local working surface.\n\n---\n\n## Summary\n\nThe main question of this refactor is not:\n\n- what more can Agent do\n\nThe main question is:\n\n- what is the smallest stable model that current Agent behavior can be organized around\n"
  },
  {
    "path": "docs/channels/dingtalk/README.zh.md",
    "content": "# 钉钉\n\n钉钉是阿里巴巴的企业通讯平台，在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n| 字段          | 类型   | 必填 | 描述                             |\n| ------------- | ------ | ---- | -------------------------------- |\n| enabled       | bool   | 是   | 是否启用钉钉频道                 |\n| client_id     | string | 是   | 钉钉应用的 Client ID             |\n| client_secret | string | 是   | 钉钉应用的 Client Secret         |\n| allow_from    | array  | 否   | 用户ID白名单，空表示允许所有用户 |\n\n## 设置流程\n\n1. 前往 [钉钉开放平台](https://open.dingtalk.com/)\n2. 创建一个企业内部应用\n3. 从应用设置中获取 Client ID 和 Client Secret\n4. 配置OAuth和事件订阅(如需要)\n5. 将 Client ID 和 Client Secret 填入配置文件中\n"
  },
  {
    "path": "docs/channels/discord/README.zh.md",
    "content": "# Discord\n\nDiscord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器，支持接收和发送消息。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"],\n      \"group_trigger\": {\n        \"mention_only\": false\n      }\n    }\n  }\n}\n```\n\n| 字段         | 类型   | 必填 | 描述                             |\n| ------------ | ------ | ---- | -------------------------------- |\n| enabled      | bool   | 是   | 是否启用 Discord 频道            |\n| token        | string | 是   | Discord 机器人 Token             |\n| allow_from   | array  | 否   | 用户ID白名单，空表示允许所有用户 |\n| group_trigger | object | 否   | 群组触发设置（示例: { \"mention_only\": false }） |\n\n## 设置流程\n\n1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用\n2. 启用 Intents:\n   - Message Content Intent\n   - Server Members Intent\n3. 获取 Bot Token\n4. 将 Bot Token 填入配置文件中\n5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)\n"
  },
  {
    "path": "docs/channels/feishu/README.zh.md",
    "content": "# 飞书\n\n飞书（国际版名称：Lark）是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"feishu\": {\n      \"enabled\": true,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": [],\n      \"is_lark\": false\n    }\n  }\n}\n```\n\n| 字段                  | 类型   | 必填 | 描述                                                                                             |\n| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ |\n| enabled               | bool   | 是   | 是否启用飞书频道                                                                                 |\n| app_id                | string | 是   | 飞书应用的 App ID(以cli\\_开头)                                                                   |\n| app_secret            | string | 是   | 飞书应用的 App Secret                                                                            |\n| encrypt_key           | string | 否   | 事件回调加密密钥                                                                                 |\n| verification_token    | string | 否   | 用于Webhook事件验证的Token                                                                       |\n| allow_from            | array  | 否   | 用户ID白名单，空表示所有用户                                                                     |\n| random_reaction_emoji | array  | 否   | 随机添加的表情列表，空则使用默认 \"Pin\"                                                           |\n| is_lark               | bool   | 否   | 是否使用 Lark 国际版域名（`open.larksuite.com`），默认为 `false`（使用飞书域名 `open.feishu.cn`） |\n\n## 设置流程\n\n1. 前往 [飞书开放平台](https://open.feishu.cn/)（国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/)）创建应用程序\n2. 获取 App ID 和 App Secret\n3. 配置事件订阅和Webhook URL\n4. 设置加密(可选,生产环境建议启用)\n5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中\n6. 自定义你希望 PicoClaw react 你消息时的表情（可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))\n"
  },
  {
    "path": "docs/channels/line/README.zh.md",
    "content": "# Line\n\nPicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n| 字段                 | 类型   | 必填 | 描述                                       |\n| -------------------- | ------ | ---- | ------------------------------------------ |\n| enabled              | bool   | 是   | 是否启用 LINE Channel                      |\n| channel_secret       | string | 是   | LINE Messaging API 的 Channel Secret       |\n| channel_access_token | string | 是   | LINE Messaging API 的 Channel Access Token |\n| webhook_path         | string | 否   | Webhook 的路径 (默认为 /webhook/line)      |\n| allow_from           | array  | 否   | 用户ID白名单，空表示允许所有用户           |\n\n## 设置流程\n\n1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel\n2. 获取 Channel Secret 和 Channel Access Token\n3. 配置Webhook:\n   - LINE 要求 Webhook 必须使用 HTTPS 协议，因此需要部署一个支持 HTTPS 的服务器，或者使用反向代理工具如 ngrok 将本地服务器暴露到公网\n   - PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调，默认监听地址为 127.0.0.1:18790\n   - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`，然后将外部域名反向代理到本机的 Gateway（默认端口 18790）\n   - 启用 Webhook 并验证 URL\n4. 将 Channel Secret 和 Channel Access Token 填入配置文件中\n"
  },
  {
    "path": "docs/channels/maixcam/README.zh.md",
    "content": "# MaixCam\n\nMaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信，支持边缘 AI 部署场景。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"maixcam\": {\n      \"enabled\": true,\n      \"server_address\": \"0.0.0.0:8899\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n| 字段           | 类型   | 必填 | 描述                             |\n| -------------- | ------ | ---- | -------------------------------- |\n| enabled        | bool   | 是   | 是否启用 MaixCam 频道            |\n| server_address | string | 是   | TCP 服务器监听地址和端口         |\n| allow_from     | array  | 否   | 设备ID白名单，空表示允许所有设备 |\n\n## 使用场景\n\nMaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行：\n\n- **智能监控** ：MaixCAM 发送图像帧，PicoClaw 通过视觉模型进行分析\n- **物联网控制** ：设备发送传感器数据，PicoClaw 协调响应\n- **离线AI** ：在本地网络部署 PicoClaw 实现低延迟推理\n"
  },
  {
    "path": "docs/channels/matrix/README.md",
    "content": "# Matrix Channel Configuration Guide\n\n## 1. Example Configuration\n\nAdd this to `config.json`:\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"device_id\": \"\",\n      \"join_on_invite\": true,\n      \"allow_from\": [],\n      \"group_trigger\": {\n        \"mention_only\": true\n      },\n      \"placeholder\": {\n        \"enabled\": true,\n        \"text\": \"Thinking...\"\n      },\n      \"reasoning_channel_id\": \"\",\n      \"message_format\": \"richtext\"\n    }\n  }\n}\n```\n\n## 2. Field Reference\n\n| Field                | Type     | Required | Description |\n|----------------------|----------|----------|-------------|\n| enabled              | bool     | Yes      | Enable or disable the Matrix channel |\n| homeserver           | string   | Yes      | Matrix homeserver URL (for example `https://matrix.org`) |\n| user_id              | string   | Yes      | Bot Matrix user ID (for example `@bot:matrix.org`) |\n| access_token         | string   | Yes      | Bot access token |\n| device_id            | string   | No       | Optional Matrix device ID |\n| join_on_invite       | bool     | No       | Auto-join invited rooms |\n| allow_from           | []string | No       | User whitelist (Matrix user IDs) |\n| group_trigger        | object   | No       | Group trigger strategy (`mention_only` / `prefixes`) |\n| placeholder          | object   | No       | Placeholder message config |\n| reasoning_channel_id | string   | No       | Target channel for reasoning output |\n| message_format       | string   | No       | Output format: `\"richtext\"` (default) renders markdown as HTML; `\"plain\"` sends plain text only |\n\n## 3. Currently Supported\n\n- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)\n- Configurable message format (`richtext` / `plain`)\n- Incoming image/audio/video/file download (MediaStore first, local path fallback)\n- Incoming audio normalization into existing transcription flow (`[audio: ...]`)\n- Outgoing image/audio/video/file upload and send\n- Group trigger rules (including mention-only mode)\n- Typing state (`m.typing`)\n- Placeholder message + final reply replacement\n- Auto-join invited rooms (can be disabled)\n\n## 4. TODO\n\n- Rich media metadata improvements (for example image/video size and thumbnails)\n"
  },
  {
    "path": "docs/channels/matrix/README.zh.md",
    "content": "# Matrix 通道配置指南\n\n## 1. 配置示例\n\n在 `config.json` 中添加：\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"device_id\": \"\",\n      \"join_on_invite\": true,\n      \"allow_from\": [],\n      \"group_trigger\": {\n        \"mention_only\": true\n      },\n      \"placeholder\": {\n        \"enabled\": true,\n        \"text\": \"Thinking... 💭\"\n      },\n      \"reasoning_channel_id\": \"\"\n    }\n  }\n}\n```\n\n## 2. 参数说明\n\n| 字段                 | 类型     | 必填 | 说明 |\n|----------------------|----------|------|------|\n| enabled              | bool     | 是   | 是否启用 Matrix 通道 |\n| homeserver           | string   | 是   | Matrix 服务器地址（例如 `https://matrix.org`） |\n| user_id              | string   | 是   | 机器人 Matrix 用户 ID（例如 `@bot:matrix.org`） |\n| access_token         | string   | 是   | 机器人 access token |\n| device_id            | string   | 否   | 设备 ID（可选） |\n| join_on_invite       | bool     | 否   | 是否自动加入邀请房间 |\n| allow_from           | []string | 否   | 白名单用户（Matrix 用户 ID） |\n| group_trigger        | object   | 否   | 群聊触发策略（支持 `mention_only` / `prefixes`） |\n| placeholder          | object   | 否   | 占位消息配置 |\n| reasoning_channel_id | string   | 否   | 思维链输出目标通道 |\n\n## 3. 当前支持\n\n- 文本消息收发\n- 图片/音频/视频/文件消息入站下载（写入 MediaStore / 本地路径回退）\n- 音频消息按统一标记进入现有转写流程（`[audio: ...]`）\n- 图片/音频/视频/文件消息出站发送（上传到 Matrix 媒体库后发送）\n- 群聊触发规则（支持仅 @ 提及时响应）\n- Typing 状态（`m.typing`）\n- 占位消息（`Thinking... 💭`）+ 最终回复替换\n- 自动加入邀请房间（可关闭）\n\n## 4. TODO\n\n- 富媒体细节增强（如 image/video 的尺寸、缩略图等 metadata）\n"
  },
  {
    "path": "docs/channels/onebot/README.zh.md",
    "content": "# OneBot\n\nOneBot 是一个面向 QQ 机器人的开放协议标准，为多种 QQ 机器人实现（例如 go-cqhttp、Mirai）提供了统一的接口。它使用 WebSocket 进行通信。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"onebot\": {\n      \"enabled\": true,\n      \"ws_url\": \"ws://localhost:8080\",\n      \"access_token\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n| 字段         | 类型   | 必填 | 描述                             |\n| ------------ | ------ | ---- | -------------------------------- |\n| enabled      | bool   | 是   | 是否启用 OneBot 频道             |\n| ws_url       | string | 是   | OneBot 服务器的 WebSocket URL    |\n| access_token | string | 否   | 连接 OneBot 服务器的访问令牌     |\n| allow_from   | array  | 否   | 用户ID白名单，空表示允许所有用户 |\n\n## 设置流程\n\n1. 部署一个 OneBot 兼容的实现(例如napcat)\n2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)\n3. 将 WebSocket URL 和访问令牌填入配置文件中\n"
  },
  {
    "path": "docs/channels/qq/README.zh.md",
    "content": "# QQ\n\nPicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": [],\n      \"max_base64_file_size_mib\": 0\n    }\n  }\n}\n```\n\n| 字段                 | 类型   | 必填 | 描述                                                         |\n| -------------------- | ------ | ---- | ------------------------------------------------------------ |\n| enabled              | bool   | 是   | 是否启用 QQ Channel                                          |\n| app_id               | string | 是   | QQ 机器人应用的 App ID                                       |\n| app_secret           | string | 是   | QQ 机器人应用的 App Secret                                   |\n| allow_from           | array  | 否   | 用户ID白名单，空表示允许所有用户                             |\n| max_base64_file_size_mib | int | 否   | 本地文件转 base64 上传的最大体积，单位 MiB；`0` 表示不限制。仅影响本地文件，不影响 URL 直传 |\n\n## 设置流程\n\n1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人\n2. 通过仪表盘获取 App ID 和 App Secret\n3. 开启机器人沙箱模式, 将用户和群添加到沙箱中\n4. 将 App ID 和 App Secret 填入配置文件中\n"
  },
  {
    "path": "docs/channels/slack/README.zh.md",
    "content": "# Slack\n\nSlack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信，无需配置公开的 Webhook 端点。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"slack\": {\n      \"enabled\": true,\n      \"bot_token\": \"xoxb-...\",\n      \"app_token\": \"xapp-...\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n| 字段       | 类型   | 必填 | 描述                                                     |\n| ---------- | ------ | ---- | -------------------------------------------------------- |\n| enabled    | bool   | 是   | 是否启用 Slack 频道                                      |\n| bot_token  | string | 是   | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头)      |\n| app_token  | string | 是   | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |\n| allow_from | array  | 否   | 用户ID白名单，空表示允许所有用户                         |\n\n## 设置流程\n\n1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用\n2. 启用 Socket Mode 并获取 App Level Token\n3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)\n4. 安装应用到工作区并获取 Bot User OAuth Token\n5. 将 Bot Token 和 App Token 填入配置文件中\n"
  },
  {
    "path": "docs/channels/telegram/README.zh.md",
    "content": "# Telegram\n\nTelegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件（照片、语音、音频、文档）、通过 Groq Whisper 进行语音转录以及内置命令处理器。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456789:ABCdefGHIjklMNOpqrsTUVwxyz\",\n      \"allow_from\": [\"123456789\"],\n      \"proxy\": \"\"\n    }\n  }\n}\n```\n\n| 字段       | 类型   | 必填 | 描述                                                      |\n| ---------- | ------ | ---- | --------------------------------------------------------- |\n| enabled    | bool   | 是   | 是否启用 Telegram 频道                                    |\n| token      | string | 是   | Telegram 机器人 API Token                                 |\n| allow_from | array  | 否   | 用户ID白名单，空表示允许所有用户                          |\n| proxy      | string | 否   | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |\n\n## 设置流程\n\n1. 在 Telegram 中搜索 `@BotFather`\n2. 发送 `/newbot` 命令并按照提示创建新机器人\n3. 获取 HTTP API Token\n4. 将 Token 填入配置文件中\n5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)\n"
  },
  {
    "path": "docs/channels/wecom/wecom_aibot/README.zh.md",
    "content": "# 企业微信智能机器人 (AI Bot)\n\n企业微信智能机器人（AI Bot）是企业微信官方提供的 AI 对话接入方式，支持私聊与群聊，内置流式响应协议。PicoClaw 当前同时支持两种接入模式：\n\n- WebSocket 长连接模式：使用 `bot_id` + `secret`，优先级更高，推荐使用\n- Webhook 短连接模式：使用 `token` + `encoding_aes_key`，兼容传统回调，并支持超时后通过 `response_url` 主动推送最终回复\n\n## 与其他 WeCom 通道的对比\n\n| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** |\n|------|-----------|-----------|-----------------|\n| 私聊 | ✅ | ✅ | ✅ |\n| 群聊 | ✅ | ❌ | ✅ |\n| 流式输出 | ❌ | ❌ | ✅ |\n| 超时主动推送 | ❌ | ✅ | ✅ |\n| 配置复杂度 | 低 | 高 | 中 |\n\n## 配置\n\n### WebSocket 长连接模式（推荐）\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"bot_id\": \"YOUR_BOT_ID\",\n      \"secret\": \"YOUR_SECRET\",\n      \"allow_from\": [],\n      \"welcome_message\": \"你好！有什么可以帮助你的吗？\",\n      \"max_steps\": 10\n    }\n  }\n}\n```\n\n### Webhook 短连接模式\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"你好！有什么可以帮助你的吗？\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\",\n      \"max_steps\": 10\n    }\n  }\n}\n```\n\n### WebSocket 模式字段\n\n| 字段   | 类型   | 必填 | 描述                                       |\n|--------|--------|------|--------------------------------------------|\n| bot_id | string | 是   | AI Bot 的唯一标识，在 AI Bot 管理页面配置 |\n| secret | string | 是   | AI Bot 的密钥，在 AI Bot 管理页面配置     |\n\n### Webhook 模式字段\n\n| 字段             | 类型   | 必填 | 描述                                         |\n|------------------|--------|------|----------------------------------------------|\n| token            | string | 是   | 回调验证令牌，在 AI Bot 管理页面配置         |\n| encoding_aes_key | string | 是   | 43 字符 AES 密钥，在 AI Bot 管理页面随机生成 |\n| webhook_path     | string | 否   | Webhook 路径，默认 `/webhook/wecom-aibot`    |\n| processing_message | string | 否 | 流式超时后返回给用户的提示语                 |\n\n### 通用字段\n\n| 字段            | 类型   | 必填 | 描述                                     |\n|-----------------|--------|------|------------------------------------------|\n| allow_from      | array  | 否   | 用户 ID 白名单，空数组表示允许所有用户   |\n| welcome_message | string | 否   | 用户进入聊天时发送的欢迎语，留空则不发送 |\n| reply_timeout   | int    | 否   | 回复超时时间（秒，默认：5）              |\n| max_steps       | int    | 否   | Agent 最大执行步骤数（默认：10）         |\n\n## 模式选择\n\n- 当 `bot_id` 和 `secret` 同时存在时，PicoClaw 会优先使用 WebSocket 长连接模式\n- 否则，当 `token` 和 `encoding_aes_key` 同时存在时，PicoClaw 会使用 Webhook 短连接模式\n\n## 设置流程\n\n### WebSocket 长连接模式\n\n1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)\n2. 进入\"应用管理\" → \"智能机器人\"，创建或选择一个 AI Bot\n3. 在 AI Bot 配置页面，配置 Bot 的名称、头像等信息，获取 `Bot ID` 和 `Secret`\n4. 在 PicoClaw 配置文件中添加上述配置，重启 PicoClaw\n\n### Webhook 短连接模式\n\n1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)\n2. 进入\"应用管理\" → \"智能机器人\"，创建或选择一个 AI Bot\n3. 在 AI Bot 配置页面，填写\"消息接收\"信息：\n   - **URL**：`http://<your-server-ip>:18791/webhook/wecom-aibot`\n   - **Token**：随机生成或自定义\n   - **EncodingAESKey**：点击\"随机生成\"，得到 43 字符密钥\n4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件，启动服务后回到管理后台保存\n\n> [!TIP]\n> 服务器需要能被企业微信服务器访问。如在内网或本地开发，可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。\n\n## Webhook 模式的流式响应协议\n\nWebhook 模式使用\"流式拉取\"协议，区别于普通 Webhook 的一次性回复：\n\n```\n用户发消息\n  │\n  ▼\nPicoClaw 立即返回 {finish: false}（Agent 开始处理）\n  │\n  ▼\n企业微信每隔约 1 秒拉取一次 {msgtype: \"stream\", stream: {id: \"...\"}}\n  │\n  ├─ Agent 未完成 → 返回 {finish: false}（继续等待）\n  │\n  └─ Agent 完成 → 返回 {finish: true, content: \"回答内容\"}\n```\n\n**超时处理**（任务超过约 30 秒）：\n\n若 Agent 处理时间超过轮询窗口，PicoClaw 会：\n\n1. 立即关闭流，向用户显示 `processing_message` 提示语\n2. Agent 继续在后台运行\n3. Agent 完成后，通过消息中携带的 `response_url` 将最终回复主动推送给用户\n\n> `response_url` 由企业微信颁发，有效期 1 小时，只可使用一次，无需加密，直接 POST markdown 消息体即可。\n\n## 超时提示语\n\n配置 `processing_message` 后，当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时，PicoClaw 会先返回这段提示语来结束当前流。\n\n```json\n\"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n```\n\n## 欢迎语\n\n配置 `welcome_message` 后，当用户打开与 AI Bot 的聊天窗口时（`enter_chat` 事件），PicoClaw 会自动回复该欢迎语。留空则静默忽略。\n\n```json\n\"welcome_message\": \"你好！我是 PicoClaw AI 助手，有什么可以帮你？\"\n```\n\n## 常见问题\n\n### WebSocket 模式无法连接\n\n- 检查 `bot_id` 和 `secret` 是否填写正确\n- 查看日志中是否有 WebSocket 连接或鉴权失败信息\n- 确认服务器可以访问企业微信长连接接口\n\n### 回调 URL 验证失败\n\n- 确认 `token` 与 `encoding_aes_key` 填写正确\n- 确认服务器防火墙已开放对应端口\n- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求\n\n### 消息没有回复\n\n- 检查 `allow_from` 是否意外限制了发送者\n- 查看日志中是否出现 `context canceled` 或 Agent 错误\n- 确认 Agent 配置（`model_name` 等）正确\n\n### 超长任务没有收到最终推送\n\n- 确认消息回调中携带了 `response_url`\n- 确认服务器能主动访问外网\n- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url`\n\n## 参考文档\n\n- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463)\n- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)\n- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)\n"
  },
  {
    "path": "docs/channels/wecom/wecom_app/README.zh.md",
    "content": "# 企业微信自建应用\n\n企业微信自建应用是指企业在企业微信中创建的应用，主要用于企业内部使用。通过企业微信自建应用，企业可以实现与员工的高效沟通和协作，提高工作效率。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": [],\n      \"reply_timeout\": 5\n    }\n  }\n}\n```\n\n| 字段             | 类型   | 必填 | 描述                                     |\n| ---------------- | ------ | ---- | ---------------------------------------- |\n| corp_id          | string | 是   | 企业 ID                                  |\n| corp_secret      | string | 是   | 应用程序密钥                             |\n| agent_id         | int    | 是   | 应用程序代理 ID                          |\n| token            | string | 是   | 回调验证令牌                             |\n| encoding_aes_key | string | 是   | 43 字符 AES 密钥                         |\n| webhook_path     | string | 否   | Webhook 路径（默认：/webhook/wecom-app） |\n| allow_from       | array  | 否   | 用户 ID 白名单                           |\n| reply_timeout    | int    | 否   | 回复超时时间（秒）                       |\n\n## 设置流程\n\n1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)\n2. 进入“应用管理” -> “创建应用”\n3. 获取企业 ID (CorpID) 和应用 Secret\n4. 在应用设置中配置“接收消息”，获取 Token 和 EncodingAESKey\n5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`\n6. 将 CorpID, Secret, AgentID 等信息填入配置文件\n\n   注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调，默认监听地址为 127.0.0.1:18790。如需从公网接收回调，请把外部域名反向代理到 Gateway（默认端口 18790）。\n"
  },
  {
    "path": "docs/channels/wecom/wecom_bot/README.zh.md",
    "content": "# 企业微信机器人\n\n企业微信机器人是企业微信提供的一种快速接入方式，可以通过 Webhook URL 接收消息。\n\n## 配置\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": [],\n      \"reply_timeout\": 5\n    }\n  }\n}\n```\n\n| 字段             | 类型   | 必填 | 描述                                         |\n| ---------------- | ------ | ---- | -------------------------------------------- |\n| token            | string | 是   | 签名验证代币                                 |\n| encoding_aes_key | string | 是   | 用于解密的 43 字符 AES 密钥                  |\n| webhook_url      | string | 是   | 用于发送回复的企业微信群聊机器人 Webhook URL |\n| webhook_path     | string | 否   | Webhook 端点路径（默认：/webhook/wecom）     |\n| allow_from       | array  | 否   | 用户 ID 白名单（空值 = 允许所有用户）        |\n| reply_timeout    | int    | 否   | 回复超时时间（单位：秒，默认值：5）          |\n\n## 设置流程\n\n1. 在企业微信群中添加机器人\n2. 获取 Webhook URL\n3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址（回调地址）以及 Token 和 EncodingAESKey\n4. 将相关信息填入配置文件\n\n   注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调，默认监听地址为 127.0.0.1:18790。如需从公网接收回调，请把外部域名反向代理到 Gateway（默认端口 18790）。\n"
  },
  {
    "path": "docs/chat-apps.md",
    "content": "# 💬 Chat Apps Configuration\n\n> Back to [README](../README.md)\n\n## 💬 Chat Apps\n\nTalk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)\n\n> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.\n\n| Channel      | Setup                              |\n| ------------ | ---------------------------------- |\n| **Telegram** | Easy (just a token)                |\n| **Discord**  | Easy (bot token + intents)         |\n| **WhatsApp** | Easy (native: QR scan; or bridge URL) |\n| **Matrix**   | Medium (homeserver + bot access token) |\n| **QQ**       | Easy (AppID + AppSecret)           |\n| **DingTalk** | Medium (app credentials)           |\n| **LINE**     | Medium (credentials + webhook URL) |\n| **WeCom AI Bot** | Medium (Token + AES key)       |\n| **Feishu**   | Medium (App ID + Secret, WebSocket mode) |\n| **Slack**    | Medium (Bot token + App token) |\n| **IRC**      | Medium (server + TLS config)   |\n| **OneBot**   | Medium (QQ via OneBot protocol) |\n| **MaixCam**  | Easy (Sipeed hardware integration) |\n| **Pico**     | Native PicoClaw protocol           |\n\n<details>\n<summary><b>Telegram</b> (Recommended)</summary>\n\n**1. Create a bot**\n\n* Open Telegram, search `@BotFather`\n* Send `/newbot`, follow prompts\n* Copy the token\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"],\n      \"use_markdown_v2\": false,\n    }\n  }\n}\n```\n\n> Get your user ID from `@userinfobot` on Telegram.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n**4. Telegram command menu (auto-registered at startup)**\n\nPicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.\nTelegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.\n\nIf command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.\n\n**4. Advanced Formatting**\nYou can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Create a bot**\n\n* Go to <https://discord.com/developers/applications>\n* Create an application → Bot → Add Bot\n* Copy the bot token\n\n**2. Enable intents**\n\n* In the Bot settings, enable **MESSAGE CONTENT INTENT**\n* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data\n\n**3. Get your User ID**\n* Discord Settings → Advanced → enable **Developer Mode**\n* Right-click your avatar → **Copy User ID**\n\n**4. Configure**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Invite the bot**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* Open the generated invite URL and add the bot to your server\n\n**Optional: Group trigger mode**\n\nBy default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nYou can also trigger by keyword prefixes (e.g. `!bot`):\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. Run**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b> (native via whatsmeow)</summary>\n\nPicoClaw can connect to WhatsApp in two ways:\n\n- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `\"use_native\": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).\n- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.\n\n**Configure (native)**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\nIf `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Create a bot**\n\n- Go to [QQ Open Platform](https://q.qq.com/#)\n- Create an application → Get **AppID** and **AppSecret**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Create a bot**\n\n* Go to [Open Platform](https://open.dingtalk.com/)\n* Create an internal app\n* Copy Client ID and Client Secret\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Prepare bot account**\n\n* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)\n* Create a bot user and obtain its access token\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\nFor full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. Create a LINE Official Account**\n\n- Go to [LINE Developers Console](https://developers.line.biz/)\n- Create a provider → Create a Messaging API channel\n- Copy **Channel Secret** and **Channel Access Token**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).\n\n**3. Set up Webhook URL**\n\nLINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:\n\n```bash\n# Example with ngrok (gateway default port is 18790)\nngrok http 18790\n```\n\nThen set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.\n\n**4. Run**\n\n```bash\npicoclaw gateway\n```\n\n> In group chats, the bot responds only when @mentioned. Replies quote the original message.\n\n</details>\n\n<details>\n<summary><b>WeCom (企业微信)</b></summary>\n\nPicoClaw supports three types of WeCom integration:\n\n**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats\n**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only\n**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat\n\nSee [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.\n\n**Quick Setup - WeCom Bot:**\n\n**1. Create a bot**\n\n* Go to WeCom Admin Console → Group Chat → Add Group Bot\n* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).\n\n**Quick Setup - WeCom App:**\n\n**1. Create an app**\n\n* Go to WeCom Admin Console → App Management → Create App\n* Copy **AgentId** and **Secret**\n* Go to \"My Company\" page, copy **CorpID**\n\n**2. Configure receive message**\n\n* In App details, click \"Receive Message\" → \"Set API\"\n* Set URL to `http://your-server:18790/webhook/wecom-app`\n* Generate **Token** and **EncodingAESKey**\n\n**3. Configure**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. Run**\n\n```bash\npicoclaw gateway\n```\n\n> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.\n\n**Quick Setup - WeCom AI Bot:**\n\n**1. Create an AI Bot**\n\n* Go to WeCom Admin Console → App Management → AI Bot\n* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot`\n* Copy **Token** and click \"Random Generate\" for **EncodingAESKey**\n\n**2. Configure**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"Hello! How can I help you?\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n    }\n  }\n}\n```\n\n**3. Run**\n\n```bash\npicoclaw gateway\n```\n\n> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.\n\n</details>\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# ⚙️ Configuration Guide\n\n> Back to [README](../README.md)\n\n## ⚙️ Configuration\n\nConfig file: `~/.picoclaw/config.json`\n\n### Environment Variables\n\nYou can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.\n\n| Variable          | Description                                                                                                                             | Default Path              |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories.          | `~/.picoclaw`             |\n\n**Examples:**\n\n```bash\n# Run picoclaw using a specific config file\n# The workspace path will be read from within that config file\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# Run picoclaw with all its data stored in /opt/picoclaw\n# Config will be loaded from the default ~/.picoclaw/config.json\n# Workspace will be created at /opt/picoclaw/workspace\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# Use both for a fully customized setup\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### Workspace Layout\n\nPicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # Conversation sessions and history\n├── memory/           # Long-term memory (MEMORY.md)\n├── state/            # Persistent state (last channel, etc.)\n├── cron/             # Scheduled jobs database\n├── skills/           # Custom skills\n├── AGENT.md          # Agent behavior guide\n├── HEARTBEAT.md      # Periodic task prompts (checked every 30 min)\n├── IDENTITY.md       # Agent identity\n├── SOUL.md           # Agent soul\n└── USER.md           # User preferences\n```\n\n> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.\n\n### Skill Sources\n\nBy default, skills are loaded from:\n\n1. `~/.picoclaw/workspace/skills` (workspace)\n2. `~/.picoclaw/skills` (global)\n3. `<current-working-directory>/skills` (builtin)\n\nFor advanced/test setups, you can override the builtin skills root with:\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### Unified Command Execution Policy\n\n- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.\n- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.\n- Unknown slash command (for example `/foo`) passes through to normal LLM processing.\n- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.\n\n### Agent Bindings (Route messages to specific agents)\n\nUse `bindings` in `config.json` to route incoming messages to different agents by channel/account/context.\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-4o-mini\"\n    },\n    \"list\": [\n      { \"id\": \"main\", \"default\": true, \"name\": \"Main Assistant\" },\n      { \"id\": \"support\", \"name\": \"Support Assistant\" },\n      { \"id\": \"sales\", \"name\": \"Sales Assistant\" }\n    ]\n  },\n  \"bindings\": [\n    {\n      \"agent_id\": \"support\",\n      \"match\": {\n        \"channel\": \"telegram\",\n        \"account_id\": \"*\",\n        \"peer\": { \"kind\": \"direct\", \"id\": \"user123\" }\n      }\n    },\n    {\n      \"agent_id\": \"sales\",\n      \"match\": {\n        \"channel\": \"discord\",\n        \"account_id\": \"my-discord-bot\",\n        \"guild_id\": \"987654321\"\n      }\n    }\n  ]\n}\n```\n\n#### `bindings` fields\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `agent_id` | Yes | Target agent id in `agents.list` |\n| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) |\n| `match.account_id` | No | Channel account filter. Use `\"*\"` for all accounts of that channel. If omitted, only default account is matched |\n| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) |\n| `match.guild_id` | No | Guild/server-level match |\n| `match.team_id` | No | Team/workspace-level match |\n\n#### Matching priority\n\nWhen multiple bindings exist, PicoClaw resolves in this order:\n\n1. `peer`\n2. `parent_peer` (for thread/topic parent contexts)\n3. `guild_id`\n4. `team_id`\n5. `account_id` (non-wildcard)\n6. channel wildcard (`account_id: \"*\"`)\n7. default agent\n\nIf a binding points to a missing `agent_id`, PicoClaw falls back to the default agent.\n\n#### How matching works (step-by-step)\n\n1. PicoClaw first filters bindings by `match.channel` (must equal current channel).\n2. It then filters by `match.account_id`:\n   - omitted: match only the channel's default account\n   - `\"*\"`: match all accounts on this channel\n   - explicit value: exact account id match (case-insensitive)\n3. From the remaining candidates, it applies the priority chain above and stops at the first hit.\n\nIn other words: **channel + account form the candidate set; peer/guild/team then decide final winner**.\n\n#### Common recipes\n\n**1) Route one specific DM user to a specialist agent**\n\n```json\n{\n  \"agent_id\": \"support\",\n  \"match\": {\n    \"channel\": \"telegram\",\n    \"account_id\": \"*\",\n    \"peer\": { \"kind\": \"direct\", \"id\": \"user123\" }\n  }\n}\n```\n\n**2) Route one Discord server (guild) to a dedicated agent**\n\n```json\n{\n  \"agent_id\": \"sales\",\n  \"match\": {\n    \"channel\": \"discord\",\n    \"account_id\": \"my-discord-bot\",\n    \"guild_id\": \"987654321\"\n  }\n}\n```\n\n**3) Route all remaining traffic of a channel to a fallback agent**\n\n```json\n{\n  \"agent_id\": \"main\",\n  \"match\": {\n    \"channel\": \"discord\",\n    \"account_id\": \"*\"\n  }\n}\n```\n\n#### Authoring guidelines (important)\n\n- Keep exactly one clear default agent in `agents.list` (`\"default\": true`).\n- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: \"*\"` only) together safely; priority already guarantees specific rules win.\n- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins.\n- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default.\n\n#### Troubleshooting checklist\n\n- **Rule not taking effect?** Check `match.channel` spelling first (must be exact).\n- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id.\n- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths.\n- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled.\n\n### 🔒 Security Sandbox\n\nPicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.\n\n#### Default Configuration\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| Option                  | Default                 | Description                               |\n| ----------------------- | ----------------------- | ----------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Working directory for the agent           |\n| `restrict_to_workspace` | `true`                  | Restrict file/command access to workspace |\n\n#### Protected Tools\n\nWhen `restrict_to_workspace: true`, the following tools are sandboxed:\n\n| Tool          | Function         | Restriction                            |\n| ------------- | ---------------- | -------------------------------------- |\n| `read_file`   | Read files       | Only files within workspace            |\n| `write_file`  | Write files      | Only files within workspace            |\n| `list_dir`    | List directories | Only directories within workspace      |\n| `edit_file`   | Edit files       | Only files within workspace            |\n| `append_file` | Append to files  | Only files within workspace            |\n| `exec`        | Execute commands | Command paths must be within workspace |\n\n#### Additional Exec Protection\n\nEven with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:\n\n* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion\n* `format`, `mkfs`, `diskpart` — Disk formatting\n* `dd if=` — Disk imaging\n* Writing to `/dev/sd[a-z]` — Direct disk writes\n* `shutdown`, `reboot`, `poweroff` — System shutdown\n* Fork bomb `:(){ :|:& };:`\n\n### File Access Control\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |\n| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |\n\n### Exec Security\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) |\n| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow |\n\n> **Security Note:** Symlink protection is enabled by default — all file paths are resolved through `filepath.EvalSymlinks` before whitelist matching, preventing symlink escape attacks.\n\n#### Known Limitation: Child Processes From Build Tools\n\nThe exec safety guard only inspects the command line PicoClaw launches directly. It does not recursively inspect child\nprocesses spawned by allowed developer tools such as `make`, `go run`, `cargo`, `npm run`, or custom build scripts.\n\nThat means a top-level command can still compile or launch other binaries after it passes the initial guard check. In\npractice, treat build scripts, Makefiles, package scripts, and generated binaries as executable code that needs the same\nlevel of review as a direct shell command.\n\nFor higher-risk environments:\n\n* Review build scripts before execution.\n* Prefer approval/manual review for compile-and-run workflows.\n* Run PicoClaw inside a container or VM if you need stronger isolation than the built-in guard provides.\n\n#### Error Examples\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### Disabling Restrictions (Security Risk)\n\nIf you need the agent to access paths outside the workspace:\n\n**Method 1: Config file**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**Method 2: Environment variable**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only.\n\n#### Security Boundary Consistency\n\nThe `restrict_to_workspace` setting applies consistently across all execution paths:\n\n| Execution Path   | Security Boundary            |\n| ---------------- | ---------------------------- |\n| Main Agent       | `restrict_to_workspace` ✅   |\n| Subagent / Spawn | Inherits same restriction ✅ |\n| Heartbeat tasks  | Inherits same restriction ✅ |\n\nAll paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.\n\n### Heartbeat (Periodic Tasks)\n\nPicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:\n\n```markdown\n# Periodic Tasks\n\n- Check my email for important messages\n- Review my calendar for upcoming events\n- Check the weather forecast\n```\n\nThe agent will read this file every 30 minutes (configurable) and execute any tasks using available tools.\n\n#### Async Tasks with Spawn\n\nFor long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**:\n\n```markdown\n# Periodic Tasks\n"
  },
  {
    "path": "docs/credential_encryption.md",
    "content": "# Credential Encryption\n\nPicoClaw supports encrypting `api_key` values in `model_list` configuration entries.\nEncrypted keys are stored as `enc://<base64>` strings and decrypted automatically at startup.\n\n---\n\n## Quick Start\n\n**1. Set your passphrase**\n\n```bash\nexport PICOCLAW_KEY_PASSPHRASE=\"your-passphrase\"\n```\n\n**2. Encrypt an API key**\n\nRun `picoclaw onboard` — it prompts for your passphrase and generates the SSH key,\nthen automatically re-encrypts any plaintext `api_key` entries in your config on\nthe next `SaveConfig` call. The resulting `enc://` value will look like:\n\n```\nenc://AAAA...base64...\n```\n\n**3. Paste the output into your config**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-4o\",\n      \"api_key\": \"enc://AAAA...base64...\",\n      \"base_url\": \"https://api.openai.com/v1\"\n    }\n  ]\n}\n```\n\n---\n\n## Supported `api_key` Formats\n\n| Format | Example | Behaviour |\n|--------|---------|-----------|\n| Plaintext | `sk-abc123` | Used as-is |\n| File reference | `file://openai.key` | Content read from the same directory as the config file |\n| Encrypted | `enc://<base64>` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` |\n| Empty | `\"\"` | Passed through unchanged (used with `auth_method: oauth`) |\n\n---\n\n## Cryptographic Design\n\n### Key Derivation\n\nEncryption uses **HKDF-SHA256** with an optional SSH private key as a second factor.\n\n```\nWithout SSH key (passphrase only):\n\n  ikm     = SHA256(passphrase)\n  aes_key = HKDF-SHA256(ikm, salt, info=\"picoclaw-credential-v1\", 32 bytes)\n\n\nWith SSH key (recommended):\n\n  sshHash = SHA256(ssh_private_key_file_bytes)\n  ikm     = HMAC-SHA256(key=sshHash, message=passphrase)\n  aes_key = HKDF-SHA256(ikm, salt, info=\"picoclaw-credential-v1\", 32 bytes)\n```\n\n### Encryption\n\n```\nAES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)\n```\n\n### Wire Format\n\n```\nenc://<base64( salt[16] + nonce[12] + ciphertext )>\n```\n\n| Field | Size | Description |\n|-------|------|-------------|\n| `salt` | 16 bytes | Random per encryption; fed into HKDF |\n| `nonce` | 12 bytes | Random per encryption; AES-GCM IV |\n| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag |\n\nThe GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext.\n\n### Performance\n\n| Operation | Time (ARM Cortex-A) |\n|-----------|---------------------|\n| Key derivation (HKDF) | < 1 ms |\n| AES-256-GCM decrypt | < 1 ms |\n| **Total startup overhead** | **< 2 ms per key** |\n\n---\n\n## Two-Factor Security with SSH Key\n\nWhen a SSH private key is provided, breaking the encryption requires **both**:\n\n1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`)\n2. The **SSH private key file**\n\nThis means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength.\n\n### Threat Model\n\n| Attacker Has | Can Decrypt? |\n|---|---|\n| Config file only | No — needs passphrase + SSH key |\n| SSH key only | No — needs passphrase |\n| Passphrase only | No — needs SSH key |\n| Config file + SSH key + passphrase | Yes — full compromise |\n\n---\n\n## Environment Variables\n\n| Variable | Required | Description |\n|----------|----------|-------------|\n| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation |\n| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `\"\"` to disable auto-detection and use passphrase-only mode |\n\n### SSH Key Auto-Detection\n\nIf `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key:\n\n```\n~/.ssh/picoclaw_ed25519.key\n```\n\nThis dedicated file avoids conflicts with the user's existing SSH keys.\nRun `picoclaw onboard` to generate it automatically.\n\n`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS).\n\nTo explicitly disable SSH key usage and use passphrase-only mode:\n\n```bash\nexport PICOCLAW_SSH_KEY_PATH=\"\"\n```\n\n---\n\n## Migration\n\nBecause the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward:\n\n1. Copy the config file to the new machine.\n2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value.\n3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location).\n\nNo re-encryption is needed.\n\n---\n\n## Security Considerations\n\n- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=\"\"` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters).\n- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file.\n- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected.\n- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values.\n"
  },
  {
    "path": "docs/debug.md",
    "content": "# Debugging PicoClaw\n\nPicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.\n## Starting PicoClaw in Debug Mode\n\nTo get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:\n\n```bash\npicoclaw gateway --debug\n# or\npicoclaw gateway -d\n```\n\nIn this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.\n\n## Disabling Log Truncation (Full Logs)\n\nBy default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.\n\nIf you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.\n\n**Note:** This flag *only* works when combined with the `--debug` mode.\n\n```bash\npicoclaw gateway --debug --no-truncate\n\n```\n\nWhen this flag is active, the global truncation function is disabled. This is extremely useful for:\n\n* Verifying the exact syntax of the messages sent to the provider.\n* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.\n* Debugging the session history saved in memory.\n\n## Tool Call Visibility in Debug Logs\n\nWhen debug mode is active, the agent emits structured log entries at each stage of the tool execution lifecycle. These entries carry a `component=agent` label and use `INFO` or `DEBUG` level depending on the amount of detail:\n\n| Log message | Level | Key fields | Description |\n|---|---|---|---|\n| `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call |\n| `Tool call: <name>(<args>)` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) |\n| `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel |\n| `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round |\n| `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background |\n\n### Reading a tool call log entry\n\nA typical synchronous tool call produces two consecutive lines in the console:\n\n```\n[...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1}\n[...] [INFO] agent: Tool call: web_search({\"query\":\"picoclaw release notes\"}) {tool=web_search, iteration=1}\n```\n\nThe arguments preview is hard-capped at **200 characters** in the logs regardless of the `--no-truncate` flag, because it belongs to the `INFO`-level path. Use `--no-truncate` together with `--debug` to see the full `tools_json` field emitted by the `Full LLM request` DEBUG entry, which contains every tool definition sent to the model.\n\n## Real-Time Tool Feedback in Chat (tool_feedback)\n\nDebug logs are server-side only. If you want the agent to send a visible notification directly into the chat channel every time it executes a tool—useful when sharing the bot with other users or for transparency—enable the `tool_feedback` feature in `config.json`:\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"tool_feedback\": {\n        \"enabled\": true,\n        \"max_args_length\": 300\n      }\n    }\n  }\n}\n```\n\nWhen `enabled` is `true`, every tool call sends a short message to the chat before the tool result is returned to the model. The message looks like:\n\n```bash\n🔧 `web_search`\n{\"query\": \"picoclaw release notes\"}\n```\n\n\n### Options\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `enabled` | bool | `false` | Send a chat notification for each tool call |\n| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |\n\n### Environment variables\n\nBoth fields can also be set via environment variables:\n\n```bash\nPICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true\nPICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300\n```\n\n> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.\n"
  },
  {
    "path": "docs/design/issue-783-investigation-and-fix-plan.zh.md",
    "content": "# Issue #783 调研与修复执行文档\n\n## 1. 问题澄清（已确认）\n\n- 现象：当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名（如 `step-3.5-flash`）时，fallback 链路将别名当作真实 `provider/model` 解析，导致 `provider` 可能为空、`model` 可能错误。\n- 根因：`ResolveCandidates` 仅对字符串做 `ParseModelRef`，未先通过 `model_list` 将别名映射到真实 `model` 字段。\n- 影响：\n  - fallback 执行可能把别名直接发给 OpenAI-compatible provider，触发 `Unknown Model`。\n  - `defaults.provider` 为空时，日志出现 `provider=` 空值。\n\n## 2. 本次目标\n\n- 修复 fallback 候选解析：优先通过 `model_list` 解析别名。\n- 兼容旧行为：若未命中 `model_list`，继续走原有 `ParseModelRef` 兜底。\n- 补充测试：覆盖别名、嵌套路径模型（如 `openrouter/stepfun/...`）、空默认 provider。\n- 验证代码风格：与当前仓库风格保持一致（命名、错误处理、测试结构）。\n\n## 3. 联网最佳实践调研结论（已完成）\n\n- [x] 查阅 OpenAI-compatible 网关（如 OpenRouter）对 `model` 字段的推荐处理。\n- [x] 查阅多 provider/fallback 设计最佳实践（候选解析、日志可观测性）。\n- [x] 将外部建议映射为本仓库可执行约束。\n\n外部参考要点（来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档）：\n\n- 优先显式配置，不依赖字符串切分推断 provider。\n- 对网关模型标识应保留完整路径语义，避免截断导致 Unknown Model。\n- fallback 与 primary 应复用同一解析策略，避免“主路径正确、降级路径错误”。\n\n参考链接：\n\n- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection\n- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks\n- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion\n- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture\n- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/\n\n与本仓库对应的可执行约束：\n\n- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。\n- 未命中映射时保留旧解析行为，保证兼容性。\n- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。\n\n## 4. 实施步骤（顺序执行）\n\n- [x] Step 1: 对齐现有代码模式，定位最小改动点（`pkg/agent` + `pkg/providers`）。\n- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。\n- [x] Step 3: 增加/更新单元测试，覆盖 issue 场景。\n- [x] Step 4: 代码风格一致性复核（与现有文件风格对照）。\n- [x] Step 5: 运行质量门禁（LSP + `make check`）。\n\n## 5. 执行记录\n\n- 状态：已完成\n- 已完成改动：\n  - `pkg/providers/fallback.go`：新增 `ResolveCandidatesWithLookup`，并保持 `ResolveCandidates` 向后兼容。\n  - `pkg/agent/instance.go`：在构建 fallback candidates 前，优先通过 `model_list` 解析别名，并对无协议模型补齐默认 `openai/` 前缀后再解析。\n  - `pkg/providers/fallback_test.go`：新增别名解析与去重测试。\n  - `pkg/agent/instance_test.go`：新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。\n- 风格对齐检查（完成）：与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。\n- 质量验证（完成）：先 `make generate`，后 `make check` 全量通过。\n"
  },
  {
    "path": "docs/design/provider-refactoring-tests.md",
    "content": "# Provider Architecture Refactoring - Test Suite Summary\n\nThis document summarizes the complete test suite designed for the Provider architecture refactoring.\n\n## Test File Structure\n\n```\npkg/\n├── config/\n│   ├── model_config_test.go      # US-001, US-002: ModelConfig struct and GetModelConfig tests\n│   └── migration_test.go         # US-003: Backward compatibility and migration tests\n├── providers/\n│   ├── factory_test.go           # US-004, US-005: Provider factory tests\n│   └── factory_provider_test.go  # Factory provider integration tests\n```\n\n---\n\n## Test Case Checklist\n\n### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests\n\n| Test Name | Purpose | PRD Reference |\n|-----------|---------|---------------|\n| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |\n| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |\n| `TestModelConfig_Validation` | Verify required field validation | US-001 |\n| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |\n| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |\n| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |\n| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |\n| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |\n| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |\n| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |\n\n### 2. `pkg/config/migration_test.go` - Migration Tests\n\n| Test Name | Purpose | PRD Reference |\n|-----------|---------|---------------|\n| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |\n| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |\n| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |\n| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |\n| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |\n| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |\n| `TestGenerateModelName_*` | Model name generation | US-003 |\n| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |\n| `TestValidateMigration_*` | Migration validation | US-003 |\n| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |\n| `TestMigrateConfig_Actual` | Actual migration | US-003 |\n\n### 3. `pkg/providers/registry_test.go` - Load Balancing Tests\n\n| Test Name | Purpose | PRD Reference |\n|-----------|---------|---------------|\n| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |\n| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |\n| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |\n| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |\n| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |\n| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |\n| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |\n| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |\n| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |\n| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |\n\n### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests\n\n| Test Name | Purpose | PRD Reference |\n|-----------|---------|---------------|\n| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |\n| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |\n| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |\n| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |\n| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |\n| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |\n| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |\n| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |\n| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |\n| `TestExtractProtocol` | Protocol prefix extraction | US-004 |\n| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |\n| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |\n| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |\n\n### 5. `pkg/providers/integration_test.go` - E2E Integration Tests\n\n| Test Name | Purpose | PRD Reference |\n|-----------|---------|---------------|\n| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |\n| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |\n| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |\n| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |\n| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |\n| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |\n| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |\n| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |\n| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |\n\n### 6. Performance Tests\n\n| Test Name | Purpose |\n|-----------|---------|\n| `BenchmarkCreateProviderFromConfig` | Provider creation performance |\n| `BenchmarkGetModelConfig` | Model lookup performance |\n| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |\n\n---\n\n## Running Tests\n\n```bash\n# Run all tests\ngo test ./pkg/... -v\n\n# Run with data race detection\ngo test ./pkg/... -race\n\n# Run specific package tests\ngo test ./pkg/config -v\ngo test ./pkg/providers -v\n\n# Run E2E tests\ngo test ./pkg/providers -run TestE2E -v\n\n# Run performance tests\ngo test ./pkg/providers -bench=. -benchmem\n```\n\n---\n\n## PRD Acceptance Criteria Mapping\n\n| PRD Acceptance Criteria | Test Cases |\n|------------------------|------------|\n| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |\n| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |\n| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |\n| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |\n| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |\n| US-003: Existing tests pass | (existing test files unchanged) |\n| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |\n| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |\n| US-005: CreateProvider uses factory | `TestCreateProvider_*` |\n| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |\n| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |\n\n---\n\n## Recommended Implementation Order\n\n1. **Phase 1: Configuration Structure** (US-001, US-002)\n   - Implement `ModelConfig` struct\n   - Implement `GetModelConfig` method\n   - Run `model_config_test.go`\n\n2. **Phase 2: Protocol Factory** (US-004)\n   - Implement `CreateProviderFromConfig`\n   - Implement `ExtractProtocol`\n   - Run `factory_test.go`\n\n3. **Phase 3: Load Balancing** (US-006)\n   - Implement `ModelRegistry`\n   - Implement round-robin selection\n   - Run `registry_test.go` (with `-race`)\n\n4. **Phase 4: Backward Compatibility** (US-003, US-005)\n   - Implement `ConvertProvidersToModelList`\n   - Refactor `CreateProvider`\n   - Run `migration_test.go`\n   - Verify existing tests pass\n\n5. **Phase 5: E2E Verification**\n   - Run `integration_test.go`\n   - Manual testing with `config.example.json`\n"
  },
  {
    "path": "docs/design/provider-refactoring.md",
    "content": "# Provider Architecture Refactoring Design\n\n> Issue: #283\n> Discussion: #122\n> Branch: feat/refactor-provider-by-protocol\n\n## 1. Current Problems\n\n### 1.1 Configuration Structure Issues\n\n**Current State**: Each Provider requires a predefined field in `ProvidersConfig`\n\n```go\ntype ProvidersConfig struct {\n    Anthropic     ProviderConfig `json:\"anthropic\"`\n    OpenAI        ProviderConfig `json:\"openai\"`\n    DeepSeek      ProviderConfig `json:\"deepseek\"`\n    Qwen          ProviderConfig `json:\"qwen\"`\n    Cerebras      ProviderConfig `json:\"cerebras\"`\n    VolcEngine    ProviderConfig `json:\"volcengine\"`\n    // ... every new provider requires changes here\n}\n```\n\n**Problems**:\n- Adding a new Provider requires modifying Go code (struct definition)\n- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case\n- Most Providers are OpenAI-compatible, but code is duplicated\n\n### 1.2 Code Bloat Trend\n\nRecent PRs demonstrate this issue:\n\n| PR | Provider | Code Changes |\n|----|----------|--------------|\n| #365 | Qwen | +17 lines to http_provider.go |\n| #333 | Cerebras | +17 lines to http_provider.go |\n| #368 | Volcengine | +18 lines to http_provider.go |\n\nEach OpenAI-compatible Provider requires:\n1. Modify `config.go` to add configuration field\n2. Modify `http_provider.go` to add switch case\n3. Update documentation\n\n### 1.3 Agent-Provider Coupling\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"deepseek\",  // need to know provider name\n      \"model\": \"deepseek-chat\"\n    }\n  }\n}\n```\n\nProblem: Agent needs to know both `provider` and `model`, adding complexity.\n\n---\n\n## 2. New Approach: model_list\n\n### 2.1 Core Principles\n\nInspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:\n\n1. **Model-centric**: Users care about models, not providers\n2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`\n3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes\n\n### 2.2 New Configuration Structure\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"deepseek-chat\",\n      \"model\": \"openai/deepseek-chat\",\n      \"api_base\": \"https://api.deepseek.com/v1\",\n      \"api_key\": \"sk-xxx\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-xxx\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-xxx\"\n    },\n    {\n      \"model_name\": \"gemini-3-flash\",\n      \"model\": \"antigravity/gemini-3-flash\",\n      \"auth_method\": \"oauth\"\n    },\n    {\n      \"model_name\": \"my-company-llm\",\n      \"model\": \"openai/company-model-v1\",\n      \"api_base\": \"https://llm.company.com/v1\",\n      \"api_key\": \"xxx\"\n    }\n  ],\n\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"deepseek-chat\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7\n    }\n  }\n}\n```\n\n### 2.3 Go Struct Definition\n\n```go\ntype Config struct {\n    ModelList []ModelConfig `json:\"model_list\"`  // new\n    Providers ProvidersConfig `json:\"providers\"`  // old, deprecated\n\n    Agents   AgentsConfig   `json:\"agents\"`\n    Channels ChannelsConfig `json:\"channels\"`\n    // ...\n}\n\ntype ModelConfig struct {\n    // Required\n    ModelName string `json:\"model_name\"`  // user-facing name (alias)\n    Model     string `json:\"model\"`       // protocol/model, e.g., openai/gpt-5.4\n\n    // Common config\n    APIBase   string `json:\"api_base,omitempty\"`\n    APIKey    string `json:\"api_key,omitempty\"`\n    Proxy     string `json:\"proxy,omitempty\"`\n\n    // Special provider config\n    AuthMethod  string `json:\"auth_method,omitempty\"`   // oauth, token\n    ConnectMode string `json:\"connect_mode,omitempty\"`  // stdio, grpc\n\n    // Optional optimizations\n    RPM            int    `json:\"rpm,omitempty\"`              // rate limit\n    MaxTokensField string `json:\"max_tokens_field,omitempty\"` // max_tokens or max_completion_tokens\n}\n```\n\n### 2.4 Protocol Recognition\n\nIdentify protocol via prefix in `model` field:\n\n| Prefix | Protocol | Description |\n|--------|----------|-------------|\n| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |\n| `anthropic/` | Anthropic | Claude series specific |\n| `antigravity/` | Antigravity | Google Cloud Code Assist |\n| `gemini/` | Gemini | Google Gemini native API (if needed) |\n\n---\n\n## 3. Design Rationale\n\n### 3.1 Problems Solved\n\n| Problem | Old Approach | New Approach |\n|---------|--------------|--------------|\n| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |\n| Agent specifies model | Need provider + model | Only need model |\n| Code duplication | Each Provider duplicates logic | Share protocol implementation |\n| Multi-Agent support | Complex | Naturally compatible |\n\n### 3.2 Multi-Agent Compatibility\n\n```json\n{\n  \"model_list\": [...],\n\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"deepseek-chat\"\n    },\n    \"coder\": {\n      \"model\": \"gpt-5.4\",\n      \"system_prompt\": \"You are a coding assistant...\"\n    },\n    \"translator\": {\n      \"model\": \"claude-sonnet-4.6\"\n    }\n  }\n}\n```\n\nEach Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).\n\n### 3.3 Industry Comparison\n\n**LiteLLM** (most mature open-source LLM Proxy) uses similar design:\n\n```yaml\nmodel_list:\n  - model_name: gpt-4o\n    litellm_params:\n      model: openai/gpt-5.4\n      api_key: xxx\n  - model_name: my-custom\n    litellm_params:\n      model: openai/custom-model\n      api_base: https://my-api.com/v1\n```\n\n---\n\n## 4. Migration Plan\n\n### 4.1 Phase 1: Compatibility Period (v1.x)\n\nSupport both `providers` and `model_list`:\n\n```go\nfunc (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {\n    // Prefer new config\n    if len(c.ModelList) > 0 {\n        return c.findModelByName(modelName)\n    }\n\n    // Backward compatibility with old config\n    if !c.Providers.IsEmpty() {\n        logger.Warn(\"'providers' config is deprecated, please migrate to 'model_list'\")\n        return c.convertFromProviders(modelName)\n    }\n\n    return nil, fmt.Errorf(\"model %s not found\", modelName)\n}\n```\n\n### 4.2 Phase 2: Warning Period (late v1.x)\n\n- Print more prominent warnings at startup\n- Provide automatic migration script\n- Mark `providers` as deprecated in documentation\n\n### 4.3 Phase 3: Removal Period (v2.0)\n\n- Completely remove `providers` support\n- Remove `agents.defaults.provider` field\n- Only support `model_list`\n\n### 4.4 Configuration Migration Example\n\n**Old Config**:\n```json\n{\n  \"providers\": {\n    \"deepseek\": {\n      \"api_key\": \"sk-xxx\",\n      \"api_base\": \"https://api.deepseek.com/v1\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"deepseek\",\n      \"model\": \"deepseek-chat\"\n    }\n  }\n}\n```\n\n**New Config**:\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"deepseek-chat\",\n      \"model\": \"openai/deepseek-chat\",\n      \"api_base\": \"https://api.deepseek.com/v1\",\n      \"api_key\": \"sk-xxx\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"deepseek-chat\"\n    }\n  }\n}\n```\n\n---\n\n## 5. Implementation Checklist\n\n### 5.1 Configuration Layer\n\n- [ ] Add `ModelConfig` struct\n- [ ] Add `Config.ModelList` field\n- [ ] Implement `GetModelConfig(modelName)` method\n- [ ] Implement old config compatibility conversion\n- [ ] Add `model_name` uniqueness validation\n\n### 5.2 Provider Layer\n\n- [ ] Create `pkg/providers/factory/` directory\n- [ ] Implement `CreateProviderFromModelConfig()`\n- [ ] Refactor `http_provider.go` to `openai/provider.go`\n- [ ] Maintain backward compatibility for old `CreateProvider()`\n\n### 5.3 Testing\n\n- [ ] New config unit tests\n- [ ] Old config compatibility tests\n- [ ] Integration tests\n\n### 5.4 Documentation\n\n- [ ] Update README\n- [ ] Update config.example.json\n- [ ] Write migration guide\n\n---\n\n## 6. Risks and Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| Breaking existing configs | Compatibility period keeps old config working |\n| User migration cost | Provide automatic migration script |\n| Special Provider incompatibility | Keep `auth_method` and other extension fields |\n\n---\n\n## 7. References\n\n- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)\n- [One-API GitHub](https://github.com/songquanpeng/one-api)\n- Discussion #122: Refactor Provider Architecture\n"
  },
  {
    "path": "docs/docker.md",
    "content": "# 🐳 Docker & Quick Start Guide\n\n> Back to [README](../README.md)\n\n## 🐳 Docker Compose\n\nYou can also run PicoClaw using Docker Compose without installing anything locally.\n\n```bash\n# 1. Clone this repo\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. First run — auto-generates docker/data/config.json then exits\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# The container prints \"First-run setup complete.\" and stops.\n\n# 3. Set your API keys\nvim docker/data/config.json   # Set provider API keys, bot tokens, etc.\n\n# 4. Start\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.\n\n```bash\n# 5. Check logs\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. Stop\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Launcher Mode (Web Console)\n\nThe `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nOpen http://localhost:18800 in your browser. The launcher manages the gateway process automatically.\n\n> [!WARNING]\n> The web console does not yet support authentication. Avoid exposing it to the public internet.\n\n### Agent Mode (One-shot)\n\n```bash\n# Ask a question\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"What is 2+2?\"\n\n# Interactive mode\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### Update\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n### 🚀 Quick Start\n\n> [!TIP]\n> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).\n\n**1. Initialize**\n\n```bash\npicoclaw onboard\n```\n\n**2. Configure** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.\n> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).\n\n**3. Get API Keys**\n\n* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Web Search** (optional):\n  * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)\n  * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface\n  * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)\n  * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)\n  * DuckDuckGo - Built-in fallback (no API key required)\n\n> **Note**: See `config.example.json` for a complete configuration template.\n\n**4. Chat**\n\n```bash\npicoclaw agent -m \"What is 2+2?\"\n```\n\nThat's it! You have a working AI assistant in 2 minutes.\n\n---\n"
  },
  {
    "path": "docs/fr/chat-apps.md",
    "content": "# 💬 Configuration des Applications de Chat\n\n> Retour au [README](../../README.fr.md)\n\n## 💬 Applications de Chat\n\nCommuniquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam.\n\n> **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé.\n\n| Canal        | Configuration                          |\n| ------------ | -------------------------------------- |\n| **Telegram** | Facile (juste un token)                |\n| **Discord**  | Facile (bot token + intents)           |\n| **WhatsApp** | Facile (natif : scan QR ; ou bridge URL) |\n| **Matrix**   | Moyen (homeserver + bot access token)  |\n| **QQ**       | Facile (AppID + AppSecret)             |\n| **DingTalk** | Moyen (identifiants de l'application)  |\n| **LINE**     | Moyen (identifiants + webhook URL)     |\n| **WeCom AI Bot** | Moyen (Token + clé AES)           |\n| **Feishu**   | Moyen (App ID + Secret, mode WebSocket) |\n| **Slack**    | Moyen (Bot token + App token)          |\n| **IRC**      | Moyen (serveur + configuration TLS)    |\n| **OneBot**   | Moyen (QQ via protocole OneBot)        |\n| **MaixCam**  | Facile (intégration matérielle Sipeed) |\n| **Pico**     | Native PicoClaw protocol           |\n\n<details>\n<summary><b>Telegram</b> (Recommandé)</summary>\n\n**1. Créer un bot**\n\n* Ouvrez Telegram, recherchez `@BotFather`\n* Envoyez `/newbot`, suivez les instructions\n* Copiez le token\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> Obtenez votre identifiant utilisateur via `@userinfobot` sur Telegram.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)**\n\nPicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés.\nL'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes.\n\nSi l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan.\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Créer un bot**\n\n* Allez sur <https://discord.com/developers/applications>\n* Créez une application → Bot → Add Bot\n* Copiez le token du bot\n\n**2. Activer les intents**\n\n* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT**\n* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous prévoyez d'utiliser des listes d'autorisation basées sur les données des membres\n\n**3. Obtenir votre identifiant utilisateur**\n* Paramètres Discord → Avancé → activez **Developer Mode**\n* Clic droit sur votre avatar → **Copy User ID**\n\n**4. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Inviter le bot**\n\n* OAuth2 → URL Generator\n* Scopes : `bot`\n* Bot Permissions : `Send Messages`, `Read Message History`\n* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur\n\n**Mode déclenchement en groupe (optionnel)**\n\nPar défaut, le bot répond à tous les messages dans un canal de serveur. Pour limiter les réponses aux @mentions uniquement, ajoutez :\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nVous pouvez également déclencher par préfixes de mots-clés (par ex. `!bot`) :\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b> (natif via whatsmeow)</summary>\n\nPicoClaw peut se connecter à WhatsApp de deux manières :\n\n- **Natif (recommandé) :** En processus via [whatsmeow](https://github.com/tulir/whatsmeow). Pas de bridge séparé. Définissez `\"use_native\": true` et laissez `bridge_url` vide. Au premier lancement, scannez le code QR avec WhatsApp (Appareils liés). La session est stockée dans votre workspace (par ex. `workspace/whatsapp/`). Le canal natif est **optionnel** pour garder le binaire par défaut léger ; compilez avec `-tags whatsapp_native` (par ex. `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`).\n- **Bridge :** Connectez-vous à un bridge WebSocket externe. Définissez `bridge_url` (par ex. `ws://localhost:3001`) et gardez `use_native` à false.\n\n**Configurer (natif)**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\nSi `session_store_path` est vide, la session est stockée dans `<workspace>/whatsapp/`. Lancez `picoclaw gateway` ; au premier lancement, scannez le code QR affiché dans le terminal avec WhatsApp → Appareils liés.\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Créer un bot**\n\n- Allez sur [QQ Open Platform](https://q.qq.com/#)\n- Créez une application → Obtenez **AppID** et **AppSecret**\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Créer un bot**\n\n* Allez sur [Open Platform](https://open.dingtalk.com/)\n* Créez une application interne\n* Copiez le Client ID et le Client Secret\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants DingTalk pour restreindre l'accès.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Préparer le compte bot**\n\n* Utilisez votre homeserver préféré (par ex. `https://matrix.org` ou auto-hébergé)\n* Créez un utilisateur bot et obtenez son access token\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\nPour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](docs/channels/matrix/README.md).\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. Créer un compte officiel LINE**\n\n- Allez sur [LINE Developers Console](https://developers.line.biz/)\n- Créez un provider → Créez un canal Messaging API\n- Copiez le **Channel Secret** et le **Channel Access Token**\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Le webhook LINE est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`).\n\n**3. Configurer l'URL du Webhook**\n\nLINE nécessite HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :\n\n```bash\n# Exemple avec ngrok (le port par défaut du gateway est 18790)\nngrok http 18790\n```\n\nPuis définissez l'URL du Webhook dans la console LINE Developers à `https://your-domain/webhook/line` et activez **Use webhook**.\n\n**4. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n> Dans les discussions de groupe, le bot ne répond que lorsqu'il est @mentionné. Les réponses citent le message original.\n\n</details>\n\n<details>\n<summary><b>WeCom (企业微信)</b></summary>\n\nPicoClaw prend en charge trois types d'intégration WeCom :\n\n**Option 1 : WeCom Bot (Bot)** - Configuration plus facile, prend en charge les discussions de groupe\n**Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement\n**Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées\n\nVoir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour les instructions détaillées.\n\n**Configuration rapide - WeCom Bot :**\n\n**1. Créer un bot**\n\n* Allez dans la console d'administration WeCom → Discussion de groupe → Ajouter un bot de groupe\n* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Le webhook WeCom est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`).\n\n**Configuration rapide - WeCom App :**\n\n**1. Créer une application**\n\n* Allez dans la console d'administration WeCom → Gestion des applications → Créer une application\n* Copiez **AgentId** et **Secret**\n* Allez sur la page \"Mon entreprise\", copiez **CorpID**\n\n**2. Configurer la réception des messages**\n\n* Dans les détails de l'application, cliquez sur \"Recevoir les messages\" → \"Configurer l'API\"\n* Définissez l'URL à `http://your-server:18790/webhook/wecom-app`\n* Générez **Token** et **EncodingAESKey**\n\n**3. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n> **Note** : Les callbacks webhook WeCom sont servis sur le port Gateway (par défaut 18790). Utilisez un reverse proxy pour HTTPS.\n\n**Configuration rapide - WeCom AI Bot :**\n\n**1. Créer un AI Bot**\n\n* Allez dans la console d'administration WeCom → Gestion des applications → AI Bot\n* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot`\n* Copiez **Token** et cliquez sur \"Générer aléatoirement\" pour **EncodingAESKey**\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"Hello! How can I help you?\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n    }\n  }\n}\n```\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n> **Note** : WeCom AI Bot utilise le protocole streaming pull — pas de problème de timeout de réponse. Les tâches longues (>30 secondes) basculent automatiquement vers la livraison push via `response_url`.\n\n</details>\n\n<details>\n<summary><b>Feishu (飞书)</b></summary>\n\n**1. Créer une application**\n\n* Allez sur [Feishu Open Platform](https://open.feishu.cn/)\n* Créez une application → Obtenez **App ID** et **App Secret**\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"feishu\": {\n      \"enabled\": true,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Feishu utilise le mode WebSocket/SDK et ne nécessite pas de serveur webhook.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>Slack</b></summary>\n\n**1. Créer une application Slack**\n\n* Allez sur [Slack API](https://api.slack.com/apps)\n* Créez une nouvelle application\n* Obtenez le **Bot Token** et l'**App Token**\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"slack\": {\n      \"enabled\": true,\n      \"bot_token\": \"xoxb-your-bot-token\",\n      \"app_token\": \"xapp-your-app-token\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>IRC</b></summary>\n\n**1. Configurer le serveur IRC**\n\n* Préparez les informations de votre serveur IRC (adresse, port, canal)\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"irc\": {\n      \"enabled\": true,\n      \"server\": \"irc.example.com:6697\",\n      \"nick\": \"picoclaw-bot\",\n      \"channel\": \"#your-channel\",\n      \"use_tls\": true,\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>OneBot</b></summary>\n\n**1. Configurer OneBot**\n\n* Installez une implémentation OneBot compatible (par ex. go-cqhttp, Lagrange)\n* Configurez la connexion WebSocket\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"onebot\": {\n      \"enabled\": true,\n      \"ws_url\": \"ws://localhost:8080\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> OneBot permet d'utiliser QQ via le protocole OneBot standard.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>MaixCam</b></summary>\n\n**1. Préparer le matériel**\n\n* Obtenez un appareil [Sipeed MaixCam](https://wiki.sipeed.com/maixcam)\n\n**2. Configurer**\n\n```json\n{\n  \"channels\": {\n    \"maixcam\": {\n      \"enabled\": true,\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> MaixCam est une intégration matérielle Sipeed pour l'interaction IA embarquée.\n\n**3. Lancer**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n"
  },
  {
    "path": "docs/fr/configuration.md",
    "content": "# ⚙️ Guide de Configuration\n\n> Retour au [README](../../README.fr.md)\n\n## ⚙️ Configuration\n\nFichier de configuration : `~/.picoclaw/config.json`\n\n### Variables d'Environnement\n\nVous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de PicoClaw en tant que service système. Ces variables sont indépendantes et contrôlent des chemins différents.\n\n| Variable          | Description                                                                                                                             | Chemin par défaut         |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | Remplace le chemin vers le fichier de configuration. Indique directement à PicoClaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | Remplace le répertoire racine des données PicoClaw. Change l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw`             |\n\n**Exemples :**\n\n```bash\n# Run picoclaw using a specific config file\n# The workspace path will be read from within that config file\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# Run picoclaw with all its data stored in /opt/picoclaw\n# Config will be loaded from the default ~/.picoclaw/config.json\n# Workspace will be created at /opt/picoclaw/workspace\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# Use both for a fully customized setup\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### Structure du Workspace\n\nPicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # Sessions de conversation et historique\n├── memory/           # Mémoire à long terme (MEMORY.md)\n├── state/            # État persistant (dernier canal, etc.)\n├── cron/             # Base de données des tâches planifiées\n├── skills/           # Compétences personnalisées\n├── AGENT.md          # Guide de comportement de l'agent\n├── HEARTBEAT.md      # Invites de tâches périodiques (vérifiées toutes les 30 min)\n├── SOUL.md           # Âme de l'agent\n└── USER.md           # Préférences utilisateur\n```\n\n> **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête.\n\n### Sources de Compétences\n\nPar défaut, les compétences sont chargées depuis :\n\n1. `~/.picoclaw/workspace/skills` (workspace)\n2. `~/.picoclaw/skills` (global)\n3. `<current-working-directory>/skills` (builtin)\n\nPour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec :\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### Politique Unifiée d'Exécution des Commandes\n\n- Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`.\n- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement les commandes prises en charge au démarrage.\n- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal.\n- Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur.\n\n### 🔒 Sandbox de Sécurité\n\nPicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes que dans le workspace configuré.\n\n#### Configuration par Défaut\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| Option                  | Par défaut              | Description                                       |\n| ----------------------- | ----------------------- | ------------------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Répertoire de travail de l'agent                  |\n| `restrict_to_workspace` | `true`                  | Restreindre l'accès fichiers/commandes au workspace |\n\n#### Outils Protégés\n\nLorsque `restrict_to_workspace: true`, les outils suivants sont sandboxés :\n\n| Outil         | Fonction              | Restriction                                    |\n| ------------- | --------------------- | ---------------------------------------------- |\n| `read_file`   | Lire des fichiers     | Uniquement les fichiers dans le workspace      |\n| `write_file`  | Écrire des fichiers   | Uniquement les fichiers dans le workspace      |\n| `list_dir`    | Lister les répertoires| Uniquement les répertoires dans le workspace   |\n| `edit_file`   | Modifier des fichiers | Uniquement les fichiers dans le workspace      |\n| `append_file` | Ajouter aux fichiers  | Uniquement les fichiers dans le workspace      |\n| `exec`        | Exécuter des commandes| Les chemins de commande doivent être dans le workspace |\n\n#### Protection Exec Supplémentaire\n\nMême avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses :\n\n* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse\n* `format`, `mkfs`, `diskpart` — Formatage de disque\n* `dd if=` — Imagerie de disque\n* Écriture vers `/dev/sd[a-z]` — Écritures directes sur disque\n* `shutdown`, `reboot`, `poweroff` — Arrêt du système\n* Fork bomb `:(){ :|:& };:`\n\n### Contrôle d'Accès aux Fichiers\n\n| Clé de configuration | Type | Par défaut | Description |\n|----------------------|------|------------|-------------|\n| `tools.allow_read_paths` | string[] | `[]` | Chemins supplémentaires autorisés en lecture en dehors du workspace |\n| `tools.allow_write_paths` | string[] | `[]` | Chemins supplémentaires autorisés en écriture en dehors du workspace |\n\n### Sécurité Exec\n\n| Clé de configuration | Type | Par défaut | Description |\n|----------------------|------|------------|-------------|\n| `tools.exec.allow_remote` | bool | `false` | Autoriser l'outil exec depuis les canaux distants (Telegram/Discord etc.) |\n| `tools.exec.enable_deny_patterns` | bool | `true` | Activer l'interception des commandes dangereuses |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | Patterns regex personnalisés à bloquer |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | Patterns regex personnalisés à autoriser |\n\n> **Note de sécurité :** La protection Symlink est activée par défaut — tous les chemins de fichiers sont résolus via `filepath.EvalSymlinks` avant la correspondance avec la liste blanche, empêchant les attaques d'évasion par symlink.\n\n#### Limitation Connue : Processus Enfants des Outils de Build\n\nLe garde de sécurité exec n'inspecte que la ligne de commande lancée directement par PicoClaw. Il n'inspecte pas récursivement les processus enfants générés par les outils de développement autorisés tels que `make`, `go run`, `cargo`, `npm run` ou les scripts de build personnalisés.\n\nCela signifie qu'une commande de niveau supérieur peut toujours compiler ou lancer d'autres binaires après avoir passé la vérification initiale du garde. En pratique, traitez les scripts de build, les Makefiles, les scripts de packages et les binaires générés comme du code exécutable nécessitant le même niveau de revue qu'une commande shell directe.\n\nPour les environnements à haut risque :\n\n* Examinez les scripts de build avant l'exécution.\n* Préférez l'approbation/revue manuelle pour les workflows de compilation et d'exécution.\n* Exécutez PicoClaw dans un conteneur ou une VM si vous avez besoin d'une isolation plus forte que celle fournie par le garde intégré.\n\n#### Exemples d'Erreurs\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### Désactiver les Restrictions (Risque de Sécurité)\n\nSi vous avez besoin que l'agent accède à des chemins en dehors du workspace :\n\n**Méthode 1 : Fichier de configuration**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**Méthode 2 : Variable d'environnement**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **Avertissement** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution dans des environnements contrôlés uniquement.\n\n#### Cohérence des Limites de Sécurité\n\nLe paramètre `restrict_to_workspace` s'applique de manière cohérente à tous les chemins d'exécution :\n\n| Chemin d'exécution | Limite de sécurité               |\n| ------------------ | -------------------------------- |\n| Main Agent         | `restrict_to_workspace` ✅       |\n| Subagent / Spawn   | Hérite de la même restriction ✅ |\n| Heartbeat tasks    | Hérite de la même restriction ✅ |\n\nTous les chemins partagent la même restriction de workspace — il n'y a aucun moyen de contourner la limite de sécurité via les subagents ou les tâches planifiées.\n\n### Heartbeat (Tâches Périodiques)\n\nPicoClaw peut effectuer des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace :\n\n```markdown\n# Periodic Tasks\n\n- Check my email for important messages\n- Review my calendar for upcoming events\n- Check the weather forecast\n```\n\nL'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toutes les tâches en utilisant les outils disponibles.\n\n#### Tâches Asynchrones avec Spawn\n\nPour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** :\n\n```markdown\n# Periodic Tasks\n```\n"
  },
  {
    "path": "docs/fr/docker.md",
    "content": "# 🐳 Docker et Démarrage Rapide\n\n> Retour au [README](../../README.fr.md)\n\n## 🐳 Docker Compose\n\nVous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement.\n\n```bash\n# 1. Cloner ce dépôt\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# Le conteneur affiche \"First-run setup complete.\" et s'arrête.\n\n# 3. Configurer vos clés API\nvim docker/data/config.json   # Set provider API keys, bot tokens, etc.\n\n# 4. Démarrer\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous devez accéder aux endpoints de santé ou exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.\n\n```bash\n# 5. Vérifier les logs\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. Arrêter\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Mode Launcher (Console Web)\n\nL'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nOuvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway.\n\n> [!WARNING]\n> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public.\n\n### Mode Agent (One-shot)\n\n```bash\n# Poser une question\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"What is 2+2?\"\n\n# Mode interactif\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### Mise à jour\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n### 🚀 Démarrage Rapide\n\n> [!TIP]\n> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenir des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement une [API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou une [API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).\n\n**1. Initialiser**\n\n```bash\npicoclaw onboard\n```\n\n**2. Configurer** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **Nouveau** : Le format de configuration `model_list` permet l'ajout de fournisseurs sans modification de code. Voir [Configuration des Modèles](#configuration-des-modèles-model_list) pour plus de détails.\n> `request_timeout` est optionnel et utilise les secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le timeout par défaut (120s).\n\n**3. Obtenir des clés API**\n\n* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Recherche Web** (optionnel) :\n  * [Brave Search](https://brave.com/search/api) - Payant ($5/1000 requêtes, ~$5-6/mois)\n  * [Perplexity](https://www.perplexity.ai) - Recherche alimentée par l'IA avec interface de chat\n  * [SearXNG](https://github.com/searxng/searxng) - Métamoteur auto-hébergé (gratuit, pas de clé API nécessaire)\n  * [Tavily](https://tavily.com) - Optimisé pour les agents IA (1000 requêtes/mois)\n  * DuckDuckGo - Solution de repli intégrée (pas de clé API requise)\n\n> **Note** : Voir `config.example.json` pour un modèle de configuration complet.\n\n**4. Discuter**\n\n```bash\npicoclaw agent -m \"What is 2+2?\"\n```\n\nC'est tout ! Vous avez un assistant IA fonctionnel en 2 minutes.\n\n---\n"
  },
  {
    "path": "docs/fr/providers.md",
    "content": "# 🔌 Fournisseurs et Configuration des Modèles\n\n> Retour au [README](../../README.fr.md)\n\n### Fournisseurs\n\n> [!NOTE]\n> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.\n\n| Provider     | Purpose                                 | Get API Key                                                  |\n| ------------ | --------------------------------------- | ------------------------------------------------------------ |\n| `gemini`     | LLM (Gemini direct)                     | [aistudio.google.com](https://aistudio.google.com)           |\n| `zhipu`      | LLM (Zhipu direct)                      | [bigmodel.cn](https://bigmodel.cn)                           |\n| `volcengine` | LLM (Volcengine direct)                 | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                 |\n| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai)                       |\n| `anthropic`  | LLM (Claude direct)                     | [console.anthropic.com](https://console.anthropic.com)       |\n| `openai`     | LLM (GPT direct)                        | [platform.openai.com](https://platform.openai.com)           |\n| `deepseek`   | LLM (DeepSeek direct)                   | [platform.deepseek.com](https://platform.deepseek.com)       |\n| `qwen`       | LLM (Qwen direct)                       | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`       | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com)                 |\n| `cerebras`   | LLM (Cerebras direct)                   | [cerebras.ai](https://cerebras.ai)                           |\n| `vivgrid`    | LLM (Vivgrid direct)                    | [vivgrid.com](https://vivgrid.com)                           |\n| `moonshot`   | LLM (Kimi/Moonshot direct)              | [platform.moonshot.cn](https://platform.moonshot.cn)         |\n| `minimax`    | LLM (Minimax direct)                    | [platform.minimaxi.com](https://platform.minimaxi.com)      |\n| `avian`      | LLM (Avian direct)                      | [avian.io](https://avian.io)                                 |\n| `mistral`    | LLM (Mistral direct)                    | [console.mistral.ai](https://console.mistral.ai)            |\n| `longcat`    | LLM (Longcat direct)                    | [longcat.ai](https://longcat.ai)                             |\n| `modelscope` | LLM (ModelScope direct)                 | [modelscope.cn](https://modelscope.cn)                       |\n\n### Configuration des Modèles (model_list)\n\n> **Nouveauté** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (par ex. `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs — **aucune modification de code requise !**\n\nCette conception permet également le **support multi-agents** avec une sélection flexible de fournisseurs :\n\n- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM\n- **Modèles de repli** : Configurez des modèles principaux et de repli pour la résilience\n- **Répartition de charge** : Distribuez les requêtes entre plusieurs endpoints\n- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit\n\n#### 📋 Tous les Vendors Supportés\n\n| Vendor              | `model` Prefix    | Default API Base                                    | Protocol  | API Key                                                          |\n| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |\n| **OpenAI**          | `openai/`         | `https://api.openai.com/v1`                         | OpenAI    | [Get Key](https://platform.openai.com)                           |\n| **Anthropic**       | `anthropic/`      | `https://api.anthropic.com/v1`                      | Anthropic | [Get Key](https://console.anthropic.com)                         |\n| **智谱 AI (GLM)**   | `zhipu/`          | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI    | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`       | `https://api.deepseek.com/v1`                       | OpenAI    | [Get Key](https://platform.deepseek.com)                         |\n| **Google Gemini**   | `gemini/`         | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI    | [Get Key](https://aistudio.google.com/api-keys)                  |\n| **Groq**            | `groq/`           | `https://api.groq.com/openai/v1`                    | OpenAI    | [Get Key](https://console.groq.com)                              |\n| **Moonshot**        | `moonshot/`       | `https://api.moonshot.cn/v1`                        | OpenAI    | [Get Key](https://platform.moonshot.cn)                          |\n| **通义千问 (Qwen)** | `qwen/`           | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI    | [Get Key](https://dashscope.console.aliyun.com)                  |\n| **NVIDIA**          | `nvidia/`         | `https://integrate.api.nvidia.com/v1`               | OpenAI    | [Get Key](https://build.nvidia.com)                              |\n| **Ollama**          | `ollama/`         | `http://localhost:11434/v1`                         | OpenAI    | Local (no key needed)                                            |\n| **OpenRouter**      | `openrouter/`     | `https://openrouter.ai/api/v1`                      | OpenAI    | [Get Key](https://openrouter.ai/keys)                            |\n| **LiteLLM Proxy**   | `litellm/`        | `http://localhost:4000/v1`                          | OpenAI    | Your LiteLLM proxy key                                            |\n| **VLLM**            | `vllm/`           | `http://localhost:8000/v1`                          | OpenAI    | Local                                                            |\n| **Cerebras**        | `cerebras/`       | `https://api.cerebras.ai/v1`                        | OpenAI    | [Get Key](https://cerebras.ai)                                   |\n| **VolcEngine (Doubao)** | `volcengine/`     | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI    | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                        |\n| **神算云**          | `shengsuanyun/`   | `https://router.shengsuanyun.com/api/v1`            | OpenAI    | -                                                                |\n| **BytePlus**        | `byteplus/`       | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI    | [Get Key](https://www.byteplus.com)                        |\n| **Vivgrid**         | `vivgrid/`        | `https://api.vivgrid.com/v1`                        | OpenAI    | [Get Key](https://vivgrid.com)                                   |\n| **LongCat**         | `longcat/`        | `https://api.longcat.chat/openai`                   | OpenAI    | [Get Key](https://longcat.chat/platform)                         |\n| **ModelScope (魔搭)**| `modelscope/`    | `https://api-inference.modelscope.cn/v1`            | OpenAI    | [Get Token](https://modelscope.cn/my/tokens)                     |\n| **Antigravity**     | `antigravity/`    | Google Cloud                                        | Custom    | OAuth only                                                       |\n| **GitHub Copilot**  | `github-copilot/` | `localhost:4321`                                    | gRPC      | -                                                                |\n\n#### Configuration de Base\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### Exemples par Vendor\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**VolcEngine (Doubao)**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (avec clé API)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"api_key\": \"sk-ant-your-key\"\n}\n```\n\n> Exécutez `picoclaw auth login --provider anthropic` pour coller votre token API.\n\n**API Anthropic Messages (format natif)**\n\nPour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne prennent en charge que le format de message natif d'Anthropic :\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> Utilisez le protocole `anthropic-messages` lorsque :\n> - Vous utilisez des proxys tiers qui ne prennent en charge que l'endpoint natif `/v1/messages` d'Anthropic (pas le format compatible OpenAI `/v1/chat/completions`)\n> - Vous vous connectez à des services comme MiniMax, Synthetic qui nécessitent le format de message natif d'Anthropic\n> - Le protocole `anthropic` existant renvoie des erreurs 404 (indiquant que l'endpoint ne prend pas en charge le format compatible OpenAI)\n>\n> **Note :** Le protocole `anthropic` utilise le format compatible OpenAI (`/v1/chat/completions`), tandis que `anthropic-messages` utilise le format natif d'Anthropic (`/v1/messages`). Choisissez en fonction du format pris en charge par votre endpoint.\n\n**Ollama (local)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**Proxy/API Personnalisé**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nPicoClaw ne supprime que le préfixe externe `litellm/` avant d'envoyer la requête, donc les alias de proxy comme `litellm/lite-gpt4` envoient `lite-gpt4`, tandis que `litellm/openai/gpt-4o` envoie `openai/gpt-4o`.\n\n#### Répartition de Charge\n\nConfigurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin entre eux :\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### Migration depuis l'Ancienne Configuration `providers`\n\nL'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante.\n\n**Ancienne configuration (dépréciée) :**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**Nouvelle configuration (recommandée) :**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\nPour un guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).\n\n### Architecture des Fournisseurs\n\nPicoClaw route les fournisseurs par famille de protocoles :\n\n- Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM.\n- Protocole Anthropic : Comportement natif de l'API Claude.\n- Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI.\n\nCela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`).\n\n<details>\n<summary><b>Zhipu</b></summary>\n\n**1. Obtenir la clé API et l'URL de base**\n\n* Obtenir la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)\n\n**2. Configurer**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. Lancer**\n\n```bash\npicoclaw agent -m \"Hello\"\n```\n\n</details>\n\n<details>\n<summary><b>Exemple de configuration complète</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 Comparaison des Clés API\n\n| Service          | Pricing                  | Use Case                              |\n| ---------------- | ------------------------ | ------------------------------------- |\n| **OpenRouter**   | Free: 200K tokens/month  | Multiple models (Claude, GPT-4, etc.) |\n| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |\n| **Zhipu**        | Free: 200K tokens/month  | Suitable for Chinese users                |\n| **Brave Search** | $5/1000 queries          | Web search functionality              |\n| **SearXNG**      | Free (self-hosted)       | Privacy-focused metasearch (70+ engines) |\n| **Groq**         | Free tier available      | Fast inference (Llama, Mixtral)       |\n| **Cerebras**     | Free tier available      | Fast inference (Llama, Qwen, etc.)    |\n| **LongCat**      | Free: up to 5M tokens/day | Fast inference                       |\n| **ModelScope**   | Free: 2000 requests/day  | Inference (Qwen, GLM, DeepSeek, etc.) |\n\n---\n\n<div align=\"center\">\n  <img src=\"assets/logo.jpg\" alt=\"PicoClaw Meme\" width=\"512\">\n</div>\n"
  },
  {
    "path": "docs/fr/spawn-tasks.md",
    "content": "# 🔄 Tâches Asynchrones et Spawn\n\n> Retour au [README](../../README.fr.md)\n\n## Tâches Rapides (réponse directe)\n\n- Rapporter l'heure actuelle\n\n## Tâches Longues (utiliser spawn pour l'asynchrone)\n\n- Rechercher sur le web des actualités IA et résumer\n- Vérifier les emails et rapporter les messages importants\n```\n\n**Comportements clés :**\n\n| Fonctionnalité          | Description                                                     |\n| ----------------------- | --------------------------------------------------------------- |\n| **spawn**               | Crée un subagent asynchrone, ne bloque pas le heartbeat         |\n| **Independent context** | Le subagent a son propre contexte, pas d'historique de session  |\n| **message tool**        | Le subagent communique directement avec l'utilisateur via l'outil message |\n| **Non-blocking**        | Après le spawn, le heartbeat continue à la tâche suivante       |\n\n#### Fonctionnement de la Communication du Subagent\n\n```\nHeartbeat se déclenche\n    ↓\nL'agent lit HEARTBEAT.md\n    ↓\nPour une tâche longue : spawn subagent\n    ↓                           ↓\nContinue à la tâche suivante  Le subagent travaille indépendamment\n    ↓                           ↓\nToutes les tâches terminées  Le subagent utilise l'outil \"message\"\n    ↓                           ↓\nRépond HEARTBEAT_OK          L'utilisateur reçoit le résultat directement\n```\n\nLe subagent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal.\n\n**Configuration :**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| Option     | Par défaut | Description                                    |\n| ---------- | ---------- | ---------------------------------------------- |\n| `enabled`  | `true`     | Activer/désactiver le heartbeat                |\n| `interval` | `30`       | Intervalle de vérification en minutes (min: 5) |\n\n**Variables d'environnement :**\n\n* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver\n* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle\n"
  },
  {
    "path": "docs/fr/tools_configuration.md",
    "content": "# 🔧 Configuration des Outils\n\n> Retour au [README](../../README.fr.md)\n\nLa configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`.\n\n## Structure du répertoire\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Outils Web\n\nLes outils web sont utilisés pour la recherche et la récupération de pages web.\n\n### Web Fetcher\nParamètres généraux pour la récupération et le traitement du contenu des pages web.\n\n| Config              | Type   | Par défaut    | Description                                                                                   |\n|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------|\n| `enabled`           | bool   | true          | Activer la capacité de récupération de pages web.                                             |\n| `fetch_limit_bytes` | int    | 10485760      | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo).          |\n| `format`            | string | \"plaintext\"   | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé).       |\n\n### Brave\n\n| Config        | Type   | Par défaut | Description               |\n|---------------|--------|------------|---------------------------|\n| `enabled`     | bool   | false      | Activer la recherche Brave |\n| `api_key`     | string | -          | Clé API Brave Search      |\n| `max_results` | int    | 5          | Nombre maximum de résultats |\n\n### DuckDuckGo\n\n| Config        | Type | Par défaut | Description                    |\n|---------------|------|------------|--------------------------------|\n| `enabled`     | bool | true       | Activer la recherche DuckDuckGo |\n| `max_results` | int  | 5          | Nombre maximum de résultats    |\n\n### Perplexity\n\n| Config        | Type   | Par défaut | Description                    |\n|---------------|--------|------------|--------------------------------|\n| `enabled`     | bool   | false      | Activer la recherche Perplexity |\n| `api_key`     | string | -          | Clé API Perplexity             |\n| `max_results` | int    | 5          | Nombre maximum de résultats    |\n\n## Outil Exec\n\nL'outil exec est utilisé pour exécuter des commandes shell.\n\n| Config                 | Type  | Par défaut | Description                                    |\n|------------------------|-------|------------|------------------------------------------------|\n| `enabled`              | bool  | true       | Activer l'outil exec                           |\n| `enable_deny_patterns` | bool  | true       | Activer le blocage par défaut des commandes dangereuses |\n| `custom_deny_patterns` | array | []         | Modèles de refus personnalisés (expressions régulières) |\n\n### Désactivation de l'Outil Exec\n\nPour désactiver complètement l'outil `exec`, définissez `enabled` à `false` :\n\n**Via le fichier de configuration :**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**Via la variable d'environnement :**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées.\n\n### Fonctionnalité\n\n- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses\n- **`custom_deny_patterns`** : Ajouter des modèles regex de refus personnalisés ; les commandes correspondantes seront bloquées\n\n### Modèles de commandes bloquées par défaut\n\nPar défaut, PicoClaw bloque les commandes dangereuses suivantes :\n\n- Commandes de suppression : `rm -rf`, `del /f/q`, `rmdir /s`\n- Opérations disque : `format`, `mkfs`, `diskpart`, `dd if=`, écriture vers `/dev/sd*`\n- Opérations système : `shutdown`, `reboot`, `poweroff`\n- Substitution de commandes : `$()`, `${}`, backticks\n- Pipe vers shell : `| sh`, `| bash`\n- Élévation de privilèges : `sudo`, `chmod`, `chown`\n- Contrôle de processus : `pkill`, `killall`, `kill -9`\n- Opérations distantes : `curl | sh`, `wget | sh`, `ssh`\n- Gestion de paquets : `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`\n- Conteneurs : `docker run`, `docker exec`\n- Git : `git push`, `git force`\n- Autres : `eval`, `source *.sh`\n\n### Limitation architecturale connue\n\nLe garde exec ne valide que la commande de niveau supérieur envoyée à PicoClaw. Il n'inspecte **pas** récursivement les processus enfants générés par les outils de build ou les scripts après le démarrage de cette commande.\n\nExemples de workflows pouvant contourner le garde de commande directe une fois la commande initiale autorisée :\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\nCela signifie que le garde est utile pour bloquer les commandes directes manifestement dangereuses, mais ce n'est **pas** un bac à sable complet pour les pipelines de build non vérifiés. Si votre modèle de menace inclut du code non fiable dans l'espace de travail, utilisez une isolation plus forte comme des conteneurs, des VM ou un flux d'approbation autour des commandes de build et d'exécution.\n\n### Exemple de configuration\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Outil Cron\n\nL'outil cron est utilisé pour planifier des tâches périodiques.\n\n| Config                 | Type | Par défaut | Description                                        |\n|------------------------|------|------------|----------------------------------------------------|\n| `exec_timeout_minutes` | int  | 5          | Délai d'expiration en minutes, 0 signifie sans limite |\n\n## Outil MCP\n\nL'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes.\n\n### Découverte d'outils (chargement paresseux)\n\nLors de la connexion à plusieurs serveurs MCP, exposer simultanément des centaines d'outils peut épuiser la fenêtre de contexte du LLM et augmenter les coûts API. La fonctionnalité **Discovery** résout ce problème en gardant les outils MCP *masqués* par défaut.\n\nAu lieu de charger tous les outils, le LLM reçoit un outil de recherche léger (utilisant la correspondance par mots-clés BM25 ou les expressions régulières). Lorsque le LLM a besoin d'une capacité spécifique, il recherche dans la bibliothèque masquée. Les outils correspondants sont alors temporairement « déverrouillés » et injectés dans le contexte pour un nombre configuré de tours (`ttl`).\n\n### Configuration globale\n\n| Config      | Type   | Par défaut | Description                                  |\n|-------------|--------|------------|----------------------------------------------|\n| `enabled`   | bool   | false      | Activer l'intégration MCP globalement        |\n| `discovery` | object | `{}`       | Configuration de la découverte d'outils (voir ci-dessous) |\n| `servers`   | object | `{}`       | Mappage du nom de serveur à la configuration du serveur |\n\n### Configuration Discovery (`discovery`)\n\n| Config               | Type | Par défaut | Description                                                                                                                       |\n|----------------------|------|------------|-----------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false      | Si true, les outils MCP sont masqués et chargés à la demande via la recherche. Si false, tous les outils sont chargés             |\n| `ttl`                | int  | 5          | Nombre de tours de conversation pendant lesquels un outil découvert reste déverrouillé                                            |\n| `max_search_results` | int  | 5          | Nombre maximum d'outils retournés par requête de recherche                                                                        |\n| `use_bm25`           | bool | true       | Activer l'outil de recherche par langage naturel/mots-clés (`tool_search_tool_bm25`). **Attention** : consomme plus de ressources que la recherche regex |\n| `use_regex`          | bool | false      | Activer l'outil de recherche par motif regex (`tool_search_tool_regex`)                                                           |\n\n> **Note :** Si `discovery.enabled` est `true`, vous **devez** activer au moins un moteur de recherche (`use_bm25` ou `use_regex`),\n> sinon l'application ne démarrera pas.\n\n### Configuration par serveur\n\n| Config     | Type   | Requis   | Description                                |\n|------------|--------|----------|--------------------------------------------|\n| `enabled`  | bool   | oui      | Activer ce serveur MCP                     |\n| `type`     | string | non      | Type de transport : `stdio`, `sse`, `http` |\n| `command`  | string | stdio    | Commande exécutable pour le transport stdio |\n| `args`     | array  | non      | Arguments de commande pour le transport stdio |\n| `env`      | object | non      | Variables d'environnement pour le processus stdio |\n| `env_file` | string | non      | Chemin vers le fichier d'environnement pour le processus stdio |\n| `url`      | string | sse/http | URL du point de terminaison pour le transport `sse`/`http` |\n| `headers`  | object | non      | En-têtes HTTP pour le transport `sse`/`http` |\n\n### Comportement du transport\n\n- Si `type` est omis, le transport est détecté automatiquement :\n    - `url` est défini → `sse`\n    - `command` est défini → `stdio`\n- `http` et `sse` utilisent tous deux `url` + `headers` optionnels.\n- `env` et `env_file` ne sont appliqués qu'aux serveurs `stdio`.\n\n### Exemples de configuration\n\n#### 1) Serveur MCP Stdio\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) Serveur MCP distant SSE/HTTP\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) Configuration MCP massive avec découverte d'outils activée\n\n*Dans cet exemple, le LLM ne verra que `tool_search_tool_bm25`. Il recherchera et déverrouillera dynamiquement les outils Github ou Postgres uniquement lorsque l'utilisateur le demande.*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n## Outil Skills\n\nL'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub.\n\n### Registres\n\n| Config                             | Type   | Par défaut           | Description                                  |\n|------------------------------------|--------|----------------------|----------------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                 | Activer le registre ClawHub                  |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai` | URL de base ClawHub                          |\n| `registries.clawhub.auth_token`    | string | `\"\"`                 | Jeton Bearer optionnel pour des limites de débit plus élevées |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`     | Chemin de l'API de recherche                 |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`     | Chemin de l'API Skills                       |\n| `registries.clawhub.download_path` | string | `/api/v1/download`   | Chemin de l'API de téléchargement            |\n\n### Exemple de configuration\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## Variables d'environnement\n\nToutes les options de configuration peuvent être remplacées via des variables d'environnement au format `PICOCLAW_TOOLS_<SECTION>_<KEY>` :\n\nPar exemple :\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\nNote : La configuration de type map imbriquée (par exemple `tools.mcp.servers.<name>.*`) est configurée dans `config.json` plutôt que via des variables d'environnement.\n"
  },
  {
    "path": "docs/fr/troubleshooting.md",
    "content": "# 🐛 Dépannage\n\n> Retour au [README](../../README.fr.md)\n\n## \"model ... not found in model_list\" ou OpenRouter \"free is not a valid model ID\"\n\n**Symptôme :** Vous voyez l'une des erreurs suivantes :\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter retourne 400 : `\"free is not a valid model ID\"`\n\n**Cause :** Le champ `model` dans votre entrée `model_list` est ce qui est envoyé à l'API. Pour OpenRouter, vous devez utiliser l'identifiant de modèle **complet**, pas un raccourci.\n\n- **Incorrect :** `\"model\": \"free\"` → OpenRouter reçoit `free` et le rejette.\n- **Correct :** `\"model\": \"openrouter/free\"` → OpenRouter reçoit `openrouter/free` (routage automatique du niveau gratuit).\n\n**Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) :\n\n1. **agents.defaults.model** doit correspondre à un `model_name` dans `model_list` (par ex. `\"openrouter-free\"`).\n2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple :\n   - `\"openrouter/free\"` – niveau gratuit automatique\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\nExemple :\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\nObtenez votre clé sur [OpenRouter Keys](https://openrouter.ai/keys).\n"
  },
  {
    "path": "docs/it/configuration.md",
    "content": "# ⚙️ Guida alla Configurazione\n\n> Torna al [README](../../README.md)\n\n## ⚙️ Configurazione\n\nFile di configurazione: `~/.picoclaw/config.json`\n\n### Variabili d'Ambiente\n\nPuoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.\n\n| Variabile         | Descrizione                                                                                                                             | Percorso Predefinito      |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati.  | `~/.picoclaw`             |\n\n**Esempi:**\n\n```bash\n# Esegui picoclaw usando un file di configurazione specifico\n# Il percorso del workspace verrà letto da quel file di configurazione\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw\n# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json\n# Il workspace verrà creato in /opt/picoclaw/workspace\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# Usa entrambi per un setup completamente personalizzato\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### Struttura del Workspace\n\nPicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # Sessioni di conversazione e cronologia\n├── memory/           # Memoria a lungo termine (MEMORY.md)\n├── state/            # Stato persistente (ultimo canale, ecc.)\n├── cron/             # Database dei job pianificati\n├── skills/           # Skill personalizzate\n├── AGENTS.md         # Guida al comportamento dell'agent\n├── HEARTBEAT.md      # Prompt per task periodici (controllato ogni 30 min)\n├── IDENTITY.md       # Identità dell'agent\n├── SOUL.md           # Anima dell'agent\n└── USER.md           # Preferenze dell'utente\n```\n\n> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.\n\n### Sorgenti delle Skill\n\nPer impostazione predefinita, le skill vengono caricate da:\n\n1. `~/.picoclaw/workspace/skills` (workspace)\n2. `~/.picoclaw/skills` (globale)\n3. `<current-working-directory>/skills` (builtin)\n\nPer configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### Politica Unificata di Esecuzione dei Comandi\n\n- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.\n- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.\n- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.\n- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.\n\n### 🔒 Sandbox di Sicurezza\n\nPicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.\n\n#### Configurazione Predefinita\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| Opzione                 | Predefinito             | Descrizione                                          |\n| ----------------------- | ----------------------- | ---------------------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Directory di lavoro dell'agent                       |\n| `restrict_to_workspace` | `true`                  | Limita l'accesso a file/comandi al workspace         |\n\n#### Strumenti Protetti\n\nQuando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:\n\n| Strumento     | Funzione                  | Restrizione                                          |\n| ------------- | ------------------------- | ---------------------------------------------------- |\n| `read_file`   | Legge file                | Solo file all'interno del workspace                  |\n| `write_file`  | Scrive file               | Solo file all'interno del workspace                  |\n| `list_dir`    | Elenca directory          | Solo directory all'interno del workspace             |\n| `edit_file`   | Modifica file             | Solo file all'interno del workspace                  |\n| `append_file` | Aggiunge ai file          | Solo file all'interno del workspace                  |\n| `exec`        | Esegue comandi            | I percorsi dei comandi devono essere nel workspace   |\n\n#### Protezione Exec Aggiuntiva\n\nAnche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:\n\n* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa\n* `format`, `mkfs`, `diskpart` — Formattazione del disco\n* `dd if=` — Imaging del disco\n* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco\n* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema\n* Fork bomb `:(){ :|:& };:`\n\n### Controllo Accesso ai File\n\n| Chiave di configurazione | Tipo | Predefinito | Descrizione |\n|--------------------------|------|-------------|-------------|\n| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |\n| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |\n\n### Sicurezza Exec\n\n| Chiave di configurazione | Tipo | Predefinito | Descrizione |\n|--------------------------|------|-------------|-------------|\n| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |\n| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |\n\n> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.\n\n#### Limitazione Nota: Processi Figlio degli Strumenti di Build\n\nIl controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.\n\nCiò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.\n\nPer ambienti ad alto rischio:\n\n* Esamina gli script di build prima dell'esecuzione.\n* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.\n* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.\n\n#### Esempi di Errore\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### Disabilitare le Restrizioni (Rischio di Sicurezza)\n\nSe hai bisogno che l'agent acceda a percorsi al di fuori del workspace:\n\n**Metodo 1: File di configurazione**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**Metodo 2: Variabile d'ambiente**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.\n\n#### Coerenza dei Confini di Sicurezza\n\nL'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:\n\n| Percorso di esecuzione | Confine di sicurezza              |\n| ---------------------- | --------------------------------- |\n| Main Agent             | `restrict_to_workspace` ✅        |\n| Subagent / Spawn       | Eredita la stessa restrizione ✅  |\n| Heartbeat tasks        | Eredita la stessa restrizione ✅  |\n\nTutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.\n\n### Heartbeat (Task Periodici)\n\nPicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:\n\n```markdown\n# Periodic Tasks\n\n- Check my email for important messages\n- Review my calendar for upcoming events\n- Check the weather forecast\n```\n\nL'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.\n\n#### Task Asincroni con Spawn\n\nPer task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:\n\n```markdown\n# Periodic Tasks\n```\n"
  },
  {
    "path": "docs/ja/chat-apps.md",
    "content": "# 💬 チャットアプリ設定\n\n> [README](../../README.ja.md) に戻る\n\n## 💬 チャットアプリ連携\n\nPicoClaw は複数のチャットプラットフォームをサポートしており、Agent をどこにでも接続できます。\n\n> **注意**: すべての Webhook ベースのチャネル（LINE、WeCom など）は、共有 Gateway HTTP サーバー（`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`）上で提供されます。チャネルごとにポートを設定する必要はありません。注意：飛書（Feishu）は WebSocket/SDK モードを使用し、共有 HTTP Webhook サーバーは使用しません。\n\n### チャネル一覧\n\n| チャネル             | セットアップ難易度 | 特徴                                      | ドキュメント                                                                                                    |\n| -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |\n| **Telegram**         | ⭐ 簡単            | 推奨、音声テキスト変換対応、ロングポーリング（公開 IP 不要） | [ドキュメント](../channels/telegram/README.zh.md)                                                             |\n| **Discord**          | ⭐ 簡単            | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.zh.md)                                                              |\n| **WhatsApp**         | ⭐ 簡単            | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](../channels/whatsapp/README.zh.md)                                                             |\n| **Slack**            | ⭐ 簡単            | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.zh.md)                                                                |\n| **Matrix**           | ⭐⭐ 中程度        | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.zh.md)                                                              |\n| **QQ**               | ⭐⭐ 中程度        | 公式ボット API、中国コミュニティ向け       | [ドキュメント](../channels/qq/README.zh.md)                                                                   |\n| **DingTalk**         | ⭐⭐ 中程度        | Stream モード（公開 IP 不要）、企業向け    | [ドキュメント](../channels/dingtalk/README.zh.md)                                                             |\n| **LINE**             | ⭐⭐⭐ やや難      | HTTPS Webhook が必要                       | [ドキュメント](../channels/line/README.zh.md)                                                                 |\n| **WeCom (企業微信)** | ⭐⭐⭐ やや難      | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.zh.md) / [App](../channels/wecom/wecom_app/README.zh.md) / [AI Bot](../channels/wecom/wecom_aibot/README.zh.md) |\n| **Feishu (飛書)**    | ⭐⭐⭐ やや難      | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.zh.md)                                                               |\n| **IRC**              | ⭐⭐ 中程度        | サーバー + TLS 設定                        | -                                                                                                               |\n| **OneBot**           | ⭐⭐ 中程度        | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.zh.md)                                                               |\n| **MaixCam**          | ⭐ 簡単            | Sipeed AI カメラハードウェア統合チャネル   | [ドキュメント](../channels/maixcam/README.zh.md)                                                              |\n| **Pico**             | ⭐ 簡単            | PicoClaw ネイティブプロトコルチャネル     |                                                                                                               |\n\n---\n\n<details>\n<summary><b>Telegram</b>（推奨）</summary>\n\n**1. Bot を作成**\n\n* Telegram を開き、`@BotFather` を検索\n* `/newbot` を送信し、プロンプトに従う\n* Token をコピー\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> Telegram の `@userinfobot` から User ID を取得できます。\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n**4. Telegram コマンドメニュー（起動時に自動登録）**\n\nPicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド（例: `/start`、`/help`、`/show`、`/list`）を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。\nTelegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。\n\nネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Bot を作成**\n\n* <https://discord.com/developers/applications> にアクセス\n* アプリケーションを作成 → Bot → Bot を追加\n* Bot Token をコピー\n\n**2. Intents を有効化**\n\n* Bot 設定で **MESSAGE CONTENT INTENT** を有効化\n* （オプション）メンバーデータに基づくホワイトリストが必要な場合は **SERVER MEMBERS INTENT** を有効化\n\n**3. User ID を取得**\n\n* Discord 設定 → 詳細設定 → **開発者モード** を有効化\n* アバターを右クリック → **ユーザー ID をコピー**\n\n**4. 設定**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Bot を招待**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* 生成された招待リンクを開き、Bot をサーバーに追加\n\n**オプション：グループトリガーモード**\n\nデフォルトでは Bot はサーバーチャネル内のすべてのメッセージに応答します。@メンション時のみ応答するには：\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nキーワードプレフィックスでトリガーすることもできます（例: `!bot`）：\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b>（ネイティブ whatsmeow）</summary>\n\nPicoClaw は 2 つの WhatsApp 接続方式をサポートしています：\n\n- **ネイティブ（推奨）：** プロセス内で [whatsmeow](https://github.com/tulir/whatsmeow) を使用。独立した Bridge は不要です。`\"use_native\": true` に設定し、`bridge_url` を空にします。初回実行時に WhatsApp で QR コードをスキャン（リンクデバイス）。セッションはワークスペース配下（例: `workspace/whatsapp/`）に保存されます。ネイティブチャネルは**オプション**ビルドで、`-tags whatsapp_native` でコンパイルします（例: `make build-whatsapp-native` または `go build -tags whatsapp_native ./cmd/...`）。\n- **Bridge：** 外部 WebSocket Bridge に接続。`bridge_url`（例: `ws://localhost:3001`）を設定し、`use_native` を false のままにします。\n\n**設定（ネイティブ）**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n`session_store_path` が空の場合、セッションは `<workspace>/whatsapp/` に保存されます。`picoclaw gateway` を実行し、初回実行時にターミナルに表示される QR コードをスキャンしてください（WhatsApp → リンクデバイス）。\n\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Bot アカウントを準備**\n\n* お好みの homeserver（例: `https://matrix.org` またはセルフホスト）を使用\n* Bot ユーザーを作成し、access token を取得\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\nすべてのオプション（`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`）については [Matrix チャネル設定ガイド](../channels/matrix/README.md) を参照してください。\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Bot を作成**\n\n- [QQ 開放プラットフォーム](https://q.qq.com/#) にアクセス\n- アプリケーションを作成 → **AppID** と **AppSecret** を取得\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> `allow_from` を空にするとすべてのユーザーを許可します。QQ 番号を指定してアクセスを制限することもできます。\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>Slack</b></summary>\n\n**1. Slack App を作成**\n\n* [Slack API](https://api.slack.com/apps) でアプリを作成\n* **Socket Mode** を有効化\n* **Bot Token** と **App-Level Token** を取得\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"slack\": {\n      \"enabled\": true,\n      \"bot_token\": \"xoxb-YOUR_BOT_TOKEN\",\n      \"app_token\": \"xapp-YOUR_APP_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>IRC</b></summary>\n\n**1. 設定**\n\n```json\n{\n  \"channels\": {\n    \"irc\": {\n      \"enabled\": true,\n      \"server\": \"irc.libera.chat:6697\",\n      \"nick\": \"picoclaw-bot\",\n      \"use_tls\": true,\n      \"channels_to_join\": [\"#your-channel\"],\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**2. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Bot を作成**\n\n* [開放プラットフォーム](https://open.dingtalk.com/) にアクセス\n* 内部アプリを作成\n* Client ID と Client Secret をコピー\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> `allow_from` を空にするとすべてのユーザーを許可します。DingTalk ユーザー ID を指定してアクセスを制限することもできます。\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. LINE 公式アカウントを作成**\n\n- [LINE Developers Console](https://developers.line.biz/) にアクセス\n- Provider を作成 → Messaging API チャネルを作成\n- **Channel Secret** と **Channel Access Token** をコピー\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> LINE Webhook は共有 Gateway サーバー（`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`）上で提供されます。\n\n**3. Webhook URL を設定**\n\nLINE は HTTPS Webhook が必要です。リバースプロキシまたはトンネルを使用してください：\n\n```bash\n# 例：ngrok を使用（Gateway デフォルトポートは 18790）\nngrok http 18790\n```\n\nLINE Developers Console で Webhook URL を `https://your-domain/webhook/line` に設定し、**Use webhook** を有効にしてください。\n\n**4. 実行**\n\n```bash\npicoclaw gateway\n```\n\n> グループチャットでは、Bot は @メンション時のみ応答します。返信は元のメッセージを引用します。\n\n</details>\n\n<details>\n<summary><b>Feishu (飛書)</b></summary>\n\n**1. アプリを作成**\n\n* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセス\n* 企業カスタムアプリを作成\n* **App ID** と **App Secret** を取得\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"feishu\": {\n      \"enabled\": true,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WeCom (企業微信)</b></summary>\n\nPicoClaw は 3 種類の WeCom 統合をサポートしています：\n\n**方式 1: グループ Bot (Bot)** — セットアップ簡単、グループチャット対応\n**方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ\n**方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応\n\n詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.zh.md) を参照してください。\n\n**クイックセットアップ — グループ Bot：**\n\n**1. Bot を作成**\n\n* WeCom 管理コンソール → グループチャット → グループ Bot を追加\n* Webhook URL をコピー（形式：`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`）\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> WeCom Webhook は共有 Gateway サーバー（`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`）上で提供されます。\n\n**クイックセットアップ — カスタムアプリ：**\n\n**1. アプリを作成**\n\n* WeCom 管理コンソール → アプリ管理 → アプリを作成\n* **AgentId** と **Secret** をコピー\n* 「マイ企業」ページで **CorpID** をコピー\n\n**2. メッセージ受信を設定**\n\n* アプリ詳細で「メッセージ受信」→「API を設定」をクリック\n* URL を `http://your-server:18790/webhook/wecom-app` に設定\n* **Token** と **EncodingAESKey** を生成\n\n**3. 設定**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. 実行**\n\n```bash\npicoclaw gateway\n```\n\n> **注意**: WeCom Webhook コールバックは Gateway ポート（デフォルト 18790）で提供されます。HTTPS にはリバースプロキシを使用してください。\n\n**クイックセットアップ — AI Bot：**\n\n**1. AI Bot を作成**\n\n* WeCom 管理コンソール → アプリ管理 → AI Bot\n* AI Bot 設定でコールバック URL を設定：`http://your-server:18791/webhook/wecom-aibot`\n* **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得\n\n**2. 設定**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"こんにちは！何かお手伝いできますか？\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n    }\n  }\n}\n```\n\n**3. 実行**\n\n```bash\npicoclaw gateway\n```\n\n> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用しており、返信タイムアウトの心配はありません。長時間タスク（30 秒超）は自動的に `response_url` プッシュ配信に切り替わります。\n\n</details>\n\n<details>\n<summary><b>OneBot</b></summary>\n\n**1. 設定**\n\nNapCat / Go-CQHTTP などの OneBot 実装と互換性があります。\n\n```json\n{\n  \"channels\": {\n    \"onebot\": {\n      \"enabled\": true,\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**2. 実行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>MaixCam</b></summary>\n\nSipeed AI カメラハードウェア向けの統合チャネルです。\n\n```json\n{\n  \"channels\": {\n    \"maixcam\": {\n      \"enabled\": true\n    }\n  }\n}\n```\n\n```bash\npicoclaw gateway\n```\n\n</details>\n"
  },
  {
    "path": "docs/ja/configuration.md",
    "content": "# ⚙️ 設定ガイド\n\n> [README](../../README.ja.md) に戻る\n\n## ⚙️ 設定詳細\n\n設定ファイルパス: `~/.picoclaw/config.json`\n\n### 環境変数\n\n環境変数を使用してデフォルトパスを上書きできます。ポータブルインストール、コンテナ化デプロイ、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。\n\n| 変数              | 説明                                                                                                                             | デフォルトパス            |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | 設定ファイルのパスを上書きします。picoclaw がどの `config.json` を読み込むかを直接指定し、他のすべての場所を無視します。 | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | picoclaw データのルートディレクトリを上書きします。`workspace` やその他のデータディレクトリのデフォルト場所を変更します。          | `~/.picoclaw`             |\n\n**例：**\n\n```bash\n# 特定の設定ファイルで picoclaw を実行\n# ワークスペースパスはその設定ファイル内から読み込まれます\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# /opt/picoclaw にすべてのデータを保存して picoclaw を実行\n# 設定はデフォルトの ~/.picoclaw/config.json から読み込まれます\n# ワークスペースは /opt/picoclaw/workspace に作成されます\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# 両方を使用して完全にカスタマイズ\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### ワークスペースレイアウト\n\nPicoClaw は設定されたワークスペース（デフォルト: `~/.picoclaw/workspace`）にデータを保存します：\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # 会話セッションと履歴\n├── memory/           # 長期記憶 (MEMORY.md)\n├── state/            # 永続化状態 (最後のチャネルなど)\n├── cron/             # スケジュールジョブデータベース\n├── skills/           # カスタムスキル\n├── AGENT.md          # Agent 動作ガイド\n├── HEARTBEAT.md      # 定期タスクプロンプト (30 分ごとにチェック)\n├── IDENTITY.md       # Agent アイデンティティ\n├── SOUL.md           # Agent ソウル/性格\n└── USER.md           # ユーザー設定\n```\n\n> **注意：** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻（mtime）の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。\n\n### スキルソース\n\nデフォルトでは、スキルは以下の順序で読み込まれます：\n\n1. `~/.picoclaw/workspace/skills`（ワークスペース）\n2. `~/.picoclaw/skills`（グローバル）\n3. `<current-working-directory>/skills`（ビルトイン）\n\n高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます：\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### 統一コマンド実行ポリシー\n\n- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。\n- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。\n- 未登録のスラッシュコマンド（例: `/foo`）は通常の LLM 処理にパススルーされます。\n- 登録済みだが現在のチャネルでサポートされていないコマンド（例: WhatsApp での `/show`）は、明示的なユーザー向けエラーを返し、以降の処理を停止します。\n\n### 🔒 セキュリティサンドボックス\n\nPicoClaw はデフォルトでサンドボックス環境で実行されます。Agent は設定されたワークスペース内のファイルアクセスとコマンド実行のみが可能です。\n\n#### デフォルト設定\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| オプション              | デフォルト値            | 説明                                  |\n| ----------------------- | ----------------------- | ------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Agent の作業ディレクトリ              |\n| `restrict_to_workspace` | `true`                  | ファイル/コマンドアクセスをワークスペース内に制限 |\n\n#### 保護されたツール\n\n`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます：\n\n| ツール        | 機能             | 制限                               |\n| ------------- | ---------------- | ---------------------------------- |\n| `read_file`   | ファイル読み取り | ワークスペース内のファイルのみ     |\n| `write_file`  | ファイル書き込み | ワークスペース内のファイルのみ     |\n| `list_dir`    | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |\n| `edit_file`   | ファイル編集     | ワークスペース内のファイルのみ     |\n| `append_file` | ファイル追記     | ワークスペース内のファイルのみ     |\n| `exec`        | コマンド実行     | コマンドパスはワークスペース内必須 |\n\n#### 追加の Exec 保護\n\n`restrict_to_workspace: false` の場合でも、`exec` ツールは以下の危険なコマンドをブロックします：\n\n* `rm -rf`、`del /f`、`rmdir /s` — 一括削除\n* `format`、`mkfs`、`diskpart` — ディスクフォーマット\n* `dd if=` — ディスクイメージング\n* `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み\n* `shutdown`、`reboot`、`poweroff` — システムシャットダウン\n* Fork bomb `:(){ :|:& };:`\n\n### ファイルアクセス制御\n\n| 設定キー | 型 | デフォルト値 | 説明 |\n|----------|------|-------------|------|\n| `tools.allow_read_paths` | string[] | `[]` | ワークスペース外で読み取りを許可する追加パス |\n| `tools.allow_write_paths` | string[] | `[]` | ワークスペース外で書き込みを許可する追加パス |\n\n### Exec セキュリティ設定\n\n| 設定キー | 型 | デフォルト値 | 説明 |\n|----------|------|-------------|------|\n| `tools.exec.allow_remote` | bool | `false` | リモートチャネル（Telegram/Discord など）からの exec ツール実行を許可 |\n| `tools.exec.enable_deny_patterns` | bool | `true` | 危険なコマンドのインターセプトを有効化 |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | カスタムブロック正規表現パターン |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | カスタム許可正規表現パターン |\n\n> **セキュリティ注意:** Symlink 保護はデフォルトで有効です。すべてのファイルパスはホワイトリストマッチング前に `filepath.EvalSymlinks` で解決され、シンボリックリンクエスケープ攻撃を防止します。\n\n#### 既知の制限：ビルドツールの子プロセス\n\nexec セキュリティガードは PicoClaw が直接起動するコマンドラインのみを検査します。`make`、`go run`、`cargo`、`npm run`、またはカスタムビルドスクリプトなどの開発ツールが生成する子プロセスは再帰的に検査しません。\n\nつまり、トップレベルのコマンドが初期ガードチェックを通過した後、他のバイナリをコンパイルまたは起動できます。実際には、ビルドスクリプト、Makefile、パッケージスクリプト、生成されたバイナリを、直接のシェルコマンドと同等レベルの実行可能コードとしてレビューする必要があります。\n\n高リスク環境の場合：\n\n* 実行前にビルドスクリプトをレビューしてください。\n* コンパイル・実行ワークフローには承認/手動レビューを優先してください。\n* ビルトインガードより強力な分離が必要な場合は、コンテナまたは VM 内で PicoClaw を実行してください。\n\n#### エラー例\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### 制限の無効化（セキュリティリスク）\n\nAgent がワークスペース外のパスにアクセスする必要がある場合：\n\n**方法 1: 設定ファイル**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**方法 2: 環境変数**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **警告**: この制限を無効にすると、Agent がシステム上の任意のパスにアクセスできるようになります。管理された環境でのみ慎重に使用してください。\n\n#### セキュリティ境界の一貫性\n\n`restrict_to_workspace` 設定はすべての実行パスで一貫して適用されます：\n\n| 実行パス         | セキュリティ境界             |\n| ---------------- | ---------------------------- |\n| メイン Agent     | `restrict_to_workspace` ✅   |\n| サブ Agent / Spawn | 同じ制限を継承 ✅           |\n| ハートビートタスク | 同じ制限を継承 ✅           |\n\nすべてのパスは同じワークスペース制限を共有しており、サブ Agent やスケジュールタスクを通じてセキュリティ境界を回避することはできません。\n\n### ハートビート（定期タスク）\n\nPicoClaw は定期タスクを自動実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成してください：\n\n```markdown\n# Periodic Tasks\n\n- Check my email for important messages\n- Review my calendar for upcoming events\n- Check the weather forecast\n```\n\nAgent は 30 分ごと（設定可能）にこのファイルを読み取り、利用可能なツールを使用してタスクを実行します。\n\n#### Spawn を使用した非同期タスク\n\n長時間実行タスク（Web 検索、API 呼び出し）には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します：\n\n```markdown\n# Periodic Tasks\n\n## Quick Tasks (respond directly)\n\n- Report current time\n\n## Long Tasks (use spawn for async)\n\n- Search the web for AI news and summarize\n- Check email and report important messages\n```\n\n**主な動作：**\n\n| 特性             | 説明                                         |\n| ---------------- | -------------------------------------------- |\n| **spawn**        | 非同期サブ Agent を作成、メインハートビートをブロックしない |\n| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし |\n| **message tool** | サブ Agent は message ツールでユーザーと直接通信 |\n| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む     |\n\n**設定：**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| オプション | デフォルト値 | 説明                           |\n| ---------- | ------------ | ------------------------------ |\n| `enabled`  | `true`       | ハートビートの有効/無効        |\n| `interval` | `30`         | チェック間隔（分単位、最小: 5）|\n\n**環境変数:**\n\n- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化\n- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更\n"
  },
  {
    "path": "docs/ja/docker.md",
    "content": "# 🐳 Docker とクイックスタート\n\n> [README](../../README.ja.md) に戻る\n\n## 🐳 Docker Compose\n\nDocker Compose を使用して PicoClaw を実行できます。ローカルに何もインストールする必要はありません。\n\n```bash\n# 1. リポジトリをクローン\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. 初回実行 — docker/data/config.json を自動生成して終了\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# コンテナが \"First-run setup complete.\" と表示して停止します\n\n# 3. API Key を設定\nvim docker/data/config.json   # provider API key、Bot Token などを設定\n\n# 4. 起動\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Docker ユーザー**: デフォルトでは Gateway は `127.0.0.1` でリッスンしており、コンテナ外からはアクセスできません。ヘルスチェックエンドポイントへのアクセスやポート公開が必要な場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。\n\n```bash\n# 5. ログを確認\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. 停止\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Launcher モード (Web コンソール)\n\n`launcher` イメージには 3 つのバイナリ（`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`）がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。\n\n> [!WARNING]\n> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。\n\n### Agent モード (ワンショット)\n\n```bash\n# 質問する\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"2+2は？\"\n\n# インタラクティブモード\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### イメージの更新\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n---\n\n## 🚀 クイックスタート\n\n> [!TIP]\n> `~/.picoclaw/config.json` に API Key を設定してください。API Key の取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は**オプション**です — 無料の [Tavily API](https://tavily.com) (月 1000 回無料) または [Brave Search API](https://brave.com/search/api) (月 2000 回無料) を取得できます。\n\n**1. 初期化**\n\n```bash\npicoclaw onboard\n```\n\n**2. 設定** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。\n> `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト（120 秒）を使用します。\n\n**3. API Key の取得**\n\n* **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Web 検索** (オプション):\n  * [Brave Search](https://brave.com/search/api) - 有料 ($5/1000 queries, ~$5-6/month)\n  * [Perplexity](https://www.perplexity.ai) - AI 搭載の検索・チャットインターフェース\n  * [SearXNG](https://github.com/searxng/searxng) - セルフホスト型メタ検索エンジン（無料、API Key 不要）\n  * [Tavily](https://tavily.com) - AI Agent 向けに最適化 (1000 requests/month)\n  * DuckDuckGo - 組み込みフォールバック（API Key 不要）\n\n> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。\n\n**4. チャット**\n\n```bash\npicoclaw agent -m \"2+2は？\"\n```\n\n以上です！2 分で動作する AI アシスタントが手に入ります。\n\n---\n"
  },
  {
    "path": "docs/ja/providers.md",
    "content": "# 🔌 プロバイダーとモデル設定\n\n> [README](../../README.ja.md) に戻る\n\n### プロバイダー\n\n> [!NOTE]\n> Groq は Whisper による無料の音声文字起こしを提供しています。Groq を設定すると、任意のチャネルからの音声メッセージが Agent レベルで自動的にテキストに変換されます。\n\n| プロバイダー         | 用途                         | API Key の取得                                                       |\n| -------------------- | ---------------------------- | -------------------------------------------------------------------- |\n| `gemini`             | LLM (Gemini 直接接続)       | [aistudio.google.com](https://aistudio.google.com)                   |\n| `zhipu`              | LLM (Zhipu 直接接続)        | [bigmodel.cn](https://bigmodel.cn)                                   |\n| `volcengine`         | LLM (Volcengine 直接接続)   | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |\n| `openrouter`         | LLM (推奨、全モデルアクセス可) | [openrouter.ai](https://openrouter.ai)                               |\n| `anthropic`          | LLM (Claude 直接接続)       | [console.anthropic.com](https://console.anthropic.com)               |\n| `openai`             | LLM (GPT 直接接続)          | [platform.openai.com](https://platform.openai.com)                   |\n| `deepseek`           | LLM (DeepSeek 直接接続)     | [platform.deepseek.com](https://platform.deepseek.com)               |\n| `qwen`               | LLM (Qwen 直接接続)         | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`               | LLM + **音声文字起こし** (Whisper) | [console.groq.com](https://console.groq.com)                         |\n| `cerebras`           | LLM (Cerebras 直接接続)     | [cerebras.ai](https://cerebras.ai)                                   |\n| `vivgrid`            | LLM (Vivgrid 直接接続)      | [vivgrid.com](https://vivgrid.com)                                   |\n| `moonshot`           | LLM (Kimi/Moonshot 直接接続) | [platform.moonshot.cn](https://platform.moonshot.cn)                 |\n| `minimax`            | LLM (Minimax 直接接続)      | [platform.minimaxi.com](https://platform.minimaxi.com)              |\n| `avian`              | LLM (Avian 直接接続)        | [avian.io](https://avian.io)                                         |\n| `mistral`            | LLM (Mistral 直接接続)      | [console.mistral.ai](https://console.mistral.ai)                    |\n| `longcat`            | LLM (Longcat 直接接続)      | [longcat.ai](https://longcat.ai)                                     |\n| `modelscope`         | LLM (ModelScope 直接接続)   | [modelscope.cn](https://modelscope.cn)                               |\n\n### モデル設定 (model_list)\n\n> **新機能！** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式（例: `zhipu/glm-4.7`）を指定するだけで新しい provider を追加できます——**コード変更は一切不要です！**\n\nこの設計は**マルチ Agent シナリオ**もサポートし、柔軟な Provider 選択を提供します：\n\n- **Agent ごとに異なる Provider**: 各 Agent が独自の LLM provider を使用可能\n- **モデルフォールバック**: プライマリモデルとフォールバックモデルを設定し、信頼性を向上\n- **ロードバランシング**: 複数の API エンドポイント間でリクエストを分散\n- **一元管理**: すべての provider を一箇所で管理\n\n#### 📋 サポートされている全ベンダー\n\n| ベンダー            | `model` プレフィックス | デフォルト API Base                                 | プロトコル | API Key の取得                                                    |\n| ------------------- | --------------------- | --------------------------------------------------- | ---------- | ----------------------------------------------------------------- |\n| **OpenAI**          | `openai/`             | `https://api.openai.com/v1`                         | OpenAI     | [キーを取得](https://platform.openai.com)                         |\n| **Anthropic**       | `anthropic/`          | `https://api.anthropic.com/v1`                      | Anthropic  | [キーを取得](https://console.anthropic.com)                       |\n| **智谱 AI (GLM)**   | `zhipu/`              | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI     | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`           | `https://api.deepseek.com/v1`                       | OpenAI     | [キーを取得](https://platform.deepseek.com)                       |\n| **Google Gemini**   | `gemini/`             | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI     | [キーを取得](https://aistudio.google.com/api-keys)                |\n| **Groq**            | `groq/`               | `https://api.groq.com/openai/v1`                    | OpenAI     | [キーを取得](https://console.groq.com)                            |\n| **Moonshot**        | `moonshot/`           | `https://api.moonshot.cn/v1`                        | OpenAI     | [キーを取得](https://platform.moonshot.cn)                        |\n| **通義千問 (Qwen)** | `qwen/`               | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI     | [キーを取得](https://dashscope.console.aliyun.com)                |\n| **NVIDIA**          | `nvidia/`             | `https://integrate.api.nvidia.com/v1`               | OpenAI     | [キーを取得](https://build.nvidia.com)                            |\n| **Ollama**          | `ollama/`             | `http://localhost:11434/v1`                         | OpenAI     | ローカル（キー不要）                                              |\n| **OpenRouter**      | `openrouter/`         | `https://openrouter.ai/api/v1`                      | OpenAI     | [キーを取得](https://openrouter.ai/keys)                          |\n| **LiteLLM Proxy**   | `litellm/`            | `http://localhost:4000/v1`                          | OpenAI     | LiteLLM プロキシキー                                              |\n| **VLLM**            | `vllm/`               | `http://localhost:8000/v1`                          | OpenAI     | ローカル                                                          |\n| **Cerebras**        | `cerebras/`           | `https://api.cerebras.ai/v1`                        | OpenAI     | [キーを取得](https://cerebras.ai)                                 |\n| **VolcEngine (Doubao)** | `volcengine/`     | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI     | [キーを取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |\n| **神算云**          | `shengsuanyun/`       | `https://router.shengsuanyun.com/api/v1`            | OpenAI     | -                                                                 |\n| **BytePlus**        | `byteplus/`           | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI     | [キーを取得](https://www.byteplus.com)                            |\n| **Vivgrid**         | `vivgrid/`            | `https://api.vivgrid.com/v1`                        | OpenAI     | [キーを取得](https://vivgrid.com)                                 |\n| **LongCat**         | `longcat/`            | `https://api.longcat.chat/openai`                   | OpenAI     | [キーを取得](https://longcat.chat/platform)                       |\n| **ModelScope (魔搭)**| `modelscope/`        | `https://api-inference.modelscope.cn/v1`            | OpenAI     | [トークンを取得](https://modelscope.cn/my/tokens)                |\n| **Antigravity**     | `antigravity/`        | Google Cloud                                        | カスタム   | OAuth のみ                                                        |\n| **GitHub Copilot**  | `github-copilot/`     | `localhost:4321`                                    | gRPC       | -                                                                 |\n\n#### 基本設定\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### ベンダー別設定例\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**VolcEngine (Doubao)**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (API キー使用)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"api_key\": \"sk-ant-your-key\"\n}\n```\n\n> `picoclaw auth login --provider anthropic` を実行して API トークンを設定してください。\n\n**Anthropic Messages API（ネイティブ形式）**\n\nAnthropic API への直接アクセスや、Anthropic のネイティブメッセージ形式のみをサポートするカスタムエンドポイント向け：\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> `anthropic-messages` プロトコルを使用するケース：\n> - Anthropic のネイティブ `/v1/messages` エンドポイントのみをサポートするサードパーティプロキシを使用する場合（OpenAI 互換の `/v1/chat/completions` 非対応）\n> - MiniMax、Synthetic など Anthropic のネイティブメッセージ形式を必要とするサービスに接続する場合\n> - 既存の `anthropic` プロトコルが 404 エラーを返す場合（エンドポイントが OpenAI 互換形式をサポートしていないことを示す）\n>\n> **注意:** `anthropic` プロトコルは OpenAI 互換形式（`/v1/chat/completions`）を使用し、`anthropic-messages` は Anthropic のネイティブ形式（`/v1/messages`）を使用します。エンドポイントがサポートする形式に応じて選択してください。\n\n**Ollama (ローカル)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**カスタムプロキシ/API**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nPicoClaw はリクエスト送信前に外側の `litellm/` プレフィックスのみを除去するため、`litellm/lite-gpt4` は `lite-gpt4` を送信し、`litellm/openai/gpt-4o` は `openai/gpt-4o` を送信します。\n\n#### ロードバランシング\n\n同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します：\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### レガシー `providers` 設定からの移行\n\n旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。\n\n**旧設定（非推奨）：**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**新設定（推奨）：**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n詳細な移行ガイドは [docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。\n\n### Provider アーキテクチャ\n\nPicoClaw はプロトコルファミリーごとに Provider をルーティングします：\n\n- OpenAI 互換プロトコル：OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。\n- Anthropic プロトコル：Claude ネイティブ API 動作。\n- Codex/OAuth パス：OpenAI OAuth/Token 認証ルート。\n\nこれによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作（`api_base` + `api_key`）のみで実現しています。\n\n<details>\n<summary><b>Zhipu 設定例</b></summary>\n\n**1. API key と base URL を取得**\n\n- [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) を取得\n\n**2. 設定**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. 実行**\n\n```bash\npicoclaw agent -m \"こんにちは\"\n```\n\n</details>\n\n<details>\n<summary><b>完全な設定例</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 API Key 比較表\n\n| サービス         | Pricing                  | ユースケース                          |\n| ---------------- | ------------------------ | ------------------------------------- |\n| **OpenRouter**   | Free: 200K tokens/month  | マルチモデル (Claude, GPT-4 など)     |\n| **Volcengine CodingPlan** | ¥9.9/first month | 中国ユーザー向け、複数の SOTA モデル (Doubao, DeepSeek など) |\n| **Zhipu**        | Free: 200K tokens/month  | 中国ユーザー向け                      |\n| **Brave Search** | $5/1000 queries          | Web 検索機能                          |\n| **SearXNG**      | Free (self-hosted)       | プライバシー重視のメタ検索 (70+ engines) |\n| **Groq**         | Free tier available      | 高速推論 (Llama, Mixtral)             |\n| **Cerebras**     | Free tier available      | 高速推論 (Llama, Qwen など)           |\n| **LongCat**      | Free: up to 5M tokens/day | 高速推論                             |\n| **ModelScope**   | Free: 2000 requests/day  | 推論 (Qwen, GLM, DeepSeek など)       |\n\n---\n\n<div align=\"center\">\n  <img src=\"assets/logo.jpg\" alt=\"PicoClaw Meme\" width=\"512\">\n</div>\n"
  },
  {
    "path": "docs/ja/spawn-tasks.md",
    "content": "# 🔄 非同期タスクと Spawn\n\n> [README](../../README.ja.md) に戻る\n\n### Spawn を使用した非同期タスク\n\n長時間実行タスク（Web 検索、API 呼び出し）には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します：\n\n```markdown\n# Periodic Tasks\n\n## Quick Tasks (respond directly)\n\n- Report current time\n\n## Long Tasks (use spawn for async)\n\n- Search the web for AI news and summarize\n- Check email and report important messages\n```\n\n**主な動作：**\n\n| 特性             | 説明                                             |\n| ---------------- | ------------------------------------------------ |\n| **spawn**        | 非同期サブ Agent を作成、メインハートビートをブロックしない |\n| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし |\n| **message tool** | サブ Agent は message ツールでユーザーと直接通信   |\n| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む         |\n\n#### サブ Agent の通信の仕組み\n\n```\nハートビートトリガー (Heartbeat triggers)\n    ↓\nAgent が HEARTBEAT.md を読み取り\n    ↓\n長時間タスクの場合: サブ Agent を spawn\n    ↓                           ↓\n次のタスクに進む             サブ Agent が独立して作業\n    ↓                           ↓\nすべてのタスク完了           サブ Agent が \"message\" ツールを使用\n    ↓                           ↓\nHEARTBEAT_OK を応答          ユーザーが直接結果を受信\n```\n\nサブ Agent はツール（message、web_search など）にアクセスでき、メイン Agent を経由せずにユーザーと独立して通信できます。\n\n**設定：**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| オプション | デフォルト値 | 説明                           |\n| ---------- | ------------ | ------------------------------ |\n| `enabled`  | `true`       | ハートビートの有効/無効        |\n| `interval` | `30`         | チェック間隔（分単位、最小: 5）|\n\n**環境変数:**\n\n- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化\n- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更\n"
  },
  {
    "path": "docs/ja/tools_configuration.md",
    "content": "# 🔧 ツール設定\n\n> [README](../../README.ja.md) に戻る\n\nPicoClaw のツール設定は `config.json` の `tools` フィールドにあります。\n\n## ディレクトリ構造\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Web ツール\n\nWeb ツールはウェブ検索とフェッチに使用されます。\n\n### Web Fetcher\nウェブページコンテンツの取得と処理に関する一般設定。\n\n| 設定項目            | 型     | デフォルト    | 説明                                                                                   |\n|---------------------|--------|---------------|----------------------------------------------------------------------------------------|\n| `enabled`           | bool   | true          | ウェブページ取得機能を有効にする。                                                     |\n| `fetch_limit_bytes` | int    | 10485760      | 取得するウェブページペイロードの最大サイズ（バイト単位、デフォルトは10MB）。            |\n| `format`            | string | \"plaintext\"   | 取得コンテンツの出力形式。オプション：`plaintext` または `markdown`（推奨）。           |\n\n### Brave\n\n| 設定項目      | 型     | デフォルト | 説明                  |\n|---------------|--------|------------|-----------------------|\n| `enabled`     | bool   | false      | Brave 検索を有効にする |\n| `api_key`     | string | -          | Brave Search API キー  |\n| `max_results` | int    | 5          | 最大結果数            |\n\n### DuckDuckGo\n\n| 設定項目      | 型   | デフォルト | 説明                      |\n|---------------|------|------------|---------------------------|\n| `enabled`     | bool | true       | DuckDuckGo 検索を有効にする |\n| `max_results` | int  | 5          | 最大結果数                |\n\n### Perplexity\n\n| 設定項目      | 型     | デフォルト | 説明                      |\n|---------------|--------|------------|---------------------------|\n| `enabled`     | bool   | false      | Perplexity 検索を有効にする |\n| `api_key`     | string | -          | Perplexity API キー        |\n| `max_results` | int    | 5          | 最大結果数                |\n\n## Exec ツール\n\nExec ツールはシェルコマンドの実行に使用されます。\n\n| 設定項目               | 型    | デフォルト | 説明                               |\n|------------------------|-------|------------|------------------------------------|\n| `enabled`              | bool  | true       | Exec ツールを有効にする            |\n| `enable_deny_patterns` | bool  | true       | デフォルトの危険コマンドブロックを有効にする |\n| `custom_deny_patterns` | array | []         | カスタム拒否パターン（正規表現）   |\n\n### Exec ツールの無効化\n\n`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します：\n\n**設定ファイル経由：**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**環境変数経由：**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **注意：** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。\n\n### 機能\n\n- **`enable_deny_patterns`**：`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします\n- **`custom_deny_patterns`**：カスタム拒否正規表現パターンを追加します。一致するコマンドはブロックされます\n\n### デフォルトでブロックされるコマンドパターン\n\nデフォルトで、PicoClaw は以下の危険なコマンドをブロックします：\n\n- 削除コマンド：`rm -rf`、`del /f/q`、`rmdir /s`\n- ディスク操作：`format`、`mkfs`、`diskpart`、`dd if=`、`/dev/sd*` への書き込み\n- システム操作：`shutdown`、`reboot`、`poweroff`\n- コマンド置換：`$()`、`${}`、バッククォート\n- シェルへのパイプ：`| sh`、`| bash`\n- 権限昇格：`sudo`、`chmod`、`chown`\n- プロセス制御：`pkill`、`killall`、`kill -9`\n- リモート操作：`curl | sh`、`wget | sh`、`ssh`\n- パッケージ管理：`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user`\n- コンテナ：`docker run`、`docker exec`\n- Git：`git push`、`git force`\n- その他：`eval`、`source *.sh`\n\n### 既知のアーキテクチャ上の制限\n\nexec ガードは PicoClaw に送信されたトップレベルのコマンドのみを検証します。そのコマンドの実行開始後にビルドツールやスクリプトが生成する子プロセスを再帰的に検査することは**ありません**。\n\n初期コマンドが許可された後、直接コマンドガードをバイパスできるワークフローの例：\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\nこれは、明らかに危険な直接コマンドのブロックには有用ですが、未レビューのビルドパイプラインに対する完全なサンドボックスでは**ありません**。脅威モデルにワークスペース内の信頼できないコードが含まれる場合は、コンテナ、VM、またはビルド・実行コマンドに対する承認フローなど、より強力な分離を使用してください。\n\n### 設定例\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Cron ツール\n\nCron ツールは定期タスクのスケジューリングに使用されます。\n\n| 設定項目               | 型  | デフォルト | 説明                                    |\n|------------------------|-----|------------|-----------------------------------------|\n| `exec_timeout_minutes` | int | 5          | 実行タイムアウト（分）、0 は無制限      |\n\n## MCP ツール\n\nMCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。\n\n### ツールディスカバリ（遅延読み込み）\n\n複数の MCP サーバーに接続する場合、数百のツールを同時に公開すると LLM のコンテキストウィンドウを使い果たし、API コストが増加する可能性があります。**Discovery** 機能は、MCP ツールをデフォルトで*非表示*にすることでこの問題を解決します。\n\nすべてのツールを読み込む代わりに、LLM には軽量な検索ツール（BM25 キーワードマッチングまたは正規表現を使用）が提供されます。LLM が特定の機能を必要とする場合、非表示のライブラリを検索します。一致するツールは一時的に「アンロック」され、設定されたターン数（`ttl`）の間コンテキストに注入されます。\n\n### グローバル設定\n\n| 設定項目    | 型     | デフォルト | 説明                                 |\n|-------------|--------|------------|--------------------------------------|\n| `enabled`   | bool   | false      | MCP 統合をグローバルに有効にする     |\n| `discovery` | object | `{}`       | ツールディスカバリ設定（下記参照）   |\n| `servers`   | object | `{}`       | サーバー名からサーバー設定へのマップ |\n\n### Discovery 設定（`discovery`）\n\n| 設定項目             | 型   | デフォルト | 説明                                                                                                          |\n|----------------------|------|------------|---------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false      | true の場合、MCP ツールは非表示になり、検索を通じてオンデマンドで読み込まれます。false の場合、すべてのツールが読み込まれます |\n| `ttl`                | int  | 5          | 発見されたツールがアンロック状態を維持する会話ターン数                                                        |\n| `max_search_results` | int  | 5          | 検索クエリごとに返されるツールの最大数                                                                        |\n| `use_bm25`           | bool | true       | 自然言語/キーワード検索ツール（`tool_search_tool_bm25`）を有効にする。**警告**：正規表現検索よりリソースを消費します |\n| `use_regex`          | bool | false      | 正規表現パターン検索ツール（`tool_search_tool_regex`）を有効にする                                            |\n\n> **注意：** `discovery.enabled` が `true` の場合、少なくとも1つの検索エンジン（`use_bm25` または `use_regex`）を有効にする**必要があります**。\n> そうしないとアプリケーションの起動に失敗します。\n\n### サーバーごとの設定\n\n| 設定項目   | 型     | 必須     | 説明                                   |\n|------------|--------|----------|----------------------------------------|\n| `enabled`  | bool   | はい     | この MCP サーバーを有効にする          |\n| `type`     | string | いいえ   | トランスポートタイプ：`stdio`、`sse`、`http` |\n| `command`  | string | stdio    | stdio トランスポートの実行コマンド     |\n| `args`     | array  | いいえ   | stdio トランスポートのコマンド引数     |\n| `env`      | object | いいえ   | stdio プロセスの環境変数               |\n| `env_file` | string | いいえ   | stdio プロセスの環境ファイルパス       |\n| `url`      | string | sse/http | `sse`/`http` トランスポートのエンドポイント URL |\n| `headers`  | object | いいえ   | `sse`/`http` トランスポートの HTTP ヘッダー |\n\n### トランスポートの動作\n\n- `type` を省略した場合、トランスポートは自動検出されます：\n    - `url` が設定されている → `sse`\n    - `command` が設定されている → `stdio`\n- `http` と `sse` はどちらも `url` + オプションの `headers` を使用します。\n- `env` と `env_file` は `stdio` サーバーにのみ適用されます。\n\n### 設定例\n\n#### 1) Stdio MCP サーバー\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) リモート SSE/HTTP MCP サーバー\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) ツールディスカバリを有効にした大規模 MCP セットアップ\n\n*この例では、LLM は `tool_search_tool_bm25` のみを認識します。ユーザーからリクエストがあった場合にのみ、Github や Postgres のツールを動的に検索してアンロックします。*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n## Skills ツール\n\nSkills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。\n\n### レジストリ\n\n| 設定項目                           | 型     | デフォルト           | 説明                                         |\n|------------------------------------|--------|----------------------|----------------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                 | ClawHub レジストリを有効にする               |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai` | ClawHub ベース URL                           |\n| `registries.clawhub.auth_token`    | string | `\"\"`                 | より高いレート制限のためのオプションの Bearer トークン |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`     | 検索 API パス                                |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`     | Skills API パス                              |\n| `registries.clawhub.download_path` | string | `/api/v1/download`   | ダウンロード API パス                        |\n\n### 設定例\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## 環境変数\n\nすべての設定オプションは `PICOCLAW_TOOLS_<SECTION>_<KEY>` 形式の環境変数で上書きできます：\n\n例：\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\n注意：ネストされたマップ形式の設定（例：`tools.mcp.servers.<name>.*`）は環境変数ではなく `config.json` で設定します。\n"
  },
  {
    "path": "docs/ja/troubleshooting.md",
    "content": "# 🐛 トラブルシューティング\n\n> [README](../../README.ja.md) に戻る\n\n## \"model ... not found in model_list\" または OpenRouter \"free is not a valid model ID\"\n\n**症状：** 以下のいずれかのエラーが表示されます：\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter が 400 を返す：`\"free is not a valid model ID\"`\n\n**原因：** `model_list` エントリの `model` フィールドは API に送信される値です。OpenRouter では省略形ではなく、**完全な**モデル ID を使用する必要があります。\n\n- **誤り：** `\"model\": \"free\"` → OpenRouter は `free` を受け取り、拒否します。\n- **正しい：** `\"model\": \"openrouter/free\"` → OpenRouter は `openrouter/free` を受け取ります（自動無料枠ルーティング）。\n\n**修正方法：** `~/.picoclaw/config.json`（またはお使いの設定パス）で：\n\n1. **agents.defaults.model** は `model_list` 内の `model_name` と一致する必要があります（例：`\"openrouter-free\"`）。\n2. そのエントリの **model** は有効な OpenRouter モデル ID である必要があります。例：\n   - `\"openrouter/free\"` – 自動無料枠\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\n設定例：\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\nキーは [OpenRouter Keys](https://openrouter.ai/keys) で取得できます。\n"
  },
  {
    "path": "docs/migration/model-list-migration.md",
    "content": "# Migration Guide: From `providers` to `model_list`\n\nThis guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.\n\n## Why Migrate?\n\nThe new `model_list` configuration offers several advantages:\n\n- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only\n- **Load balancing**: Configure multiple endpoints for the same model\n- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.\n- **Cleaner configuration**: Model-centric instead of vendor-centric\n\n## Timeline\n\n| Version | Status |\n|---------|--------|\n| v1.x | `model_list` introduced, `providers` deprecated but functional |\n| v1.x+1 | Prominent deprecation warnings, migration tool available |\n| v2.0 | `providers` configuration removed |\n\n## Before and After\n\n### Before: Legacy `providers` Configuration\n\n```json\n{\n  \"providers\": {\n    \"openai\": {\n      \"api_key\": \"sk-your-openai-key\",\n      \"api_base\": \"https://api.openai.com/v1\"\n    },\n    \"anthropic\": {\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    \"deepseek\": {\n      \"api_key\": \"sk-your-deepseek-key\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"openai\",\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n### After: New `model_list` Configuration\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\",\n      \"api_base\": \"https://api.openai.com/v1\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"deepseek\",\n      \"model\": \"deepseek/deepseek-chat\",\n      \"api_key\": \"sk-your-deepseek-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt4\"\n    }\n  }\n}\n```\n\n## Protocol Prefixes\n\nThe `model` field uses a protocol prefix format: `[protocol/]model-identifier`\n\n| Prefix | Description | Example |\n|--------|-------------|---------|\n| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |\n| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |\n| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |\n| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |\n| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |\n| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |\n| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |\n| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |\n| `groq/` | Groq API | `groq/llama-3.1-70b` |\n| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |\n| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |\n| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |\n| `zhipu/` | Zhipu AI | `zhipu/glm-4` |\n| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |\n| `ollama/` | Ollama (local) | `ollama/llama3` |\n| `vllm/` | vLLM (local) | `vllm/my-model` |\n| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |\n| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |\n| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |\n\n**Note**: If no prefix is specified, `openai/` is used as the default.\n\n## ModelConfig Fields\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `model_name` | Yes | User-facing alias for the model |\n| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |\n| `api_base` | No | API endpoint URL |\n| `api_key` | No* | API authentication key |\n| `proxy` | No | HTTP proxy URL |\n| `auth_method` | No | Authentication method: `oauth`, `token` |\n| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |\n| `rpm` | No | Requests per minute limit |\n| `max_tokens_field` | No | Field name for max tokens |\n| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |\n\n*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.\n\n## Load Balancing\n\nConfigure multiple endpoints for the same model to distribute load:\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-key1\",\n      \"api_base\": \"https://api1.example.com/v1\"\n    },\n    {\n      \"model_name\": \"gpt4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-key2\",\n      \"api_base\": \"https://api2.example.com/v1\"\n    },\n    {\n      \"model_name\": \"gpt4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-key3\",\n      \"api_base\": \"https://api3.example.com/v1\"\n    }\n  ]\n}\n```\n\nWhen you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.\n\n## Adding a New OpenAI-Compatible Provider\n\nWith `model_list`, adding a new provider requires zero code changes:\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"my-custom-llm\",\n      \"model\": \"openai/my-model-v1\",\n      \"api_key\": \"your-api-key\",\n      \"api_base\": \"https://api.your-provider.com/v1\"\n    }\n  ]\n}\n```\n\nJust specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.\n\n## Backward Compatibility\n\nDuring the migration period, your existing `providers` configuration will continue to work:\n\n1. If `model_list` is empty and `providers` has data, the system auto-converts internally\n2. A deprecation warning is logged: `\"providers config is deprecated, please migrate to model_list\"`\n3. All existing functionality remains unchanged\n\n## Migration Checklist\n\n- [ ] Identify all providers you're currently using\n- [ ] Create `model_list` entries for each provider\n- [ ] Use appropriate protocol prefixes\n- [ ] Update `agents.defaults.model` to reference the new `model_name`\n- [ ] Test that all models work correctly\n- [ ] Remove or comment out the old `providers` section\n\n## Troubleshooting\n\n### Model not found error\n\n```\nmodel \"xxx\" not found in model_list or providers\n```\n\n**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.\n\n### Unknown protocol error\n\n```\nunknown protocol \"xxx\" in model \"xxx/model-name\"\n```\n\n**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.\n\n### Missing API key error\n\n```\napi_key or api_base is required for HTTP-based protocol \"xxx\"\n```\n\n**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.\n\n## Need Help?\n\n- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)\n- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal\n"
  },
  {
    "path": "docs/providers.md",
    "content": "# 🔌 Providers & Model Configuration\n\n> Back to [README](../README.md)\n\n### Providers\n\n> [!NOTE]\n> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.\n\n| Provider     | Purpose                                 | Get API Key                                                  |\n| ------------ | --------------------------------------- | ------------------------------------------------------------ |\n| `gemini`     | LLM (Gemini direct)                     | [aistudio.google.com](https://aistudio.google.com)           |\n| `zhipu`      | LLM (Zhipu direct)                      | [bigmodel.cn](https://bigmodel.cn)                           |\n| `volcengine` | LLM(Volcengine direct)                  | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                 |\n| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai)                       |\n| `anthropic`  | LLM (Claude direct)                     | [console.anthropic.com](https://console.anthropic.com)       |\n| `openai`     | LLM (GPT direct)                        | [platform.openai.com](https://platform.openai.com)           |\n| `deepseek`   | LLM (DeepSeek direct)                   | [platform.deepseek.com](https://platform.deepseek.com)       |\n| `qwen`       | LLM (Qwen direct)                       | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`       | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com)                 |\n| `cerebras`   | LLM (Cerebras direct)                   | [cerebras.ai](https://cerebras.ai)                           |\n| `vivgrid`    | LLM (Vivgrid direct)                    | [vivgrid.com](https://vivgrid.com)                           |\n| `nvidia`     | LLM (NVIDIA NIM)                        | [build.nvidia.com](https://build.nvidia.com)                 |\n| `moonshot`   | LLM (Kimi/Moonshot direct)              | [platform.moonshot.cn](https://platform.moonshot.cn)         |\n| `minimax`    | LLM (Minimax direct)                    | [platform.minimaxi.com](https://platform.minimaxi.com)      |\n| `avian`      | LLM (Avian direct)                      | [avian.io](https://avian.io)                                 |\n| `mistral`    | LLM (Mistral direct)                    | [console.mistral.ai](https://console.mistral.ai)            |\n| `longcat`    | LLM (Longcat direct)                    | [longcat.ai](https://longcat.ai)                             |\n| `modelscope` | LLM (ModelScope direct)                 | [modelscope.cn](https://modelscope.cn)                       |\n\n### Model Configuration (model_list)\n\n> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**\n\nThis design also enables **multi-agent support** with flexible provider selection:\n\n- **Different agents, different providers**: Each agent can use its own LLM provider\n- **Model fallbacks**: Configure primary and fallback models for resilience\n- **Load balancing**: Distribute requests across multiple endpoints\n- **Centralized configuration**: Manage all providers in one place\n\n#### 📋 All Supported Vendors\n\n| Vendor              | `model` Prefix    | Default API Base                                    | Protocol  | API Key                                                          |\n| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |\n| **OpenAI**          | `openai/`         | `https://api.openai.com/v1`                         | OpenAI    | [Get Key](https://platform.openai.com)                           |\n| **Anthropic**       | `anthropic/`      | `https://api.anthropic.com/v1`                      | Anthropic | [Get Key](https://console.anthropic.com)                         |\n| **智谱 AI (GLM)**   | `zhipu/`          | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI    | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`       | `https://api.deepseek.com/v1`                       | OpenAI    | [Get Key](https://platform.deepseek.com)                         |\n| **Google Gemini**   | `gemini/`         | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI    | [Get Key](https://aistudio.google.com/api-keys)                  |\n| **Groq**            | `groq/`           | `https://api.groq.com/openai/v1`                    | OpenAI    | [Get Key](https://console.groq.com)                              |\n| **Moonshot**        | `moonshot/`       | `https://api.moonshot.cn/v1`                        | OpenAI    | [Get Key](https://platform.moonshot.cn)                          |\n| **通义千问 (Qwen)** | `qwen/`           | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI    | [Get Key](https://dashscope.console.aliyun.com)                  |\n| **NVIDIA**          | `nvidia/`         | `https://integrate.api.nvidia.com/v1`               | OpenAI    | [Get Key](https://build.nvidia.com)                              |\n| **Ollama**          | `ollama/`         | `http://localhost:11434/v1`                         | OpenAI    | Local (no key needed)                                            |\n| **OpenRouter**      | `openrouter/`     | `https://openrouter.ai/api/v1`                      | OpenAI    | [Get Key](https://openrouter.ai/keys)                            |\n| **LiteLLM Proxy**   | `litellm/`        | `http://localhost:4000/v1`                          | OpenAI    | Your LiteLLM proxy key                                            |\n| **VLLM**            | `vllm/`           | `http://localhost:8000/v1`                          | OpenAI    | Local                                                            |\n| **Cerebras**        | `cerebras/`       | `https://api.cerebras.ai/v1`                        | OpenAI    | [Get Key](https://cerebras.ai)                                   |\n| **VolcEngine (Doubao)** | `volcengine/`     | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI    | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                        |\n| **神算云**          | `shengsuanyun/`   | `https://router.shengsuanyun.com/api/v1`            | OpenAI    | -                                                                |\n| **BytePlus**        | `byteplus/`       | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI    | [Get Key](https://www.byteplus.com)                        |\n| **Vivgrid**         | `vivgrid/`        | `https://api.vivgrid.com/v1`                        | OpenAI    | [Get Key](https://vivgrid.com)                                   |\n| **LongCat**         | `longcat/`        | `https://api.longcat.chat/openai`                   | OpenAI    | [Get Key](https://longcat.chat/platform)                         |\n| **ModelScope (魔搭)**| `modelscope/`    | `https://api-inference.modelscope.cn/v1`            | OpenAI    | [Get Token](https://modelscope.cn/my/tokens)                     |\n| **Azure OpenAI**    | `azure/`          | `https://{resource}.openai.azure.com`               | Azure     | [Get Key](https://portal.azure.com)                              |\n| **Antigravity**     | `antigravity/`    | Google Cloud                                        | Custom    | OAuth only                                                       |\n| **GitHub Copilot**  | `github-copilot/` | `localhost:4321`                                    | gRPC      | -                                                                |\n\n#### Basic Configuration\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### Vendor-Specific Examples\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**VolcEngine (Doubao)**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (with API key)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"api_key\": \"sk-ant-your-key\"\n}\n```\n\n> Run `picoclaw auth login --provider anthropic` to paste your API token.\n\n**Anthropic Messages API (native format)**\n\nFor direct Anthropic API access or custom endpoints that only support Anthropic's native message format:\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> Use `anthropic-messages` protocol when:\n> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)\n> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format\n> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)\n>\n> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.\n\n**Ollama (local)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**Custom Proxy/API**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nPicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.\n\n#### Load Balancing\n\nConfigure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### Migration from Legacy `providers` Config\n\nThe old `providers` configuration is **deprecated** but still supported for backward compatibility.\n\n**Old Config (deprecated):**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**New Config (recommended):**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\nFor detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).\n\n### Provider Architecture\n\nPicoClaw routes providers by protocol family:\n\n- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.\n- Anthropic protocol: Claude-native API behavior.\n- Codex/OAuth path: OpenAI OAuth/token authentication route.\n\nThis keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).\n\n<details>\n<summary><b>Zhipu</b></summary>\n\n**1. Get API key and base URL**\n\n* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)\n\n**2. Configure**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. Run**\n\n```bash\npicoclaw agent -m \"Hello\"\n```\n\n</details>\n\n<details>\n<summary><b>Full config example</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 API Key Comparison\n\n| Service          | Pricing                  | Use Case                              |\n| ---------------- | ------------------------ | ------------------------------------- |\n| **OpenRouter**   | Free: 200K tokens/month  | Multiple models (Claude, GPT-4, etc.) |\n| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |\n| **Zhipu**        | Free: 200K tokens/month  | Suitable for Chinese users                |\n| **Brave Search** | $5/1000 queries          | Web search functionality              |\n| **SearXNG**      | Free (self-hosted)       | Privacy-focused metasearch (70+ engines) |\n| **Groq**         | Free tier available      | Fast inference (Llama, Mixtral)       |\n| **Cerebras**     | Free tier available      | Fast inference (Llama, Qwen, etc.)    |\n| **LongCat**      | Free: up to 5M tokens/day | Fast inference                       |\n| **ModelScope**   | Free: 2000 requests/day  | Inference (Qwen, GLM, DeepSeek, etc.) |\n\n---\n\n<div align=\"center\">\n  <img src=\"assets/logo.jpg\" alt=\"PicoClaw Meme\" width=\"512\">\n</div>\n"
  },
  {
    "path": "docs/pt-br/chat-apps.md",
    "content": "# 💬 Configuração de Aplicativos de Chat\n\n> Voltar ao [README](../../README.pt-br.md)\n\n## 💬 Aplicativos de Chat\n\nConverse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam\n\n> **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado.\n\n| Channel      | Setup                              |\n| ------------ | ---------------------------------- |\n| **Telegram** | Easy (just a token)                |\n| **Discord**  | Easy (bot token + intents)         |\n| **WhatsApp** | Easy (native: QR scan; or bridge URL) |\n| **Matrix**   | Medium (homeserver + bot access token) |\n| **QQ**       | Easy (AppID + AppSecret)           |\n| **DingTalk** | Medium (app credentials)           |\n| **LINE**     | Medium (credentials + webhook URL) |\n| **WeCom AI Bot** | Medium (Token + AES key)       |\n| **Feishu**   | Medium (App ID + Secret, WebSocket mode) |\n| **Slack**    | Medium (Bot token + App token) |\n| **IRC**      | Medium (server + TLS config)   |\n| **OneBot**   | Medium (QQ via OneBot protocol) |\n| **MaixCam**  | Easy (Sipeed hardware integration) |\n| **Pico**     | Native PicoClaw protocol           |\n\n<details>\n<summary><b>Telegram</b> (Recomendado)</summary>\n\n**1. Criar um bot**\n\n* Abra o Telegram, pesquise `@BotFather`\n* Envie `/newbot`, siga as instruções\n* Copie o token\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> Obtenha seu ID de usuário com `@userinfobot` no Telegram.\n\n**3. Executar**\n\n```bash\npicoclaw gateway\n```\n\n**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)**\n\nO PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados.\nO registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor.\n\nSe o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano.\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Criar um bot**\n\n* Acesse <https://discord.com/developers/applications>\n* Crie um aplicativo → Bot → Add Bot\n* Copie o token do bot\n\n**2. Habilitar intents**\n\n* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT**\n* (Opcional) Habilite **SERVER MEMBERS INTENT** se planeja usar listas de permissão baseadas em dados de membros\n\n**3. Obter seu User ID**\n* Configurações do Discord → Avançado → habilite **Developer Mode**\n* Clique com o botão direito no seu avatar → **Copy User ID**\n\n**4. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Convidar o bot**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* Abra a URL de convite gerada e adicione o bot ao seu servidor\n\n**Opcional: Modo de ativação em grupo**\n\nPor padrão, o bot responde a todas as mensagens em um canal do servidor. Para restringir respostas apenas a @menções, adicione:\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nVocê também pode ativar por prefixos de palavras-chave (ex.: `!bot`):\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. Executar**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b> (nativo via whatsmeow)</summary>\n\nO PicoClaw pode se conectar ao WhatsApp de duas formas:\n\n- **Nativo (recomendado):** In-process usando [whatsmeow](https://github.com/tulir/whatsmeow). Sem bridge separado. Defina `\"use_native\": true` e deixe `bridge_url` vazio. Na primeira execução, escaneie o QR code com o WhatsApp (Dispositivos Vinculados). A sessão é armazenada no seu workspace (ex.: `workspace/whatsapp/`). O canal nativo é **opcional** para manter o binário padrão pequeno; compile com `-tags whatsapp_native` (ex.: `make build-whatsapp-native` ou `go build -tags whatsapp_native ./cmd/...`).\n- **Bridge:** Conecte-se a um bridge WebSocket externo. Defina `bridge_url` (ex.: `ws://localhost:3001`) e mantenha `use_native` como false.\n\n**Configurar (nativo)**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\nSe `session_store_path` estiver vazio, a sessão é armazenada em `<workspace>/whatsapp/`. Execute `picoclaw gateway`; na primeira execução, escaneie o QR code impresso no terminal com WhatsApp → Dispositivos Vinculados.\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Criar um bot**\n\n- Acesse a [QQ Open Platform](https://q.qq.com/#)\n- Crie um aplicativo → Obtenha **AppID** e **AppSecret**\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.\n\n**3. Executar**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Criar um bot**\n\n* Acesse a [Open Platform](https://open.dingtalk.com/)\n* Crie um aplicativo interno\n* Copie o Client ID e o Client Secret\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique IDs de usuário DingTalk para restringir o acesso.\n\n**3. Executar**\n\n```bash\npicoclaw gateway\n```\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Preparar conta do bot**\n\n* Use seu homeserver preferido (ex.: `https://matrix.org` ou auto-hospedado)\n* Crie um usuário bot e obtenha seu access token\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Executar**\n\n```bash\npicoclaw gateway\n```\n\nPara opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](docs/channels/matrix/README.md).\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. Criar uma Conta Oficial LINE**\n\n- Acesse o [LINE Developers Console](https://developers.line.biz/)\n- Crie um provider → Crie um canal Messaging API\n- Copie o **Channel Secret** e o **Channel Access Token**\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> O webhook do LINE é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`).\n\n**3. Configurar URL do Webhook**\n\nO LINE requer HTTPS para webhooks. Use um proxy reverso ou túnel:\n\n```bash\n# Exemplo com ngrok (porta padrão do gateway é 18790)\nngrok http 18790\n```\n\nEm seguida, defina a URL do Webhook no LINE Developers Console como `https://your-domain/webhook/line` e habilite **Use webhook**.\n\n**4. Executar**\n\n```bash\npicoclaw gateway\n```\n\n> Em chats de grupo, o bot responde apenas quando @mencionado. As respostas citam a mensagem original.\n\n</details>\n\n<details>\n<summary><b>WeCom (企业微信)</b></summary>\n\nO PicoClaw suporta três tipos de integração WeCom:\n\n**Opção 1: WeCom Bot (Bot)** - Configuração mais fácil, suporta chats de grupo\n**Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado\n**Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado\n\nVeja o [Guia de Configuração do WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas de configuração.\n\n**Configuração Rápida - WeCom Bot:**\n\n**1. Criar um bot**\n\n* Acesse o Console de Administração WeCom → Chat de Grupo → Adicionar Bot de Grupo\n* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> O webhook do WeCom é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`).\n\n**Configuração Rápida - WeCom App:**\n\n**1. Criar um aplicativo**\n\n* Acesse o Console de Administração WeCom → Gerenciamento de Apps → Criar App\n* Copie o **AgentId** e o **Secret**\n* Acesse a página \"Minha Empresa\", copie o **CorpID**\n\n**2. Configurar recebimento de mensagens**\n\n* Nos detalhes do App, clique em \"Receber Mensagem\" → \"Configurar API\"\n* Defina a URL como `http://your-server:18790/webhook/wecom-app`\n* Gere o **Token** e o **EncodingAESKey**\n\n**3. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. Executar**\n\n```bash\npicoclaw gateway\n```\n\n> **Nota**: Os callbacks de webhook do WeCom são servidos na porta do Gateway (padrão 18790). Use um proxy reverso para HTTPS.\n\n**Configuração Rápida - WeCom AI Bot:**\n\n**1. Criar um AI Bot**\n\n* Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot\n* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot`\n* Copie o **Token** e clique em \"Gerar Aleatoriamente\" para o **EncodingAESKey**\n\n**2. Configurar**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"Hello! How can I help you?\"\n    }\n  }\n}\n```\n\n**3. Executar**\n\n```bash\npicoclaw gateway\n```\n\n> **Nota**: O WeCom AI Bot usa protocolo de streaming pull — sem preocupações com timeout de resposta. Tarefas longas (>30 segundos) mudam automaticamente para entrega via `response_url` push.\n\n</details>\n"
  },
  {
    "path": "docs/pt-br/configuration.md",
    "content": "# ⚙️ Guia de Configuração\n\n> Voltar ao [README](../../README.pt-br.md)\n\n## ⚙️ Configuração\n\nArquivo de configuração: `~/.picoclaw/config.json`\n\n### Variáveis de Ambiente\n\nVocê pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou execução do picoclaw como serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.\n\n| Variável          | Descrição                                                                                                                             | Caminho Padrão              |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso indica diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | Substitui o diretório raiz para dados do picoclaw. Isso altera o local padrão do `workspace` e outros diretórios de dados.          | `~/.picoclaw`             |\n\n**Exemplos:**\n\n```bash\n# Executar picoclaw usando um arquivo de configuração específico\n# O caminho do workspace será lido de dentro desse arquivo de configuração\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# Executar picoclaw com todos os dados armazenados em /opt/picoclaw\n# A configuração será carregada do padrão ~/.picoclaw/config.json\n# O workspace será criado em /opt/picoclaw/workspace\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# Usar ambos para uma configuração totalmente personalizada\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### Layout do Workspace\n\nO PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`):\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # Sessões de conversa e histórico\n├── memory/           # Memória de longo prazo (MEMORY.md)\n├── state/            # Estado persistente (último canal, etc.)\n├── cron/             # Banco de dados de tarefas agendadas\n├── skills/           # Skills personalizadas\n├── AGENT.md          # Guia de comportamento do agente\n├── HEARTBEAT.md      # Prompts de tarefas periódicas (verificados a cada 30 min)\n├── IDENTITY.md       # Identidade do agente\n├── SOUL.md           # Alma do agente\n└── USER.md           # Preferências do usuário\n```\n\n> **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição.\n\n### Fontes de Skills\n\nPor padrão, as skills são carregadas de:\n\n1. `~/.picoclaw/workspace/skills` (workspace)\n2. `~/.picoclaw/skills` (global)\n3. `<current-working-directory>/skills` (builtin)\n\nPara configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com:\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### Política Unificada de Execução de Comandos\n\n- Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`.\n- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente os comandos suportados na inicialização.\n- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM.\n- Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento.\n\n### 🔒 Sandbox de Segurança\n\nO PicoClaw é executado em um ambiente sandbox por padrão. O agente só pode acessar arquivos e executar comandos dentro do workspace configurado.\n\n#### Configuração Padrão\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| Opção                   | Padrão                  | Descrição                                 |\n| ----------------------- | ----------------------- | ----------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Diretório de trabalho do agente           |\n| `restrict_to_workspace` | `true`                  | Restringir acesso a arquivos/comandos ao workspace |\n\n#### Ferramentas Protegidas\n\nQuando `restrict_to_workspace: true`, as seguintes ferramentas são isoladas:\n\n| Ferramenta    | Função           | Restrição                              |\n| ------------- | ---------------- | -------------------------------------- |\n| `read_file`   | Ler arquivos     | Apenas arquivos dentro do workspace    |\n| `write_file`  | Escrever arquivos| Apenas arquivos dentro do workspace    |\n| `list_dir`    | Listar diretórios| Apenas diretórios dentro do workspace  |\n| `edit_file`   | Editar arquivos  | Apenas arquivos dentro do workspace    |\n| `append_file` | Anexar a arquivos| Apenas arquivos dentro do workspace    |\n| `exec`        | Executar comandos| Caminhos de comando devem estar dentro do workspace |\n\n#### Proteção Adicional do Exec\n\nMesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:\n\n* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa\n* `format`, `mkfs`, `diskpart` — Formatação de disco\n* `dd if=` — Imagem de disco\n* Escrita em `/dev/sd[a-z]` — Escritas diretas em disco\n* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema\n* Fork bomb `:(){ :|:& };:`\n\n### Controle de Acesso a Arquivos\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |\n| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |\n\n### Segurança do Exec\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) |\n| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow |\n\n> **Nota de Segurança:** A proteção contra symlinks é habilitada por padrão — todos os caminhos de arquivo são resolvidos através de `filepath.EvalSymlinks` antes da correspondência com a whitelist, prevenindo ataques de escape via symlink.\n\n#### Limitação Conhecida: Processos Filhos de Ferramentas de Build\n\nO guard de segurança do exec inspeciona apenas a linha de comando que o PicoClaw executa diretamente. Ele não inspeciona recursivamente processos filhos gerados por ferramentas de desenvolvimento permitidas como `make`, `go run`, `cargo`, `npm run` ou scripts de build personalizados.\n\nIsso significa que um comando de nível superior ainda pode compilar ou executar outros binários após passar pela verificação inicial do guard. Na prática, trate scripts de build, Makefiles, scripts de pacotes e binários gerados como código executável que precisa do mesmo nível de revisão que um comando shell direto.\n\nPara ambientes de maior risco:\n\n* Revise scripts de build antes da execução.\n* Prefira aprovação/revisão manual para fluxos de trabalho de compilação e execução.\n* Execute o PicoClaw dentro de um contêiner ou VM se precisar de isolamento mais forte do que o guard integrado oferece.\n\n#### Exemplos de Erro\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### Desabilitando Restrições (Risco de Segurança)\n\nSe você precisar que o agente acesse caminhos fora do workspace:\n\n**Método 1: Arquivo de configuração**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**Método 2: Variável de ambiente**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cautela apenas em ambientes controlados.\n\n#### Consistência do Limite de Segurança\n\nA configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução:\n\n| Caminho de Execução | Limite de Segurança          |\n| -------------------- | ---------------------------- |\n| Main Agent           | `restrict_to_workspace` ✅   |\n| Subagent / Spawn     | Herda a mesma restrição ✅   |\n| Heartbeat tasks      | Herda a mesma restrição ✅   |\n\nTodos os caminhos compartilham a mesma restrição de workspace — não há como contornar o limite de segurança através de subagentes ou tarefas agendadas.\n\n### Heartbeat (Tarefas Periódicas)\n\nO PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:\n\n```markdown\n# Tarefas Periódicas\n\n- Verificar meu e-mail para mensagens importantes\n- Revisar meu calendário para eventos próximos\n- Verificar a previsão do tempo\n```\n\nO agente lerá este arquivo a cada 30 minutos (configurável) e executará quaisquer tarefas usando as ferramentas disponíveis.\n\n#### Tarefas Assíncronas com Spawn\n\nPara tarefas de longa duração (busca na web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:\n\n```markdown\n# Tarefas Periódicas\n```\n"
  },
  {
    "path": "docs/pt-br/docker.md",
    "content": "# 🐳 Docker e Início Rápido\n\n> Voltar ao [README](../../README.pt-br.md)\n\n## 🐳 Docker Compose\n\nVocê também pode executar o PicoClaw usando Docker Compose sem instalar nada localmente.\n\n```bash\n# 1. Clone este repositório\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# O contêiner exibe \"First-run setup complete.\" e para.\n\n# 3. Configure suas chaves de API\nvim docker/data/config.json   # Set provider API keys, bot tokens, etc.\n\n# 4. Iniciar\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Usuários Docker**: Por padrão, o Gateway escuta em `127.0.0.1`, que não é acessível a partir do host. Se você precisar acessar os endpoints de saúde ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` no seu ambiente ou atualize o `config.json`.\n\n```bash\n# 5. Verificar logs\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. Parar\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Modo Launcher (Console Web)\n\nA imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nAbra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente.\n\n> [!WARNING]\n> O console web ainda não suporta autenticação. Evite expô-lo na internet pública.\n\n### Modo Agent (One-shot)\n\n```bash\n# Fazer uma pergunta\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"What is 2+2?\"\n\n# Modo interativo\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### Atualização\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n### 🚀 Início Rápido\n\n> [!TIP]\n> Configure sua chave de API em `~/.picoclaw/config.json`. Obtenha chaves de API: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). A busca na web é opcional — obtenha gratuitamente uma [API Tavily](https://tavily.com) (1000 consultas gratuitas/mês) ou [API Brave Search](https://brave.com/search/api) (2000 consultas gratuitas/mês).\n\n**1. Inicializar**\n\n```bash\npicoclaw onboard\n```\n\n**2. Configurar** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alteração de código. Veja [Configuração de Modelos](#configuração-de-modelos-model_list) para detalhes.\n> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).\n\n**3. Obter chaves de API**\n\n* **Provedor LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Busca na Web** (opcional):\n  * [Brave Search](https://brave.com/search/api) - Pago ($5/1000 consultas, ~$5-6/mês)\n  * [Perplexity](https://www.perplexity.ai) - Busca com IA e interface de chat\n  * [SearXNG](https://github.com/searxng/searxng) - Metabuscador auto-hospedado (gratuito, sem necessidade de chave de API)\n  * [Tavily](https://tavily.com) - Otimizado para agentes de IA (1000 requisições/mês)\n  * DuckDuckGo - Fallback integrado (sem necessidade de chave de API)\n\n> **Nota**: Veja `config.example.json` para um modelo de configuração completo.\n\n**4. Conversar**\n\n```bash\npicoclaw agent -m \"What is 2+2?\"\n```\n\nPronto! Você tem um assistente de IA funcionando em 2 minutos.\n\n---\n"
  },
  {
    "path": "docs/pt-br/providers.md",
    "content": "# 🔌 Provedores e Configuração de Modelos\n\n> Voltar ao [README](../../README.pt-br.md)\n\n### Provedores\n\n> [!NOTE]\n> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.\n\n| Provider     | Purpose                                 | Get API Key                                                  |\n| ------------ | --------------------------------------- | ------------------------------------------------------------ |\n| `gemini`     | LLM (Gemini direct)                     | [aistudio.google.com](https://aistudio.google.com)           |\n| `zhipu`      | LLM (Zhipu direct)                      | [bigmodel.cn](https://bigmodel.cn)                           |\n| `volcengine` | LLM(Volcengine direct)                  | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                 |\n| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai)                       |\n| `anthropic`  | LLM (Claude direct)                     | [console.anthropic.com](https://console.anthropic.com)       |\n| `openai`     | LLM (GPT direct)                        | [platform.openai.com](https://platform.openai.com)           |\n| `deepseek`   | LLM (DeepSeek direct)                   | [platform.deepseek.com](https://platform.deepseek.com)       |\n| `qwen`       | LLM (Qwen direct)                       | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`       | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com)                 |\n| `cerebras`   | LLM (Cerebras direct)                   | [cerebras.ai](https://cerebras.ai)                           |\n| `vivgrid`    | LLM (Vivgrid direct)                    | [vivgrid.com](https://vivgrid.com)                           |\n| `moonshot`   | LLM (Kimi/Moonshot direct)              | [platform.moonshot.cn](https://platform.moonshot.cn)         |\n| `minimax`    | LLM (Minimax direct)                    | [platform.minimaxi.com](https://platform.minimaxi.com)      |\n| `avian`      | LLM (Avian direct)                      | [avian.io](https://avian.io)                                 |\n| `mistral`    | LLM (Mistral direct)                    | [console.mistral.ai](https://console.mistral.ai)            |\n| `longcat`    | LLM (Longcat direct)                    | [longcat.ai](https://longcat.ai)                             |\n| `modelscope` | LLM (ModelScope direct)                 | [modelscope.cn](https://modelscope.cn)                       |\n\n### Configuração de Modelos (model_list)\n\n> **Novidade?** O PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos provedores — **sem necessidade de alteração de código!**\n\nEste design também permite **suporte multi-agente** com seleção flexível de provedores:\n\n- **Agentes diferentes, provedores diferentes**: Cada agente pode usar seu próprio provedor LLM\n- **Fallback de modelos**: Configure modelos primários e de fallback para resiliência\n- **Balanceamento de carga**: Distribua requisições entre múltiplos endpoints\n- **Configuração centralizada**: Gerencie todos os provedores em um só lugar\n\n#### 📋 Todos os Vendors Suportados\n\n| Vendor              | `model` Prefix    | Default API Base                                    | Protocol  | API Key                                                          |\n| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |\n| **OpenAI**          | `openai/`         | `https://api.openai.com/v1`                         | OpenAI    | [Get Key](https://platform.openai.com)                           |\n| **Anthropic**       | `anthropic/`      | `https://api.anthropic.com/v1`                      | Anthropic | [Get Key](https://console.anthropic.com)                         |\n| **智谱 AI (GLM)**   | `zhipu/`          | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI    | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`       | `https://api.deepseek.com/v1`                       | OpenAI    | [Get Key](https://platform.deepseek.com)                         |\n| **Google Gemini**   | `gemini/`         | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI    | [Get Key](https://aistudio.google.com/api-keys)                  |\n| **Groq**            | `groq/`           | `https://api.groq.com/openai/v1`                    | OpenAI    | [Get Key](https://console.groq.com)                              |\n| **Moonshot**        | `moonshot/`       | `https://api.moonshot.cn/v1`                        | OpenAI    | [Get Key](https://platform.moonshot.cn)                          |\n| **通义千问 (Qwen)** | `qwen/`           | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI    | [Get Key](https://dashscope.console.aliyun.com)                  |\n| **NVIDIA**          | `nvidia/`         | `https://integrate.api.nvidia.com/v1`               | OpenAI    | [Get Key](https://build.nvidia.com)                              |\n| **Ollama**          | `ollama/`         | `http://localhost:11434/v1`                         | OpenAI    | Local (no key needed)                                            |\n| **OpenRouter**      | `openrouter/`     | `https://openrouter.ai/api/v1`                      | OpenAI    | [Get Key](https://openrouter.ai/keys)                            |\n| **LiteLLM Proxy**   | `litellm/`        | `http://localhost:4000/v1`                          | OpenAI    | Your LiteLLM proxy key                                            |\n| **VLLM**            | `vllm/`           | `http://localhost:8000/v1`                          | OpenAI    | Local                                                            |\n| **Cerebras**        | `cerebras/`       | `https://api.cerebras.ai/v1`                        | OpenAI    | [Get Key](https://cerebras.ai)                                   |\n| **VolcEngine (Doubao)** | `volcengine/`     | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI    | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                        |\n| **神算云**          | `shengsuanyun/`   | `https://router.shengsuanyun.com/api/v1`            | OpenAI    | -                                                                |\n| **BytePlus**        | `byteplus/`       | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI    | [Get Key](https://www.byteplus.com)                        |\n| **Vivgrid**         | `vivgrid/`        | `https://api.vivgrid.com/v1`                        | OpenAI    | [Get Key](https://vivgrid.com)                                   |\n| **LongCat**         | `longcat/`        | `https://api.longcat.chat/openai`                   | OpenAI    | [Get Key](https://longcat.chat/platform)                         |\n| **ModelScope (魔搭)**| `modelscope/`    | `https://api-inference.modelscope.cn/v1`            | OpenAI    | [Get Token](https://modelscope.cn/my/tokens)                     |\n| **Antigravity**     | `antigravity/`    | Google Cloud                                        | Custom    | OAuth only                                                       |\n| **GitHub Copilot**  | `github-copilot/` | `localhost:4321`                                    | gRPC      | -                                                                |\n\n#### Configuração Básica\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### Exemplos por Vendor\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**VolcEngine (Doubao)**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (com chave de API)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"api_key\": \"sk-ant-your-key\"\n}\n```\n\n> Execute `picoclaw auth login --provider anthropic` para colar seu token de API.\n\n**Anthropic Messages API (formato nativo)**\n\nPara acesso direto à API Anthropic ou endpoints personalizados que suportam apenas o formato de mensagem nativo da Anthropic:\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> Use o protocolo `anthropic-messages` quando:\n> - Usar proxies de terceiros que suportam apenas o endpoint nativo `/v1/messages` da Anthropic (não o compatível com OpenAI `/v1/chat/completions`)\n> - Conectar a serviços como MiniMax, Synthetic que requerem o formato de mensagem nativo da Anthropic\n> - O protocolo `anthropic` existente retorna erros 404 (indicando que o endpoint não suporta formato compatível com OpenAI)\n>\n> **Nota:** O protocolo `anthropic` usa formato compatível com OpenAI (`/v1/chat/completions`), enquanto `anthropic-messages` usa o formato nativo da Anthropic (`/v1/messages`). Escolha com base no formato suportado pelo seu endpoint.\n\n**Ollama (local)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**Proxy/API Personalizado**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nO PicoClaw remove apenas o prefixo externo `litellm/` antes de enviar a requisição, então aliases de proxy como `litellm/lite-gpt4` enviam `lite-gpt4`, enquanto `litellm/openai/gpt-4o` envia `openai/gpt-4o`.\n\n#### Balanceamento de Carga\n\nConfigure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará automaticamente round-robin entre eles:\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### Migração da Configuração Legacy `providers`\n\nA configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa.\n\n**Configuração Antiga (descontinuada):**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**Configuração Nova (recomendada):**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\nPara guia de migração detalhado, veja [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).\n\n### Arquitetura de Provedores\n\nO PicoClaw roteia provedores por família de protocolo:\n\n- Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM.\n- Protocolo Anthropic: Comportamento nativo da API Claude.\n- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI.\n\nIsso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_key`).\n\n<details>\n<summary><b>Zhipu</b></summary>\n\n**1. Obter chave de API e URL base**\n\n* Obtenha a [chave de API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)\n\n**2. Configurar**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. Executar**\n\n```bash\npicoclaw agent -m \"Hello\"\n```\n\n</details>\n\n<details>\n<summary><b>Exemplo de configuração completa</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 Comparação de Chaves de API\n\n| Service          | Pricing                  | Use Case                              |\n| ---------------- | ------------------------ | ------------------------------------- |\n| **OpenRouter**   | Free: 200K tokens/month  | Multiple models (Claude, GPT-4, etc.) |\n| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |\n| **Zhipu**        | Free: 200K tokens/month  | Suitable for Chinese users                |\n| **Brave Search** | $5/1000 queries          | Web search functionality              |\n| **SearXNG**      | Free (self-hosted)       | Privacy-focused metasearch (70+ engines) |\n| **Groq**         | Free tier available      | Fast inference (Llama, Mixtral)       |\n| **Cerebras**     | Free tier available      | Fast inference (Llama, Qwen, etc.)    |\n| **LongCat**      | Free: up to 5M tokens/day | Fast inference                       |\n| **ModelScope**   | Free: 2000 requests/day  | Inference (Qwen, GLM, DeepSeek, etc.) |\n\n---\n\n<div align=\"center\">\n  <img src=\"assets/logo.jpg\" alt=\"PicoClaw Meme\" width=\"512\">\n</div>\n"
  },
  {
    "path": "docs/pt-br/spawn-tasks.md",
    "content": "# 🔄 Tarefas Assíncronas e Spawn\n\n> Voltar ao [README](../../README.pt-br.md)\n\n## Tarefas Rápidas (resposta direta)\n\n- Informar a hora atual\n\n## Tarefas Longas (usar spawn para assíncrono)\n\n- Pesquisar na web notícias sobre IA e resumir\n- Verificar e-mail e relatar mensagens importantes\n```\n\n**Comportamentos principais:**\n\n| Feature                 | Description                                               |\n| ----------------------- | --------------------------------------------------------- |\n| **spawn**               | Creates async subagent, doesn't block heartbeat           |\n| **Independent context** | Subagent has its own context, no session history          |\n| **message tool**        | Subagent communicates with user directly via message tool |\n| **Non-blocking**        | After spawning, heartbeat continues to next task          |\n\n#### Como Funciona a Comunicação do Subagente\n\n```\nHeartbeat é acionado\n    ↓\nAgente lê HEARTBEAT.md\n    ↓\nPara tarefa longa: spawn subagente\n    ↓                           ↓\nContinua para próxima tarefa  Subagente trabalha independentemente\n    ↓                           ↓\nTodas as tarefas concluídas   Subagente usa ferramenta \"message\"\n    ↓                           ↓\nResponde HEARTBEAT_OK         Usuário recebe resultado diretamente\n```\n\nO subagente tem acesso a ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal.\n\n**Configuração:**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| Option     | Default | Description                        |\n| ---------- | ------- | ---------------------------------- |\n| `enabled`  | `true`  | Enable/disable heartbeat           |\n| `interval` | `30`    | Check interval in minutes (min: 5) |\n\n**Variáveis de ambiente:**\n\n* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar\n* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo\n"
  },
  {
    "path": "docs/pt-br/tools_configuration.md",
    "content": "# 🔧 Configuração de Ferramentas\n\n> Voltar ao [README](../../README.pt-br.md)\n\nA configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`.\n\n## Estrutura de diretórios\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Ferramentas Web\n\nAs ferramentas web são usadas para pesquisa e busca de páginas web.\n\n### Web Fetcher\nConfigurações gerais para busca e processamento de conteúdo de páginas web.\n\n| Config              | Tipo   | Padrão        | Descrição                                                                                     |\n|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------|\n| `enabled`           | bool   | true          | Habilitar a capacidade de busca de páginas web.                                               |\n| `fetch_limit_bytes` | int    | 10485760      | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB).              |\n| `format`            | string | \"plaintext\"   | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado).        |\n\n### Brave\n\n| Config        | Tipo   | Padrão | Descrição                  |\n|---------------|--------|--------|----------------------------|\n| `enabled`     | bool   | false  | Habilitar pesquisa Brave   |\n| `api_key`     | string | -      | Chave API do Brave Search  |\n| `max_results` | int    | 5      | Número máximo de resultados |\n\n### DuckDuckGo\n\n| Config        | Tipo | Padrão | Descrição                      |\n|---------------|------|--------|--------------------------------|\n| `enabled`     | bool | true   | Habilitar pesquisa DuckDuckGo  |\n| `max_results` | int  | 5      | Número máximo de resultados    |\n\n### Perplexity\n\n| Config        | Tipo   | Padrão | Descrição                      |\n|---------------|--------|--------|--------------------------------|\n| `enabled`     | bool   | false  | Habilitar pesquisa Perplexity  |\n| `api_key`     | string | -      | Chave API do Perplexity        |\n| `max_results` | int    | 5      | Número máximo de resultados    |\n\n## Ferramenta Exec\n\nA ferramenta exec é usada para executar comandos shell.\n\n| Config                 | Tipo  | Padrão | Descrição                                      |\n|------------------------|-------|--------|-------------------------------------------------|\n| `enabled`              | bool  | true   | Habilitar a ferramenta exec                     |\n| `enable_deny_patterns` | bool  | true   | Habilitar bloqueio padrão de comandos perigosos |\n| `custom_deny_patterns` | array | []     | Padrões de negação personalizados (expressões regulares) |\n\n### Desabilitando a Ferramenta Exec\n\nPara desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`:\n\n**Via arquivo de configuração:**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**Via variável de ambiente:**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados.\n\n### Funcionalidade\n\n- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão\n- **`custom_deny_patterns`**: Adicione padrões regex de negação personalizados; comandos correspondentes serão bloqueados\n\n### Padrões de comandos bloqueados por padrão\n\nPor padrão, o PicoClaw bloqueia os seguintes comandos perigosos:\n\n- Comandos de exclusão: `rm -rf`, `del /f/q`, `rmdir /s`\n- Operações de disco: `format`, `mkfs`, `diskpart`, `dd if=`, escrita em `/dev/sd*`\n- Operações do sistema: `shutdown`, `reboot`, `poweroff`\n- Substituição de comandos: `$()`, `${}`, crases\n- Pipe para shell: `| sh`, `| bash`\n- Escalação de privilégios: `sudo`, `chmod`, `chown`\n- Controle de processos: `pkill`, `killall`, `kill -9`\n- Operações remotas: `curl | sh`, `wget | sh`, `ssh`\n- Gerenciamento de pacotes: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`\n- Contêineres: `docker run`, `docker exec`\n- Git: `git push`, `git force`\n- Outros: `eval`, `source *.sh`\n\n### Limitação arquitetural conhecida\n\nO guarda exec apenas valida o comando de nível superior enviado ao PicoClaw. Ele **não** inspeciona recursivamente processos filhos gerados por ferramentas de build ou scripts após o início desse comando.\n\nExemplos de fluxos de trabalho que podem contornar o guarda de comando direto uma vez que o comando inicial é permitido:\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\nIsso significa que o guarda é útil para bloquear comandos diretos obviamente perigosos, mas **não** é um sandbox completo para pipelines de build não revisados. Se seu modelo de ameaça inclui código não confiável no workspace, use isolamento mais forte, como contêineres, VMs ou um fluxo de aprovação em torno de comandos de build e execução.\n\n### Exemplo de configuração\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Ferramenta Cron\n\nA ferramenta cron é usada para agendar tarefas periódicas.\n\n| Config                 | Tipo | Padrão | Descrição                                          |\n|------------------------|------|--------|-----------------------------------------------------|\n| `exec_timeout_minutes` | int  | 5      | Tempo limite de execução em minutos, 0 significa sem limite |\n\n## Ferramenta MCP\n\nA ferramenta MCP permite a integração com servidores Model Context Protocol externos.\n\n### Descoberta de ferramentas (carregamento preguiçoso)\n\nAo conectar a vários servidores MCP, expor centenas de ferramentas simultaneamente pode esgotar a janela de contexto do LLM e aumentar os custos de API. O recurso **Discovery** resolve isso mantendo as ferramentas MCP *ocultas* por padrão.\n\nEm vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa leve (usando correspondência de palavras-chave BM25 ou Regex). Quando o LLM precisa de uma capacidade específica, ele pesquisa a biblioteca oculta. As ferramentas correspondentes são então temporariamente \"desbloqueadas\" e injetadas no contexto por um número configurado de turnos (`ttl`).\n\n### Configuração global\n\n| Config      | Tipo   | Padrão | Descrição                                    |\n|-------------|--------|--------|----------------------------------------------|\n| `enabled`   | bool   | false  | Habilitar integração MCP globalmente         |\n| `discovery` | object | `{}`   | Configuração de descoberta de ferramentas (veja abaixo) |\n| `servers`   | object | `{}`   | Mapa de nome do servidor para configuração do servidor |\n\n### Configuração Discovery (`discovery`)\n\n| Config               | Tipo | Padrão | Descrição                                                                                                                         |\n|----------------------|------|--------|-----------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false  | Se true, as ferramentas MCP ficam ocultas e são carregadas sob demanda via pesquisa. Se false, todas as ferramentas são carregadas |\n| `ttl`                | int  | 5      | Número de turnos de conversa que uma ferramenta descoberta permanece desbloqueada                                                 |\n| `max_search_results` | int  | 5      | Número máximo de ferramentas retornadas por consulta de pesquisa                                                                  |\n| `use_bm25`           | bool | true   | Habilitar a ferramenta de pesquisa por linguagem natural/palavras-chave (`tool_search_tool_bm25`). **Aviso**: consome mais recursos que a pesquisa regex |\n| `use_regex`          | bool | false  | Habilitar a ferramenta de pesquisa por padrão regex (`tool_search_tool_regex`)                                                    |\n\n> **Nota:** Se `discovery.enabled` for `true`, você **deve** habilitar pelo menos um mecanismo de pesquisa (`use_bm25` ou `use_regex`),\n> caso contrário a aplicação falhará ao iniciar.\n\n### Configuração por servidor\n\n| Config     | Tipo   | Obrigatório | Descrição                                  |\n|------------|--------|-------------|--------------------------------------------|\n| `enabled`  | bool   | sim         | Habilitar este servidor MCP                |\n| `type`     | string | não         | Tipo de transporte: `stdio`, `sse`, `http` |\n| `command`  | string | stdio       | Comando executável para transporte stdio   |\n| `args`     | array  | não         | Argumentos do comando para transporte stdio |\n| `env`      | object | não         | Variáveis de ambiente para processo stdio  |\n| `env_file` | string | não         | Caminho para arquivo de ambiente para processo stdio |\n| `url`      | string | sse/http    | URL do endpoint para transporte `sse`/`http` |\n| `headers`  | object | não         | Cabeçalhos HTTP para transporte `sse`/`http` |\n\n### Comportamento do transporte\n\n- Se `type` for omitido, o transporte é detectado automaticamente:\n    - `url` está definido → `sse`\n    - `command` está definido → `stdio`\n- `http` e `sse` ambos usam `url` + `headers` opcionais.\n- `env` e `env_file` são aplicados apenas a servidores `stdio`.\n\n### Exemplos de configuração\n\n#### 1) Servidor MCP Stdio\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) Servidor MCP remoto SSE/HTTP\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) Configuração MCP massiva com descoberta de ferramentas habilitada\n\n*Neste exemplo, o LLM verá apenas o `tool_search_tool_bm25`. Ele pesquisará e desbloqueará ferramentas do Github ou Postgres dinamicamente apenas quando solicitado pelo usuário.*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n## Ferramenta Skills\n\nA ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub.\n\n### Registros\n\n| Config                             | Tipo   | Padrão               | Descrição                                    |\n|------------------------------------|--------|-----------------------|----------------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                  | Habilitar registro ClawHub                   |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai`  | URL base do ClawHub                          |\n| `registries.clawhub.auth_token`    | string | `\"\"`                  | Token Bearer opcional para limites de taxa mais altos |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`      | Caminho da API de pesquisa                   |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`      | Caminho da API de Skills                     |\n| `registries.clawhub.download_path` | string | `/api/v1/download`    | Caminho da API de download                   |\n\n### Exemplo de configuração\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## Variáveis de ambiente\n\nTodas as opções de configuração podem ser substituídas via variáveis de ambiente com o formato `PICOCLAW_TOOLS_<SECTION>_<KEY>`:\n\nPor exemplo:\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\nNota: Configuração de tipo mapa aninhado (por exemplo `tools.mcp.servers.<name>.*`) é configurada no `config.json` em vez de variáveis de ambiente.\n"
  },
  {
    "path": "docs/pt-br/troubleshooting.md",
    "content": "# 🐛 Solução de Problemas\n\n> Voltar ao [README](../../README.pt-br.md)\n\n## \"model ... not found in model_list\" ou OpenRouter \"free is not a valid model ID\"\n\n**Sintoma:** Você vê um dos seguintes erros:\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter retorna 400: `\"free is not a valid model ID\"`\n\n**Causa:** O campo `model` na sua entrada `model_list` é o que é enviado para a API. Para o OpenRouter, você deve usar o ID de modelo **completo**, não uma abreviação.\n\n- **Errado:** `\"model\": \"free\"` → OpenRouter recebe `free` e rejeita.\n- **Correto:** `\"model\": \"openrouter/free\"` → OpenRouter recebe `openrouter/free` (roteamento automático do nível gratuito).\n\n**Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração):\n\n1. **agents.defaults.model** deve corresponder a um `model_name` em `model_list` (ex.: `\"openrouter-free\"`).\n2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo:\n   - `\"openrouter/free\"` – nível gratuito automático\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\nExemplo:\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\nObtenha sua chave em [OpenRouter Keys](https://openrouter.ai/keys).\n"
  },
  {
    "path": "docs/spawn-tasks.md",
    "content": "# 🔄 Spawn & Async Tasks\n\n> Back to [README](../README.md)\n\n## Quick Tasks (respond directly)\n\n- Report current time\n\n## Long Tasks (use spawn for async)\n\n- Search the web for AI news and summarize\n- Check email and report important messages\n```\n\n**Key behaviors:**\n\n| Feature                 | Description                                               |\n| ----------------------- | --------------------------------------------------------- |\n| **spawn**               | Creates async subagent, doesn't block heartbeat           |\n| **Independent context** | Subagent has its own context, no session history          |\n| **message tool**        | Subagent communicates with user directly via message tool |\n| **Non-blocking**        | After spawning, heartbeat continues to next task          |\n\n#### How Subagent Communication Works\n\n```\nHeartbeat triggers\n    ↓\nAgent reads HEARTBEAT.md\n    ↓\nFor long task: spawn subagent\n    ↓                           ↓\nContinue to next task      Subagent works independently\n    ↓                           ↓\nAll tasks done            Subagent uses \"message\" tool\n    ↓                           ↓\nRespond HEARTBEAT_OK      User receives result directly\n```\n\nThe subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.\n\n**Configuration:**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| Option     | Default | Description                        |\n| ---------- | ------- | ---------------------------------- |\n| `enabled`  | `true`  | Enable/disable heartbeat           |\n| `interval` | `30`    | Check interval in minutes (min: 5) |\n\n**Environment variables:**\n\n* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable\n* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval\n"
  },
  {
    "path": "docs/tools_configuration.md",
    "content": "# Tools Configuration\n\nPicoClaw's tools configuration is located in the `tools` field of `config.json`.\n\n## Directory Structure\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Web Tools\n\nWeb tools are used for web search and fetching.\n\n### Web Fetcher\nGeneral settings for fetching and processing webpage content.\n\n| Config              | Type   | Default       | Description                                                                                   |\n|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------|\n| `enabled`           | bool   | true          | Enable the webpage fetching capability.                                                       |\n| `fetch_limit_bytes` | int    | 10485760      | Maximum size of the webpage payload to fetch, in bytes (default is 10MB).                     |\n| `format`            | string | \"plaintext\"   | Output format of the fetched content. Options: `plaintext` or `markdown` (recommended).       |\n\n### Brave\n\n| Config        | Type   | Default | Description               |\n|---------------|--------|---------|---------------------------|\n| `enabled`     | bool   | false   | Enable Brave search       |\n| `api_key`     | string | -       | Brave Search API key      |\n| `max_results` | int    | 5       | Maximum number of results |\n\n### DuckDuckGo\n\n| Config        | Type | Default | Description               |\n|---------------|------|---------|---------------------------|\n| `enabled`     | bool | true    | Enable DuckDuckGo search  |\n| `max_results` | int  | 5       | Maximum number of results |\n\n### Perplexity\n\n| Config        | Type   | Default | Description               |\n|---------------|--------|---------|---------------------------|\n| `enabled`     | bool   | false   | Enable Perplexity search  |\n| `api_key`     | string | -       | Perplexity API key        |\n| `max_results` | int    | 5       | Maximum number of results |\n\n## Exec Tool\n\nThe exec tool is used to execute shell commands.\n\n| Config                 | Type  | Default | Description                                |\n|------------------------|-------|---------|--------------------------------------------|\n| `enabled`              | bool  | true    | Enable the exec tool                        |\n| `enable_deny_patterns` | bool  | true    | Enable default dangerous command blocking  |\n| `custom_deny_patterns` | array | []      | Custom deny patterns (regular expressions) |\n\n### Disabling the Exec Tool\n\nTo completely disable the `exec` tool, set `enabled` to `false`:\n\n**Via config file:**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**Via environment variable:**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands.\n\n### Functionality\n\n- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns\n- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked\n\n### Default Blocked Command Patterns\n\nBy default, PicoClaw blocks the following dangerous commands:\n\n- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`\n- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`\n- System operations: `shutdown`, `reboot`, `poweroff`\n- Command substitution: `$()`, `${}`, backticks\n- Pipe to shell: `| sh`, `| bash`\n- Privilege escalation: `sudo`, `chmod`, `chown`\n- Process control: `pkill`, `killall`, `kill -9`\n- Remote operations: `curl | sh`, `wget | sh`, `ssh`\n- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`\n- Containers: `docker run`, `docker exec`\n- Git: `git push`, `git force`\n- Other: `eval`, `source *.sh`\n\n### Known Architectural Limitation\n\nThe exec guard only validates the top-level command sent to PicoClaw. It does **not** recursively inspect child\nprocesses spawned by build tools or scripts after that command starts running.\n\nExamples of workflows that can bypass the direct command guard once the initial command is allowed:\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\nThis means the guard is useful for blocking obviously dangerous direct commands, but it is **not** a full sandbox for\nunreviewed build pipelines. If your threat model includes untrusted code in the workspace, use stronger isolation such\nas containers, VMs, or an approval flow around build-and-run commands.\n\n### Configuration Example\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Cron Tool\n\nThe cron tool is used for scheduling periodic tasks.\n\n| Config                 | Type | Default | Description                                    |\n|------------------------|------|---------|------------------------------------------------|\n| `exec_timeout_minutes` | int  | 5       | Execution timeout in minutes, 0 means no limit |\n\n## MCP Tool\n\nThe MCP tool enables integration with external Model Context Protocol servers.\n\n### Tool Discovery (Lazy Loading)\n\nWhen connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window\nand increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.\n\nInstead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).\nWhen the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily \"unlocked\"\nand injected into the context for a configured number of turns (`ttl`).\n\n### Global Config\n\n| Config      | Type   | Default | Description                                  |\n|-------------|--------|---------|----------------------------------------------|\n| `enabled`   | bool   | false   | Enable MCP integration globally              |\n| `discovery` | object | `{}`    | Configuration for Tool Discovery (see below) |\n| `servers`   | object | `{}`    | Map of server name to server config          |\n\n### Discovery Config (`discovery`)\n\n| Config               | Type | Default | Description                                                                                                                       |\n|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false   | Global default: if `true`, all MCP tools are hidden and loaded on-demand via search; if `false`, all tools are loaded into context. Individual servers can override this with the per-server `deferred` field. |\n| `ttl`                | int  | 5       | Number of conversational turns a discovered tool remains unlocked                                                                 |\n| `max_search_results` | int  | 5       | Maximum number of tools returned per search query                                                                                 |\n| `use_bm25`           | bool | true    | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |\n| `use_regex`          | bool | false   | Enable the regex pattern search tool (`tool_search_tool_regex`)                                                                   |\n\n> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),\n> otherwise the application will fail to start.\n\n### Per-Server Config\n\n| Config     | Type    | Required | Description                                                                                                                                                     |\n|------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`  | bool    | yes      | Enable this MCP server                                                                                                                                          |\n| `deferred` | bool    | no       | Override deferred mode for this server only. `true` = tools are hidden and discoverable via search; `false` = tools are always visible in context. When omitted, the global `discovery.enabled` value applies. |\n| `type`     | string  | no       | Transport type: `stdio`, `sse`, `http`                                                                                                                          |\n| `command`  | string  | stdio    | Executable command for stdio transport                                                                                                                          |\n| `args`     | array   | no       | Command arguments for stdio transport                                                                                                                           |\n| `env`      | object  | no       | Environment variables for stdio process                                                                                                                         |\n| `env_file` | string  | no       | Path to environment file for stdio process                                                                                                                      |\n| `url`      | string  | sse/http | Endpoint URL for `sse`/`http` transport                                                                                                                         |\n| `headers`  | object  | no       | HTTP headers for `sse`/`http` transport                                                                                                                         |\n\n### Transport Behavior\n\n- If `type` is omitted, transport is auto-detected:\n    - `url` is set → `sse`\n    - `command` is set → `stdio`\n- `http` and `sse` both use `url` + optional `headers`.\n- `env` and `env_file` are only applied to `stdio` servers.\n\n### Configuration Examples\n\n#### 1) Stdio MCP server\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) Remote SSE/HTTP MCP server\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) Massive MCP setup with Tool Discovery enabled\n\n*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools\ndynamically only when requested by the user.*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 4) Mixed setup: per-server deferred override\n\n*Discovery is enabled globally, but `filesystem` is pinned as always-visible while `context7` follows the global\ndefault (deferred). `aws` explicitly opts in to deferred mode even though it is the same as the global default.*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true\n      },\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/workspace\"],\n          \"deferred\": false\n        },\n        \"context7\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\"-y\", \"@upstash/context7-mcp\"]\n        },\n        \"aws\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\"-y\", \"aws-mcp-server\"],\n          \"deferred\": true\n        }\n      }\n    }\n  }\n}\n```\n\n> **Tip:** `deferred` on a per-server basis is independent of `discovery.enabled`. You can keep\n> `discovery.enabled: false` globally (all tools visible by default) and still mark individual\n> high-volume servers as `\"deferred\": true` to avoid polluting the context with their tools.\n\n## Skills Tool\n\nThe skills tool configures skill discovery and installation via registries like ClawHub.\n\n### Registries\n\n| Config                             | Type   | Default              | Description                                  |\n|------------------------------------|--------|----------------------|----------------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                 | Enable ClawHub registry                      |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai` | ClawHub base URL                             |\n| `registries.clawhub.auth_token`    | string | `\"\"`                 | Optional Bearer token for higher rate limits |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`     | Search API path                              |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`     | Skills API path                              |\n| `registries.clawhub.download_path` | string | `/api/v1/download`   | Download API path                            |\n\n### Configuration Example\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## Environment Variables\n\nAll configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:\n\nFor example:\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\nNote: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than\nenvironment variables.\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\n\n## \"model ... not found in model_list\" or OpenRouter \"free is not a valid model ID\"\n\n**Symptom:** You see either:\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter returns 400: `\"free is not a valid model ID\"`\n\n**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.\n\n- **Wrong:** `\"model\": \"free\"` → OpenRouter receives `free` and rejects it.\n- **Right:** `\"model\": \"openrouter/free\"` → OpenRouter receives `openrouter/free` (auto free-tier routing).\n\n**Fix:** In `~/.picoclaw/config.json` (or your config path):\n\n1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `\"openrouter-free\"`).\n2. That entry’s **model** must be a valid OpenRouter model ID, for example:\n   - `\"openrouter/free\"` – auto free-tier\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\nExample snippet:\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\nGet your key at [OpenRouter Keys](https://openrouter.ai/keys).\n"
  },
  {
    "path": "docs/vi/chat-apps.md",
    "content": "# 💬 Cấu Hình Ứng Dụng Chat\n\n> Quay lại [README](../../README.vi.md)\n\n## 💬 Ứng Dụng Chat\n\nTrò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam\n\n> **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung.\n\n| Channel      | Setup                              |\n| ------------ | ---------------------------------- |\n| **Telegram** | Easy (just a token)                |\n| **Discord**  | Easy (bot token + intents)         |\n| **WhatsApp** | Easy (native: QR scan; or bridge URL) |\n| **Matrix**   | Medium (homeserver + bot access token) |\n| **QQ**       | Easy (AppID + AppSecret)           |\n| **DingTalk** | Medium (app credentials)           |\n| **LINE**     | Medium (credentials + webhook URL) |\n| **WeCom AI Bot** | Medium (Token + AES key)       |\n| **Feishu**   | Medium (App ID + Secret, WebSocket mode) |\n| **Slack**    | Medium (Bot token + App token) |\n| **IRC**      | Medium (server + TLS config)   |\n| **OneBot**   | Medium (QQ via OneBot protocol) |\n| **MaixCam**  | Easy (Sipeed hardware integration) |\n| **Pico**     | Native PicoClaw protocol           |\n\n<details>\n<summary><b>Telegram</b> (Khuyến nghị)</summary>\n\n**1. Tạo bot**\n\n* Mở Telegram, tìm `@BotFather`\n* Gửi `/newbot`, làm theo hướng dẫn\n* Sao chép token\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> Lấy user ID của bạn từ `@userinfobot` trên Telegram.\n\n**3. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)**\n\nPicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để menu lệnh và hành vi runtime luôn đồng bộ.\nĐăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor.\n\nNếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền.\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. Tạo bot**\n\n* Truy cập <https://discord.com/developers/applications>\n* Tạo ứng dụng → Bot → Add Bot\n* Sao chép bot token\n\n**2. Bật intents**\n\n* Trong cài đặt Bot, bật **MESSAGE CONTENT INTENT**\n* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu bạn muốn sử dụng danh sách cho phép dựa trên dữ liệu thành viên\n\n**3. Lấy User ID**\n* Cài đặt Discord → Nâng cao → bật **Developer Mode**\n* Nhấp chuột phải vào avatar → **Copy User ID**\n\n**4. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. Mời bot**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* Mở URL mời được tạo và thêm bot vào server của bạn\n\n**Tùy chọn: Chế độ kích hoạt nhóm**\n\nMặc định bot phản hồi tất cả tin nhắn trong kênh server. Để giới hạn phản hồi chỉ khi @mention, thêm:\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\nBạn cũng có thể kích hoạt bằng tiền tố từ khóa (ví dụ: `!bot`):\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b> (native qua whatsmeow)</summary>\n\nPicoClaw có thể kết nối WhatsApp theo hai cách:\n\n- **Native (khuyến nghị):** In-process sử dụng [whatsmeow](https://github.com/tulir/whatsmeow). Không cần bridge riêng. Đặt `\"use_native\": true` và để trống `bridge_url`. Lần chạy đầu tiên, quét mã QR bằng WhatsApp (Thiết bị liên kết). Phiên được lưu trong workspace (ví dụ: `workspace/whatsapp/`). Kênh native là **tùy chọn** để giữ binary mặc định nhỏ; build với `-tags whatsapp_native` (ví dụ: `make build-whatsapp-native` hoặc `go build -tags whatsapp_native ./cmd/...`).\n- **Bridge:** Kết nối đến bridge WebSocket bên ngoài. Đặt `bridge_url` (ví dụ: `ws://localhost:3001`) và giữ `use_native` là false.\n\n**Cấu hình (native)**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\nNếu `session_store_path` trống, phiên được lưu tại `<workspace>/whatsapp/`. Chạy `picoclaw gateway`; lần chạy đầu tiên, quét mã QR hiển thị trong terminal bằng WhatsApp → Thiết bị liên kết.\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. Tạo bot**\n\n- Truy cập [QQ Open Platform](https://q.qq.com/#)\n- Tạo ứng dụng → Lấy **AppID** và **AppSecret**\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn truy cập.\n\n**3. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>DingTalk</b></summary>\n\n**1. Tạo bot**\n\n* Truy cập [Open Platform](https://open.dingtalk.com/)\n* Tạo ứng dụng nội bộ\n* Sao chép Client ID và Client Secret\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Đặt `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định DingTalk user ID để giới hạn truy cập.\n\n**3. Chạy**\n\n```bash\npicoclaw gateway\n```\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. Chuẩn bị tài khoản bot**\n\n* Sử dụng homeserver ưa thích (ví dụ: `https://matrix.org` hoặc tự host)\n* Tạo user bot và lấy access token\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. Chạy**\n\n```bash\npicoclaw gateway\n```\n\nĐể xem đầy đủ các tùy chọn (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), xem [Hướng Dẫn Cấu Hình Kênh Matrix](docs/channels/matrix/README.md).\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. Tạo Tài Khoản LINE Official**\n\n- Truy cập [LINE Developers Console](https://developers.line.biz/)\n- Tạo provider → Tạo kênh Messaging API\n- Sao chép **Channel Secret** và **Channel Access Token**\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Webhook LINE được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`).\n\n**3. Thiết lập Webhook URL**\n\nLINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:\n\n```bash\n# Ví dụ với ngrok (port mặc định gateway là 18790)\nngrok http 18790\n```\n\nSau đó đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.\n\n**4. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n> Trong chat nhóm, bot chỉ phản hồi khi được @mention. Phản hồi trích dẫn tin nhắn gốc.\n\n</details>\n\n<details>\n<summary><b>WeCom (企业微信)</b></summary>\n\nPicoClaw hỗ trợ ba loại tích hợp WeCom:\n\n**Tùy chọn 1: WeCom Bot (Bot)** - Thiết lập dễ hơn, hỗ trợ chat nhóm\n**Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng\n**Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng\n\nXem [Hướng Dẫn Cấu Hình WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) để biết hướng dẫn thiết lập chi tiết.\n\n**Thiết Lập Nhanh - WeCom Bot:**\n\n**1. Tạo bot**\n\n* Truy cập Console Quản Trị WeCom → Chat Nhóm → Thêm Bot Nhóm\n* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> Webhook WeCom được phục vụ trên máy chủ Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`).\n\n**Thiết Lập Nhanh - WeCom App:**\n\n**1. Tạo ứng dụng**\n\n* Truy cập Console Quản Trị WeCom → Quản Lý App → Tạo App\n* Sao chép **AgentId** và **Secret**\n* Truy cập trang \"Công Ty Của Tôi\", sao chép **CorpID**\n\n**2. Cấu hình nhận tin nhắn**\n\n* Trong chi tiết App, nhấp \"Nhận Tin Nhắn\" → \"Cấu Hình API\"\n* Đặt URL thành `http://your-server:18790/webhook/wecom-app`\n* Tạo **Token** và **EncodingAESKey**\n\n**3. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n> **Lưu ý**: Callback webhook WeCom được phục vụ trên port Gateway (mặc định 18790). Sử dụng reverse proxy cho HTTPS.\n\n**Thiết Lập Nhanh - WeCom AI Bot:**\n\n**1. Tạo AI Bot**\n\n* Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot\n* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18791/webhook/wecom-aibot`\n* Sao chép **Token** và nhấp \"Tạo Ngẫu Nhiên\" cho **EncodingAESKey**\n\n**2. Cấu hình**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"Hello! How can I help you?\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n    }\n  }\n}\n```\n\n**3. Chạy**\n\n```bash\npicoclaw gateway\n```\n\n> **Lưu ý**: WeCom AI Bot sử dụng giao thức streaming pull — không lo timeout phản hồi. Tác vụ dài (>30 giây) tự động chuyển sang gửi qua `response_url` push.\n\n</details>\n"
  },
  {
    "path": "docs/vi/configuration.md",
    "content": "# ⚙️ Hướng Dẫn Cấu Hình\n\n> Quay lại [README](../../README.vi.md)\n\n## ⚙️ Cấu Hình\n\nFile cấu hình: `~/.picoclaw/config.json`\n\n### Biến Môi Trường\n\nBạn có thể ghi đè các đường dẫn mặc định bằng biến môi trường. Điều này hữu ích cho cài đặt portable, triển khai container, hoặc chạy picoclaw như dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.\n\n| Biến              | Mô tả                                                                                                                             | Đường Dẫn Mặc Định       |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Chỉ định trực tiếp cho picoclaw file `config.json` nào cần tải, bỏ qua tất cả vị trí khác. | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | Ghi đè thư mục gốc cho dữ liệu picoclaw. Thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác.          | `~/.picoclaw`             |\n\n**Ví dụ:**\n\n```bash\n# Chạy picoclaw với file cấu hình cụ thể\n# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# Chạy picoclaw với tất cả dữ liệu lưu tại /opt/picoclaw\n# Cấu hình sẽ được tải từ mặc định ~/.picoclaw/config.json\n# Workspace sẽ được tạo tại /opt/picoclaw/workspace\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# Sử dụng cả hai cho thiết lập tùy chỉnh hoàn toàn\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### Bố Cục Workspace\n\nPicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # Phiên hội thoại và lịch sử\n├── memory/           # Bộ nhớ dài hạn (MEMORY.md)\n├── state/            # Trạng thái bền vững (kênh cuối, v.v.)\n├── cron/             # Cơ sở dữ liệu tác vụ lên lịch\n├── skills/           # Skill tùy chỉnh\n├── AGENT.md          # Hướng dẫn hành vi agent\n├── HEARTBEAT.md      # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)\n├── IDENTITY.md       # Danh tính agent\n├── SOUL.md           # Linh hồn agent\n└── USER.md           # Tùy chọn người dùng\n```\n\n> **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md` và `memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo.\n\n### Nguồn Skill\n\nMặc định, skill được tải từ:\n\n1. `~/.picoclaw/workspace/skills` (workspace)\n2. `~/.picoclaw/skills` (global)\n3. `<current-working-directory>/skills` (builtin)\n\nCho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với:\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### Chính Sách Thực Thi Lệnh Thống Nhất\n\n- Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`.\n- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký lệnh được hỗ trợ khi khởi động.\n- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường.\n- Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp.\n\n### 🔒 Sandbox Bảo Mật\n\nPicoClaw chạy trong môi trường sandbox mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong workspace đã cấu hình.\n\n#### Cấu Hình Mặc Định\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| Tùy chọn                | Mặc định                | Mô tả                                    |\n| ----------------------- | ----------------------- | ----------------------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Thư mục làm việc của agent               |\n| `restrict_to_workspace` | `true`                  | Giới hạn truy cập file/lệnh trong workspace |\n\n#### Công Cụ Được Bảo Vệ\n\nKhi `restrict_to_workspace: true`, các công cụ sau được sandbox:\n\n| Công cụ       | Chức năng        | Giới hạn                               |\n| ------------- | ---------------- | -------------------------------------- |\n| `read_file`   | Đọc file         | Chỉ file trong workspace              |\n| `write_file`  | Ghi file         | Chỉ file trong workspace              |\n| `list_dir`    | Liệt kê thư mục | Chỉ thư mục trong workspace           |\n| `edit_file`   | Sửa file         | Chỉ file trong workspace              |\n| `append_file` | Nối vào file     | Chỉ file trong workspace              |\n| `exec`        | Thực thi lệnh   | Đường dẫn lệnh phải trong workspace   |\n\n#### Bảo Vệ Exec Bổ Sung\n\nNgay cả khi `restrict_to_workspace: false`, công cụ `exec` chặn các lệnh nguy hiểm sau:\n\n* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt\n* `format`, `mkfs`, `diskpart` — Định dạng đĩa\n* `dd if=` — Tạo ảnh đĩa\n* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp đĩa\n* `shutdown`, `reboot`, `poweroff` — Tắt hệ thống\n* Fork bomb `:(){ :|:& };:`\n\n### Kiểm Soát Truy Cập File\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |\n| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |\n\n### Bảo Mật Exec\n\n| Config Key | Type | Default | Description |\n|------------|------|---------|-------------|\n| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) |\n| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow |\n\n> **Lưu ý Bảo Mật:** Bảo vệ symlink được bật mặc định — tất cả đường dẫn file được giải quyết qua `filepath.EvalSymlinks` trước khi so khớp whitelist, ngăn chặn tấn công thoát qua symlink.\n\n#### Hạn Chế Đã Biết: Tiến Trình Con Từ Công Cụ Build\n\nGuard bảo mật exec chỉ kiểm tra dòng lệnh mà PicoClaw khởi chạy trực tiếp. Nó không kiểm tra đệ quy các tiến trình con được tạo bởi công cụ phát triển được phép như `make`, `go run`, `cargo`, `npm run`, hoặc script build tùy chỉnh.\n\nĐiều này có nghĩa là lệnh cấp cao nhất vẫn có thể biên dịch hoặc khởi chạy binary khác sau khi vượt qua kiểm tra guard ban đầu. Trong thực tế, hãy coi script build, Makefile, script package, và binary được tạo như mã thực thi cần cùng mức độ review như lệnh shell trực tiếp.\n\nCho môi trường rủi ro cao hơn:\n\n* Review script build trước khi thực thi.\n* Ưu tiên phê duyệt/review thủ công cho quy trình biên dịch và chạy.\n* Chạy PicoClaw trong container hoặc VM nếu bạn cần cách ly mạnh hơn guard tích hợp.\n\n#### Ví Dụ Lỗi\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### Tắt Giới Hạn (Rủi Ro Bảo Mật)\n\nNếu bạn cần agent truy cập đường dẫn ngoài workspace:\n\n**Phương pháp 1: File cấu hình**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**Phương pháp 2: Biến môi trường**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập bất kỳ đường dẫn nào trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.\n\n#### Tính Nhất Quán Ranh Giới Bảo Mật\n\nCài đặt `restrict_to_workspace` áp dụng nhất quán trên tất cả đường dẫn thực thi:\n\n| Đường Dẫn Thực Thi | Ranh Giới Bảo Mật          |\n| -------------------- | ---------------------------- |\n| Main Agent           | `restrict_to_workspace` ✅   |\n| Subagent / Spawn     | Kế thừa cùng giới hạn ✅    |\n| Heartbeat tasks      | Kế thừa cùng giới hạn ✅    |\n\nTất cả đường dẫn chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật qua subagent hoặc tác vụ lên lịch.\n\n### Heartbeat (Tác Vụ Định Kỳ)\n\nPicoClaw có thể thực hiện tác vụ định kỳ tự động. Tạo file `HEARTBEAT.md` trong workspace:\n\n```markdown\n# Tác Vụ Định Kỳ\n\n- Kiểm tra email cho tin nhắn quan trọng\n- Xem lịch cho sự kiện sắp tới\n- Kiểm tra dự báo thời tiết\n```\n\nAgent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực thi các tác vụ sử dụng công cụ có sẵn.\n\n#### Tác Vụ Bất Đồng Bộ Với Spawn\n\nCho tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:\n\n```markdown\n# Tác Vụ Định Kỳ\n```\n"
  },
  {
    "path": "docs/vi/docker.md",
    "content": "# 🐳 Docker và Bắt Đầu Nhanh\n\n> Quay lại [README](../../README.vi.md)\n\n## 🐳 Docker Compose\n\nBạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy.\n\n```bash\n# 1. Clone repo này\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# Container hiển thị \"First-run setup complete.\" và dừng lại.\n\n# 3. Cấu hình API key của bạn\nvim docker/data/config.json   # Set provider API keys, bot tokens, etc.\n\n# 4. Khởi động\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Người dùng Docker**: Mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ host. Nếu bạn cần truy cập các health endpoint hoặc mở port, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường hoặc cập nhật `config.json`.\n\n```bash\n# 5. Kiểm tra log\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. Dừng\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Chế Độ Launcher (Web Console)\n\nImage `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\nMở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway.\n\n> [!WARNING]\n> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng.\n\n### Chế Độ Agent (One-shot)\n\n```bash\n# Đặt câu hỏi\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"What is 2+2?\"\n\n# Chế độ tương tác\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### Cập Nhật\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n### 🚀 Bắt Đầu Nhanh\n\n> [!TIP]\n> Cấu hình API Key trong `~/.picoclaw/config.json`. Lấy API Key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là tùy chọn — lấy miễn phí [Tavily API](https://tavily.com) (1000 truy vấn miễn phí/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng).\n\n**1. Khởi tạo**\n\n```bash\npicoclaw onboard\n```\n\n**2. Cấu hình** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **Mới**: Định dạng cấu hình `model_list` cho phép thêm provider mà không cần thay đổi code. Xem [Cấu Hình Mô Hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.\n> `request_timeout` là tùy chọn và tính bằng giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sử dụng timeout mặc định (120s).\n\n**3. Lấy API Key**\n\n* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **Tìm kiếm Web** (tùy chọn):\n  * [Brave Search](https://brave.com/search/api) - Trả phí ($5/1000 truy vấn, ~$5-6/tháng)\n  * [Perplexity](https://www.perplexity.ai) - Tìm kiếm bằng AI với giao diện chat\n  * [SearXNG](https://github.com/searxng/searxng) - Công cụ tìm kiếm tổng hợp tự host (miễn phí, không cần API key)\n  * [Tavily](https://tavily.com) - Tối ưu cho AI Agent (1000 yêu cầu/tháng)\n  * DuckDuckGo - Fallback tích hợp (không cần API key)\n\n> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ.\n\n**4. Chat**\n\n```bash\npicoclaw agent -m \"What is 2+2?\"\n```\n\nVậy là xong! Bạn có một trợ lý AI hoạt động trong 2 phút.\n\n---\n"
  },
  {
    "path": "docs/vi/providers.md",
    "content": "# 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình\n\n> Quay lại [README](../../README.vi.md)\n\n### Nhà Cung Cấp\n\n> [!NOTE]\n> Groq cung cấp chuyển đổi giọng nói miễn phí qua Whisper. Nếu được cấu hình, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển đổi ở cấp agent.\n\n| Provider     | Purpose                                 | Get API Key                                                  |\n| ------------ | --------------------------------------- | ------------------------------------------------------------ |\n| `gemini`     | LLM (Gemini direct)                     | [aistudio.google.com](https://aistudio.google.com)           |\n| `zhipu`      | LLM (Zhipu direct)                      | [bigmodel.cn](https://bigmodel.cn)                           |\n| `volcengine` | LLM(Volcengine direct)                  | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                 |\n| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai)                       |\n| `anthropic`  | LLM (Claude direct)                     | [console.anthropic.com](https://console.anthropic.com)       |\n| `openai`     | LLM (GPT direct)                        | [platform.openai.com](https://platform.openai.com)           |\n| `deepseek`   | LLM (DeepSeek direct)                   | [platform.deepseek.com](https://platform.deepseek.com)       |\n| `qwen`       | LLM (Qwen direct)                       | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`       | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com)                 |\n| `cerebras`   | LLM (Cerebras direct)                   | [cerebras.ai](https://cerebras.ai)                           |\n| `vivgrid`    | LLM (Vivgrid direct)                    | [vivgrid.com](https://vivgrid.com)                           |\n| `moonshot`   | LLM (Kimi/Moonshot direct)              | [platform.moonshot.cn](https://platform.moonshot.cn)         |\n| `minimax`    | LLM (Minimax direct)                    | [platform.minimaxi.com](https://platform.minimaxi.com)      |\n| `avian`      | LLM (Avian direct)                      | [avian.io](https://avian.io)                                 |\n| `mistral`    | LLM (Mistral direct)                    | [console.mistral.ai](https://console.mistral.ai)            |\n| `longcat`    | LLM (Longcat direct)                    | [longcat.ai](https://longcat.ai)                             |\n| `modelscope` | LLM (ModelScope direct)                 | [modelscope.cn](https://modelscope.cn)                       |\n\n### Cấu Hình Mô Hình (model_list)\n\n> **Có gì mới?** PicoClaw hiện sử dụng cách tiếp cận cấu hình **tập trung vào mô hình**. Chỉ cần chỉ định định dạng `vendor/model` (ví dụ: `zhipu/glm-4.7`) để thêm provider mới — **không cần thay đổi code!**\n\nThiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn provider linh hoạt:\n\n- **Agent khác nhau, provider khác nhau**: Mỗi agent có thể sử dụng provider LLM riêng\n- **Fallback mô hình**: Cấu hình mô hình chính và dự phòng cho khả năng phục hồi\n- **Cân bằng tải**: Phân phối yêu cầu qua nhiều endpoint\n- **Cấu hình tập trung**: Quản lý tất cả provider tại một nơi\n\n#### 📋 Tất Cả Vendor Được Hỗ Trợ\n\n| Vendor              | `model` Prefix    | Default API Base                                    | Protocol  | API Key                                                          |\n| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |\n| **OpenAI**          | `openai/`         | `https://api.openai.com/v1`                         | OpenAI    | [Get Key](https://platform.openai.com)                           |\n| **Anthropic**       | `anthropic/`      | `https://api.anthropic.com/v1`                      | Anthropic | [Get Key](https://console.anthropic.com)                         |\n| **智谱 AI (GLM)**   | `zhipu/`          | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI    | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`       | `https://api.deepseek.com/v1`                       | OpenAI    | [Get Key](https://platform.deepseek.com)                         |\n| **Google Gemini**   | `gemini/`         | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI    | [Get Key](https://aistudio.google.com/api-keys)                  |\n| **Groq**            | `groq/`           | `https://api.groq.com/openai/v1`                    | OpenAI    | [Get Key](https://console.groq.com)                              |\n| **Moonshot**        | `moonshot/`       | `https://api.moonshot.cn/v1`                        | OpenAI    | [Get Key](https://platform.moonshot.cn)                          |\n| **通义千问 (Qwen)** | `qwen/`           | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI    | [Get Key](https://dashscope.console.aliyun.com)                  |\n| **NVIDIA**          | `nvidia/`         | `https://integrate.api.nvidia.com/v1`               | OpenAI    | [Get Key](https://build.nvidia.com)                              |\n| **Ollama**          | `ollama/`         | `http://localhost:11434/v1`                         | OpenAI    | Local (no key needed)                                            |\n| **OpenRouter**      | `openrouter/`     | `https://openrouter.ai/api/v1`                      | OpenAI    | [Get Key](https://openrouter.ai/keys)                            |\n| **LiteLLM Proxy**   | `litellm/`        | `http://localhost:4000/v1`                          | OpenAI    | Your LiteLLM proxy key                                            |\n| **VLLM**            | `vllm/`           | `http://localhost:8000/v1`                          | OpenAI    | Local                                                            |\n| **Cerebras**        | `cerebras/`       | `https://api.cerebras.ai/v1`                        | OpenAI    | [Get Key](https://cerebras.ai)                                   |\n| **VolcEngine (Doubao)** | `volcengine/`     | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI    | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw)                        |\n| **神算云**          | `shengsuanyun/`   | `https://router.shengsuanyun.com/api/v1`            | OpenAI    | -                                                                |\n| **BytePlus**        | `byteplus/`       | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI    | [Get Key](https://www.byteplus.com)                        |\n| **Vivgrid**         | `vivgrid/`        | `https://api.vivgrid.com/v1`                        | OpenAI    | [Get Key](https://vivgrid.com)                                   |\n| **LongCat**         | `longcat/`        | `https://api.longcat.chat/openai`                   | OpenAI    | [Get Key](https://longcat.chat/platform)                         |\n| **ModelScope (魔搭)**| `modelscope/`    | `https://api-inference.modelscope.cn/v1`            | OpenAI    | [Get Token](https://modelscope.cn/my/tokens)                     |\n| **Antigravity**     | `antigravity/`    | Google Cloud                                        | Custom    | OAuth only                                                       |\n| **GitHub Copilot**  | `github-copilot/` | `localhost:4321`                                    | gRPC      | -                                                                |\n\n#### Cấu Hình Cơ Bản\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### Ví Dụ Theo Vendor\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**VolcEngine (Doubao)**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (với API key)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"api_key\": \"sk-ant-your-key\"\n}\n```\n\n> Chạy `picoclaw auth login --provider anthropic` để dán API token.\n\n**Anthropic Messages API (định dạng native)**\n\nĐể truy cập trực tiếp API Anthropic hoặc endpoint tùy chỉnh chỉ hỗ trợ định dạng message native của Anthropic:\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> Sử dụng giao thức `anthropic-messages` khi:\n> - Sử dụng proxy bên thứ ba chỉ hỗ trợ endpoint native `/v1/messages` của Anthropic (không tương thích OpenAI `/v1/chat/completions`)\n> - Kết nối đến dịch vụ như MiniMax, Synthetic yêu cầu định dạng message native của Anthropic\n> - Giao thức `anthropic` hiện tại trả về lỗi 404 (cho thấy endpoint không hỗ trợ định dạng tương thích OpenAI)\n>\n> **Lưu ý:** Giao thức `anthropic` sử dụng định dạng tương thích OpenAI (`/v1/chat/completions`), trong khi `anthropic-messages` sử dụng định dạng native của Anthropic (`/v1/messages`). Chọn dựa trên định dạng endpoint hỗ trợ.\n\n**Ollama (local)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**Proxy/API Tùy Chỉnh**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nPicoClaw chỉ loại bỏ tiền tố ngoài `litellm/` trước khi gửi yêu cầu, nên alias proxy như `litellm/lite-gpt4` gửi `lite-gpt4`, trong khi `litellm/openai/gpt-4o` gửi `openai/gpt-4o`.\n\n#### Cân Bằng Tải\n\nCấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin giữa chúng:\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### Di Chuyển Từ Cấu Hình Legacy `providers`\n\nCấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được hỗ trợ để tương thích ngược.\n\n**Cấu hình cũ (ngừng hỗ trợ):**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**Cấu hình mới (khuyến nghị):**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\nĐể xem hướng dẫn di chuyển chi tiết, xem [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).\n\n### Kiến Trúc Provider\n\nPicoClaw định tuyến provider theo họ giao thức:\n\n- Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM.\n- Giao thức Anthropic: Hành vi API native của Claude.\n- Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI.\n\nĐiều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_key`).\n\n<details>\n<summary><b>Zhipu</b></summary>\n\n**1. Lấy API key và URL base**\n\n* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)\n\n**2. Cấu hình**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. Chạy**\n\n```bash\npicoclaw agent -m \"Hello\"\n```\n\n</details>\n\n<details>\n<summary><b>Ví dụ cấu hình đầy đủ</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 So Sánh API Key\n\n| Service          | Pricing                  | Use Case                              |\n| ---------------- | ------------------------ | ------------------------------------- |\n| **OpenRouter**   | Free: 200K tokens/month  | Multiple models (Claude, GPT-4, etc.) |\n| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |\n| **Zhipu**        | Free: 200K tokens/month  | Suitable for Chinese users                |\n| **Brave Search** | $5/1000 queries          | Web search functionality              |\n| **SearXNG**      | Free (self-hosted)       | Privacy-focused metasearch (70+ engines) |\n| **Groq**         | Free tier available      | Fast inference (Llama, Mixtral)       |\n| **Cerebras**     | Free tier available      | Fast inference (Llama, Qwen, etc.)    |\n| **LongCat**      | Free: up to 5M tokens/day | Fast inference                       |\n| **ModelScope**   | Free: 2000 requests/day  | Inference (Qwen, GLM, DeepSeek, etc.) |\n\n---\n\n<div align=\"center\">\n  <img src=\"assets/logo.jpg\" alt=\"PicoClaw Meme\" width=\"512\">\n</div>\n"
  },
  {
    "path": "docs/vi/spawn-tasks.md",
    "content": "# 🔄 Tác Vụ Bất Đồng Bộ và Spawn\n\n> Quay lại [README](../../README.vi.md)\n\n## Tác Vụ Nhanh (phản hồi trực tiếp)\n\n- Báo cáo thời gian hiện tại\n\n## Tác Vụ Dài (sử dụng spawn cho bất đồng bộ)\n\n- Tìm kiếm web tin tức AI và tóm tắt\n- Kiểm tra email và báo cáo tin nhắn quan trọng\n```\n\n**Hành vi chính:**\n\n| Feature                 | Description                                               |\n| ----------------------- | --------------------------------------------------------- |\n| **spawn**               | Creates async subagent, doesn't block heartbeat           |\n| **Independent context** | Subagent has its own context, no session history          |\n| **message tool**        | Subagent communicates with user directly via message tool |\n| **Non-blocking**        | After spawning, heartbeat continues to next task          |\n\n#### Cách Giao Tiếp Subagent Hoạt Động\n\n```\nHeartbeat được kích hoạt\n    ↓\nAgent đọc HEARTBEAT.md\n    ↓\nCho tác vụ dài: spawn subagent\n    ↓                           ↓\nTiếp tục tác vụ tiếp theo    Subagent làm việc độc lập\n    ↓                           ↓\nTất cả tác vụ hoàn thành     Subagent sử dụng công cụ \"message\"\n    ↓                           ↓\nPhản hồi HEARTBEAT_OK        Người dùng nhận kết quả trực tiếp\n```\n\nSubagent có quyền truy cập công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng độc lập mà không cần qua agent chính.\n\n**Cấu hình:**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| Option     | Default | Description                        |\n| ---------- | ------- | ---------------------------------- |\n| `enabled`  | `true`  | Enable/disable heartbeat           |\n| `interval` | `30`    | Check interval in minutes (min: 5) |\n\n**Biến môi trường:**\n\n* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt\n* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian\n"
  },
  {
    "path": "docs/vi/tools_configuration.md",
    "content": "# 🔧 Cấu Hình Công Cụ\n\n> Quay lại [README](../../README.vi.md)\n\nCấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`.\n\n## Cấu trúc thư mục\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Công cụ Web\n\nCác công cụ web được sử dụng để tìm kiếm và tải nội dung web.\n\n### Web Fetcher\nCài đặt chung để tải và xử lý nội dung trang web.\n\n| Cấu hình            | Kiểu   | Mặc định      | Mô tả                                                                                        |\n|----------------------|--------|---------------|-----------------------------------------------------------------------------------------------|\n| `enabled`            | bool   | true          | Bật khả năng tải trang web.                                                                   |\n| `fetch_limit_bytes`  | int    | 10485760      | Kích thước tối đa của payload trang web cần tải, tính bằng byte (mặc định là 10MB).          |\n| `format`             | string | \"plaintext\"   | Định dạng đầu ra của nội dung đã tải. Tùy chọn: `plaintext` hoặc `markdown` (khuyến nghị).   |\n\n### Brave\n\n| Cấu hình      | Kiểu   | Mặc định | Mô tả                     |\n|----------------|--------|----------|----------------------------|\n| `enabled`      | bool   | false    | Bật tìm kiếm Brave        |\n| `api_key`      | string | -        | Khóa API Brave Search      |\n| `max_results`  | int    | 5        | Số kết quả tối đa         |\n\n### DuckDuckGo\n\n| Cấu hình      | Kiểu | Mặc định | Mô tả                        |\n|----------------|------|----------|-------------------------------|\n| `enabled`      | bool | true     | Bật tìm kiếm DuckDuckGo      |\n| `max_results`  | int  | 5        | Số kết quả tối đa            |\n\n### Perplexity\n\n| Cấu hình      | Kiểu   | Mặc định | Mô tả                        |\n|----------------|--------|----------|-------------------------------|\n| `enabled`      | bool   | false    | Bật tìm kiếm Perplexity      |\n| `api_key`      | string | -        | Khóa API Perplexity           |\n| `max_results`  | int    | 5        | Số kết quả tối đa            |\n\n## Công cụ Exec\n\nCông cụ exec được sử dụng để thực thi các lệnh shell.\n\n| Cấu hình               | Kiểu  | Mặc định | Mô tả                                         |\n|--------------------------|-------|----------|------------------------------------------------|\n| `enabled`                | bool  | true     | Bật công cụ exec                             |\n| `enable_deny_patterns`   | bool  | true     | Bật chặn lệnh nguy hiểm mặc định             |\n| `custom_deny_patterns`   | array | []       | Mẫu từ chối tùy chỉnh (biểu thức chính quy)  |\n\n### Vô hiệu hóa Công cụ Exec\n\nĐể hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`:\n\n**Qua tệp cấu hình:**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**Qua biến môi trường:**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron.\n\n### Chức năng\n\n- **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định\n- **`custom_deny_patterns`**: Thêm các mẫu regex từ chối tùy chỉnh; các lệnh khớp sẽ bị chặn\n\n### Các mẫu lệnh bị chặn mặc định\n\nTheo mặc định, PicoClaw chặn các lệnh nguy hiểm sau:\n\n- Lệnh xóa: `rm -rf`, `del /f/q`, `rmdir /s`\n- Thao tác đĩa: `format`, `mkfs`, `diskpart`, `dd if=`, ghi vào `/dev/sd*`\n- Thao tác hệ thống: `shutdown`, `reboot`, `poweroff`\n- Thay thế lệnh: `$()`, `${}`, dấu backtick\n- Pipe đến shell: `| sh`, `| bash`\n- Leo thang đặc quyền: `sudo`, `chmod`, `chown`\n- Điều khiển tiến trình: `pkill`, `killall`, `kill -9`\n- Thao tác từ xa: `curl | sh`, `wget | sh`, `ssh`\n- Quản lý gói: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`\n- Container: `docker run`, `docker exec`\n- Git: `git push`, `git force`\n- Khác: `eval`, `source *.sh`\n\n### Hạn chế kiến trúc đã biết\n\nBộ bảo vệ exec chỉ xác thực lệnh cấp cao nhất được gửi đến PicoClaw. Nó **không** kiểm tra đệ quy các tiến trình con được tạo bởi các công cụ build hoặc script sau khi lệnh đó bắt đầu chạy.\n\nVí dụ về các quy trình có thể bỏ qua bộ bảo vệ lệnh trực tiếp sau khi lệnh ban đầu được cho phép:\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\nĐiều này có nghĩa là bộ bảo vệ hữu ích để chặn các lệnh trực tiếp rõ ràng nguy hiểm, nhưng nó **không phải** là sandbox đầy đủ cho các pipeline build chưa được xem xét. Nếu mô hình mối đe dọa của bạn bao gồm mã không đáng tin cậy trong workspace, hãy sử dụng cách ly mạnh hơn như container, VM hoặc quy trình phê duyệt xung quanh các lệnh build và chạy.\n\n### Ví dụ cấu hình\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Công cụ Cron\n\nCông cụ cron được sử dụng để lên lịch các tác vụ định kỳ.\n\n| Cấu hình               | Kiểu | Mặc định | Mô tả                                              |\n|--------------------------|------|----------|-----------------------------------------------------|\n| `exec_timeout_minutes`   | int  | 5        | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn |\n\n## Công cụ MCP\n\nCông cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài.\n\n### Khám phá công cụ (tải chậm)\n\nKhi kết nối với nhiều máy chủ MCP, việc hiển thị hàng trăm công cụ cùng lúc có thể làm cạn kiệt cửa sổ ngữ cảnh của LLM và tăng chi phí API. Tính năng **Discovery** giải quyết vấn đề này bằng cách giữ các công cụ MCP *ẩn* theo mặc định.\n\nThay vì tải tất cả các công cụ, LLM được cung cấp một công cụ tìm kiếm nhẹ (sử dụng khớp từ khóa BM25 hoặc Regex). Khi LLM cần một khả năng cụ thể, nó tìm kiếm trong thư viện ẩn. Các công cụ khớp sau đó được tạm thời \"mở khóa\" và đưa vào ngữ cảnh trong số lượt được cấu hình (`ttl`).\n\n### Cấu hình toàn cục\n\n| Cấu hình    | Kiểu   | Mặc định | Mô tả                                        |\n|-------------|--------|----------|-----------------------------------------------|\n| `enabled`   | bool   | false    | Bật tích hợp MCP toàn cục                    |\n| `discovery` | object | `{}`     | Cấu hình khám phá công cụ (xem bên dưới)    |\n| `servers`   | object | `{}`     | Ánh xạ tên máy chủ đến cấu hình máy chủ     |\n\n### Cấu hình Discovery (`discovery`)\n\n| Cấu hình             | Kiểu | Mặc định | Mô tả                                                                                                                            |\n|----------------------|------|----------|-----------------------------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false    | Nếu true, các công cụ MCP bị ẩn và được tải theo yêu cầu qua tìm kiếm. Nếu false, tất cả công cụ được tải                      |\n| `ttl`                | int  | 5        | Số lượt hội thoại mà một công cụ đã khám phá vẫn được mở khóa                                                                   |\n| `max_search_results` | int  | 5        | Số công cụ tối đa được trả về cho mỗi truy vấn tìm kiếm                                                                         |\n| `use_bm25`           | bool | true     | Bật công cụ tìm kiếm ngôn ngữ tự nhiên/từ khóa (`tool_search_tool_bm25`). **Cảnh báo**: tiêu tốn nhiều tài nguyên hơn tìm kiếm regex |\n| `use_regex`          | bool | false    | Bật công cụ tìm kiếm mẫu regex (`tool_search_tool_regex`)                                                                        |\n\n> **Lưu ý:** Nếu `discovery.enabled` là `true`, bạn **phải** bật ít nhất một công cụ tìm kiếm (`use_bm25` hoặc `use_regex`),\n> nếu không ứng dụng sẽ không khởi động được.\n\n### Cấu hình từng máy chủ\n\n| Cấu hình   | Kiểu   | Bắt buộc | Mô tả                                     |\n|------------|--------|----------|--------------------------------------------|\n| `enabled`  | bool   | có       | Bật máy chủ MCP này                       |\n| `type`     | string | không    | Loại truyền tải: `stdio`, `sse`, `http`   |\n| `command`  | string | stdio    | Lệnh thực thi cho truyền tải stdio        |\n| `args`     | array  | không    | Đối số lệnh cho truyền tải stdio          |\n| `env`      | object | không    | Biến môi trường cho tiến trình stdio      |\n| `env_file` | string | không    | Đường dẫn đến tệp môi trường cho tiến trình stdio |\n| `url`      | string | sse/http | URL endpoint cho truyền tải `sse`/`http`  |\n| `headers`  | object | không    | Header HTTP cho truyền tải `sse`/`http`   |\n\n### Hành vi truyền tải\n\n- Nếu bỏ qua `type`, truyền tải được tự động phát hiện:\n    - `url` được đặt → `sse`\n    - `command` được đặt → `stdio`\n- `http` và `sse` đều sử dụng `url` + `headers` tùy chọn.\n- `env` và `env_file` chỉ được áp dụng cho máy chủ `stdio`.\n\n### Ví dụ cấu hình\n\n#### 1) Máy chủ MCP Stdio\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) Máy chủ MCP từ xa SSE/HTTP\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) Thiết lập MCP quy mô lớn với khám phá công cụ được bật\n\n*Trong ví dụ này, LLM chỉ thấy `tool_search_tool_bm25`. Nó sẽ tìm kiếm và mở khóa động các công cụ Github hoặc Postgres chỉ khi được người dùng yêu cầu.*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n## Công cụ Skills\n\nCông cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub.\n\n### Registry\n\n| Cấu hình                          | Kiểu   | Mặc định             | Mô tả                                       |\n|------------------------------------|--------|-----------------------|----------------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                  | Bật registry ClawHub                         |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai`  | URL cơ sở ClawHub                            |\n| `registries.clawhub.auth_token`    | string | `\"\"`                  | Token Bearer tùy chọn để có giới hạn tốc độ cao hơn |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`      | Đường dẫn API tìm kiếm                      |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`      | Đường dẫn API Skills                         |\n| `registries.clawhub.download_path` | string | `/api/v1/download`    | Đường dẫn API tải xuống                      |\n\n### Ví dụ cấu hình\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## Biến môi trường\n\nTất cả các tùy chọn cấu hình có thể được ghi đè qua biến môi trường với định dạng `PICOCLAW_TOOLS_<SECTION>_<KEY>`:\n\nVí dụ:\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\nLưu ý: Cấu hình kiểu map lồng nhau (ví dụ `tools.mcp.servers.<name>.*`) được cấu hình trong `config.json` thay vì qua biến môi trường.\n"
  },
  {
    "path": "docs/vi/troubleshooting.md",
    "content": "# 🐛 Khắc Phục Sự Cố\n\n> Quay lại [README](../../README.vi.md)\n\n## \"model ... not found in model_list\" hoặc OpenRouter \"free is not a valid model ID\"\n\n**Triệu chứng:** Bạn thấy một trong các lỗi sau:\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter trả về 400: `\"free is not a valid model ID\"`\n\n**Nguyên nhân:** Trường `model` trong mục `model_list` của bạn là giá trị được gửi đến API. Đối với OpenRouter, bạn phải sử dụng ID mô hình **đầy đủ**, không phải dạng viết tắt.\n\n- **Sai:** `\"model\": \"free\"` → OpenRouter nhận được `free` và từ chối.\n- **Đúng:** `\"model\": \"openrouter/free\"` → OpenRouter nhận được `openrouter/free` (định tuyến tự động tầng miễn phí).\n\n**Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn):\n\n1. **agents.defaults.model** phải khớp với một `model_name` trong `model_list` (ví dụ: `\"openrouter-free\"`).\n2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ:\n   - `\"openrouter/free\"` – tầng miễn phí tự động\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\nVí dụ:\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\nLấy khóa của bạn tại [OpenRouter Keys](https://openrouter.ai/keys).\n"
  },
  {
    "path": "docs/zh/chat-apps.md",
    "content": "# 💬 聊天应用配置\n\n> 返回 [README](../../README.zh.md)\n\n## 💬 聊天应用集成 (Chat Apps)\n\nPicoClaw 支持多种聊天平台，使您的 Agent 能够连接到任何地方。\n\n> **注意**: 所有 Webhook 类渠道（LINE、WeCom 等）均挂载在同一个 Gateway HTTP 服务器上（`gateway.host`:`gateway.port`，默认 `127.0.0.1:18790`），无需为每个渠道单独配置端口。注意：飞书（Feishu）使用 WebSocket/SDK 模式，不通过该共享 HTTP webhook 服务器接收消息。\n\n### 核心渠道\n\n| 渠道                 | 设置难度    | 特性说明                                  | 文档链接                                                                                                        |\n| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |\n| **Telegram**         | ⭐ 简单     | 推荐，支持语音转文字，长轮询无需公网      | [查看文档](../channels/telegram/README.zh.md)                                                                 |\n| **Discord**          | ⭐ 简单     | Socket Mode，支持群组/私信，Bot 生态成熟  | [查看文档](../channels/discord/README.zh.md)                                                                  |\n| **WhatsApp**         | ⭐ 简单     | 原生 (QR 扫码) 或 Bridge URL              | [查看文档](../channels/whatsapp/README.zh.md)                                                                 |\n| **Slack**            | ⭐ 简单     | **Socket Mode** (无需公网 IP)，企业级支持 | [查看文档](../channels/slack/README.zh.md)                                                                    |\n| **Matrix**           | ⭐⭐ 中等   | 联邦协议，支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md)                                                                  |\n| **QQ**               | ⭐⭐ 中等   | 官方机器人 API，适合国内社群              | [查看文档](../channels/qq/README.zh.md)                                                                       |\n| **钉钉 (DingTalk)**  | ⭐⭐ 中等   | Stream 模式无需公网，企业办公首选         | [查看文档](../channels/dingtalk/README.zh.md)                                                                 |\n| **LINE**             | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook                        | [查看文档](../channels/line/README.zh.md)                                                                     |\n| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) |\n| **飞书 (Feishu)**    | ⭐⭐⭐ 较难 | 企业级协作，功能丰富                      | [查看文档](../channels/feishu/README.zh.md)                                                                   |\n| **IRC**              | ⭐⭐ 中等   | 服务器 + TLS 配置                         | -                                                                                                               |\n| **OneBot**           | ⭐⭐ 中等   | 兼容 NapCat/Go-CQHTTP，社区生态丰富       | [查看文档](../channels/onebot/README.zh.md)                                                                   |\n| **MaixCam**          | ⭐ 简单     | 专为 AI 摄像头设计的硬件集成通道          | [查看文档](../channels/maixcam/README.zh.md)                                                                  |\n| **Pico**             | ⭐ 简单     | PicoClaw 原生协议通道                     |                                                                                                               |\n\n---\n\n<details>\n<summary><b>Telegram</b>（推荐）</summary>\n\n**1. 创建 Bot**\n\n* 打开 Telegram，搜索 `@BotFather`\n* 发送 `/newbot`，按提示操作\n* 复制 Token\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n> 通过 Telegram 上的 `@userinfobot` 获取你的 User ID。\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n**4. Telegram 命令菜单（启动时自动注册）**\n\nPicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令（例如 `/start`、`/help`、`/show`、`/list`）注册到 Bot 命令菜单，确保菜单展示与实际行为一致。\nTelegram 侧保留的是命令菜单注册能力；通用命令的实际执行统一走 Agent Loop 中的 commands executor。\n\n如果注册因网络或 API 短暂异常失败，不会阻塞 channel 启动；系统会在后台自动重试。\n\n</details>\n\n<details>\n<summary><b>Discord</b></summary>\n\n**1. 创建 Bot**\n\n* 前往 <https://discord.com/developers/applications>\n* 创建应用 → Bot → 添加 Bot\n* 复制 Bot Token\n\n**2. 启用 Intents**\n\n* 在 Bot 设置中启用 **MESSAGE CONTENT INTENT**\n* （可选）启用 **SERVER MEMBERS INTENT**（如需基于成员数据的白名单）\n\n**3. 获取 User ID**\n\n* Discord 设置 → 高级 → 启用 **开发者模式**\n* 右键点击头像 → **复制用户 ID**\n\n**4. 配置**\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_BOT_TOKEN\",\n      \"allow_from\": [\"YOUR_USER_ID\"]\n    }\n  }\n}\n```\n\n**5. 邀请 Bot**\n\n* OAuth2 → URL Generator\n* Scopes: `bot`\n* Bot Permissions: `Send Messages`, `Read Message History`\n* 打开生成的邀请链接，将 Bot 添加到服务器\n\n**可选：群组触发模式**\n\n默认情况下 Bot 会回复服务器频道中的所有消息。如需仅在 @提及时回复：\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"mention_only\": true }\n    }\n  }\n}\n```\n\n也可通过关键词前缀触发（如 `!bot`）：\n\n```json\n{\n  \"channels\": {\n    \"discord\": {\n      \"group_trigger\": { \"prefixes\": [\"!bot\"] }\n    }\n  }\n}\n```\n\n**6. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>WhatsApp</b>（原生 whatsmeow）</summary>\n\nPicoClaw 支持两种 WhatsApp 连接方式：\n\n- **原生（推荐）：** 进程内使用 [whatsmeow](https://github.com/tulir/whatsmeow)，无需独立 Bridge。设置 `\"use_native\": true` 并留空 `bridge_url`。首次运行时用 WhatsApp 扫描 QR 码（关联设备）。会话存储在工作区下（如 `workspace/whatsapp/`）。原生渠道为**可选**构建，使用 `-tags whatsapp_native` 编译（如 `make build-whatsapp-native` 或 `go build -tags whatsapp_native ./cmd/...`）。\n- **Bridge：** 连接外部 WebSocket Bridge。设置 `bridge_url`（如 `ws://localhost:3001`），保持 `use_native` 为 false。\n\n**配置（原生）**\n\n```json\n{\n  \"channels\": {\n    \"whatsapp\": {\n      \"enabled\": true,\n      \"use_native\": true,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n如果 `session_store_path` 为空，会话存储在 `<workspace>/whatsapp/`。运行 `picoclaw gateway`；首次运行时在终端扫描 QR 码（WhatsApp → 关联设备）。\n\n</details>\n\n<details>\n<summary><b>Matrix</b></summary>\n\n**1. 准备 Bot 账号**\n\n* 使用你的 homeserver（如 `https://matrix.org` 或自建）\n* 创建 Bot 用户并获取 access token\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"matrix\": {\n      \"enabled\": true,\n      \"homeserver\": \"https://matrix.org\",\n      \"user_id\": \"@your-bot:matrix.org\",\n      \"access_token\": \"YOUR_MATRIX_ACCESS_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n完整选项（`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`）请参考 [Matrix 渠道配置指南](../channels/matrix/README.md)。\n\n</details>\n\n<details>\n<summary><b>QQ</b></summary>\n\n**1. 创建 Bot**\n\n- 前往 [QQ 开放平台](https://q.qq.com/#)\n- 创建应用 → 获取 **AppID** 和 **AppSecret**\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"qq\": {\n      \"enabled\": true,\n      \"app_id\": \"YOUR_APP_ID\",\n      \"app_secret\": \"YOUR_APP_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> `allow_from` 留空表示允许所有用户，或指定 QQ 号限制访问。\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>Slack</b></summary>\n\n**1. 创建 Slack App**\n\n* 前往 [Slack API](https://api.slack.com/apps) 创建应用\n* 启用 **Socket Mode**\n* 获取 **Bot Token** 和 **App-Level Token**\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"slack\": {\n      \"enabled\": true,\n      \"bot_token\": \"xoxb-YOUR_BOT_TOKEN\",\n      \"app_token\": \"xapp-YOUR_APP_TOKEN\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>IRC</b></summary>\n\n**1. 配置**\n\n```json\n{\n  \"channels\": {\n    \"irc\": {\n      \"enabled\": true,\n      \"server\": \"irc.libera.chat:6697\",\n      \"nick\": \"picoclaw-bot\",\n      \"use_tls\": true,\n      \"channels_to_join\": [\"#your-channel\"],\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**2. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>钉钉 (DingTalk)</b></summary>\n\n**1. 创建 Bot**\n\n* 前往 [开放平台](https://open.dingtalk.com/)\n* 创建内部应用\n* 复制 Client ID 和 Client Secret\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"dingtalk\": {\n      \"enabled\": true,\n      \"client_id\": \"YOUR_CLIENT_ID\",\n      \"client_secret\": \"YOUR_CLIENT_SECRET\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> `allow_from` 留空表示允许所有用户，或指定钉钉用户 ID 限制访问。\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>LINE</b></summary>\n\n**1. 创建 LINE Official Account**\n\n- 前往 [LINE Developers Console](https://developers.line.biz/)\n- 创建 Provider → 创建 Messaging API Channel\n- 复制 **Channel Secret** 和 **Channel Access Token**\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"line\": {\n      \"enabled\": true,\n      \"channel_secret\": \"YOUR_CHANNEL_SECRET\",\n      \"channel_access_token\": \"YOUR_CHANNEL_ACCESS_TOKEN\",\n      \"webhook_path\": \"/webhook/line\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> LINE Webhook 挂载在共享 Gateway 服务器上（`gateway.host`:`gateway.port`，默认 `127.0.0.1:18790`）。\n\n**3. 设置 Webhook URL**\n\nLINE 要求 HTTPS Webhook。使用反向代理或隧道：\n\n```bash\n# 示例：使用 ngrok（Gateway 默认端口 18790）\nngrok http 18790\n```\n\n然后在 LINE Developers Console 中将 Webhook URL 设置为 `https://your-domain/webhook/line` 并启用 **Use webhook**。\n\n**4. 运行**\n\n```bash\npicoclaw gateway\n```\n\n> 在群聊中，Bot 仅在被 @提及时回复。回复会引用原始消息。\n\n</details>\n\n<details>\n<summary><b>飞书 (Feishu)</b></summary>\n\n**1. 创建应用**\n\n* 前往 [飞书开放平台](https://open.feishu.cn/)\n* 创建企业自建应用\n* 获取 **App ID** 和 **App Secret**\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"feishu\": {\n      \"enabled\": true,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>企业微信 (WeCom)</b></summary>\n\nPicoClaw 支持三种企业微信集成方式：\n\n**方式 1: 群机器人 (Bot)** — 设置简单，支持群聊\n**方式 2: 自建应用 (App)** — 功能更多，支持主动推送，仅私聊\n**方式 3: 智能机器人 (AI Bot)** — 官方 AI Bot，流式回复，支持群聊和私聊\n\n详细设置请参考 [企业微信 AI Bot 配置指南](../channels/wecom/wecom_aibot/README.zh.md)。\n\n**快速设置 — 群机器人：**\n\n**1. 创建 Bot**\n\n* 企业微信管理后台 → 群聊 → 添加群机器人\n* 复制 Webhook URL（格式：`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`）\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"wecom\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY\",\n      \"webhook_path\": \"/webhook/wecom\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n> WeCom Webhook 挂载在共享 Gateway 服务器上（`gateway.host`:`gateway.port`，默认 `127.0.0.1:18790`）。\n\n**快速设置 — 自建应用：**\n\n**1. 创建应用**\n\n* 企业微信管理后台 → 应用管理 → 创建应用\n* 复制 **AgentId** 和 **Secret**\n* 前往\"我的企业\"页面，复制 **CorpID**\n\n**2. 配置接收消息**\n\n* 在应用详情中，点击\"接收消息\" → \"设置 API\"\n* 设置 URL 为 `http://your-server:18790/webhook/wecom-app`\n* 生成 **Token** 和 **EncodingAESKey**\n\n**3. 配置**\n\n```json\n{\n  \"channels\": {\n    \"wecom_app\": {\n      \"enabled\": true,\n      \"corp_id\": \"wwxxxxxxxxxxxxxxxx\",\n      \"corp_secret\": \"YOUR_CORP_SECRET\",\n      \"agent_id\": 1000002,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-app\",\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**4. 运行**\n\n```bash\npicoclaw gateway\n```\n\n> **注意**: WeCom Webhook 回调挂载在 Gateway 端口（默认 18790）。使用反向代理配置 HTTPS。\n\n**快速设置 — 智能机器人 (AI Bot)：**\n\n**1. 创建 AI Bot**\n\n* 企业微信管理后台 → 应用管理 → AI Bot\n* 在 AI Bot 设置中配置回调 URL：`http://your-server:18791/webhook/wecom-aibot`\n* 复制 **Token** 并点击\"随机生成\" **EncodingAESKey**\n\n**2. 配置**\n\n```json\n{\n  \"channels\": {\n    \"wecom_aibot\": {\n      \"enabled\": true,\n      \"token\": \"YOUR_TOKEN\",\n      \"encoding_aes_key\": \"YOUR_43_CHAR_ENCODING_AES_KEY\",\n      \"webhook_path\": \"/webhook/wecom-aibot\",\n      \"allow_from\": [],\n      \"welcome_message\": \"你好！有什么可以帮你的？\",\n      \"processing_message\": \"⏳ Processing, please wait. The results will be sent shortly.\"\n    }\n  }\n}\n```\n\n**3. 运行**\n\n```bash\npicoclaw gateway\n```\n\n> **注意**: 企业微信 AI Bot 使用流式拉取协议，无回复超时问题。长任务（>30 秒）会自动切换到 `response_url` 推送投递。\n\n</details>\n\n<details>\n<summary><b>OneBot</b></summary>\n\n**1. 配置**\n\n兼容 NapCat / Go-CQHTTP 等 OneBot 实现。\n\n```json\n{\n  \"channels\": {\n    \"onebot\": {\n      \"enabled\": true,\n      \"allow_from\": []\n    }\n  }\n}\n```\n\n**2. 运行**\n\n```bash\npicoclaw gateway\n```\n\n</details>\n\n<details>\n<summary><b>MaixCam</b></summary>\n\n专为 Sipeed AI 摄像头硬件设计的集成通道。\n\n```json\n{\n  \"channels\": {\n    \"maixcam\": {\n      \"enabled\": true\n    }\n  }\n}\n```\n\n```bash\npicoclaw gateway\n```\n\n</details>\n"
  },
  {
    "path": "docs/zh/configuration.md",
    "content": "# ⚙️ 配置指南\n\n> 返回 [README](../../README.zh.md)\n\n## ⚙️ 配置详解\n\n配置文件路径: `~/.picoclaw/config.json`\n\n### 环境变量\n\n你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的，控制不同的路径。\n\n| 变量              | 描述                                                                                                                             | 默认路径                  |\n|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|\n| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`，忽略所有其他位置。 | `~/.picoclaw/config.json` |\n| `PICOCLAW_HOME`   | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。          | `~/.picoclaw`             |\n\n**示例：**\n\n```bash\n# 使用特定的配置文件运行 picoclaw\n# 工作区路径将从该配置文件中读取\nPICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway\n\n# 在 /opt/picoclaw 中存储所有数据运行 picoclaw\n# 配置将从默认的 ~/.picoclaw/config.json 加载\n# 工作区将在 /opt/picoclaw/workspace 创建\nPICOCLAW_HOME=/opt/picoclaw picoclaw agent\n\n# 同时使用两者进行完全自定义设置\nPICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway\n```\n\n### 工作区布局 (Workspace Layout)\n\nPicoClaw 将数据存储在您配置的工作区中（默认：`~/.picoclaw/workspace`）：\n\n```\n~/.picoclaw/workspace/\n├── sessions/          # 对话会话和历史\n├── memory/           # 长期记忆 (MEMORY.md)\n├── state/            # 持久化状态 (最后一次频道等)\n├── cron/             # 定时任务数据库\n├── skills/           # 自定义技能\n├── AGENT.md          # Agent 行为指南\n├── HEARTBEAT.md      # 周期性任务提示词 (每 30 分钟检查一次)\n├── IDENTITY.md       # Agent 身份设定\n├── SOUL.md           # Agent 灵魂/性格\n└── USER.md           # 用户偏好\n```\n\n> **提示：** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间（mtime）在运行时自动检测。**无需重启 gateway**，Agent 将在下一次请求时自动加载最新内容。\n\n### 技能来源 (Skill Sources)\n\n默认情况下，技能会按以下顺序加载：\n\n1. `~/.picoclaw/workspace/skills`（工作区）\n2. `~/.picoclaw/skills`（全局）\n3. `<current-working-directory>/skills`（内置）\n\n在高级/测试场景下，可通过以下环境变量覆盖内置技能目录：\n\n```bash\nexport PICOCLAW_BUILTIN_SKILLS=/path/to/skills\n```\n\n### 统一命令执行策略\n\n- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。\n- Channel 适配器不再在本地消费通用命令；它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。\n- 未注册的斜杠命令（例如 `/foo`）会透传给 LLM 按普通输入处理。\n- 已注册但当前 channel 不支持的命令（例如 WhatsApp 上的 `/show`）会返回明确的用户可见错误，并停止后续处理。\n\n### 🔒 安全沙箱 (Security Sandbox)\n\nPicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。\n\n#### 默认配置\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"restrict_to_workspace\": true\n    }\n  }\n}\n```\n\n| 选项                    | 默认值                  | 描述                          |\n| ----------------------- | ----------------------- | ----------------------------- |\n| `workspace`             | `~/.picoclaw/workspace` | Agent 的工作目录              |\n| `restrict_to_workspace` | `true`                  | 限制文件/命令访问在工作区内   |\n\n#### 受保护的工具\n\n当 `restrict_to_workspace: true` 时，以下工具会被沙箱化：\n\n| 工具          | 功能         | 限制                           |\n| ------------- | ------------ | ------------------------------ |\n| `read_file`   | 读取文件     | 仅限工作区内的文件             |\n| `write_file`  | 写入文件     | 仅限工作区内的文件             |\n| `list_dir`    | 列出目录     | 仅限工作区内的目录             |\n| `edit_file`   | 编辑文件     | 仅限工作区内的文件             |\n| `append_file` | 追加文件     | 仅限工作区内的文件             |\n| `exec`        | 执行命令     | 命令路径必须在工作区内         |\n\n#### 额外的 Exec 保护\n\n即使 `restrict_to_workspace: false`，`exec` 工具也会阻止以下危险命令：\n\n* `rm -rf`、`del /f`、`rmdir /s` — 批量删除\n* `format`、`mkfs`、`diskpart` — 磁盘格式化\n* `dd if=` — 磁盘镜像\n* 写入 `/dev/sd[a-z]` — 直接磁盘写入\n* `shutdown`、`reboot`、`poweroff` — 系统关机\n* Fork bomb `:(){ :|:& };:`\n\n### 文件访问控制\n\n| 配置键 | 类型 | 默认值 | 描述 |\n|--------|------|--------|------|\n| `tools.allow_read_paths` | string[] | `[]` | 允许在工作区外读取的额外路径 |\n| `tools.allow_write_paths` | string[] | `[]` | 允许在工作区外写入的额外路径 |\n\n### Exec 安全配置\n\n| 配置键 | 类型 | 默认值 | 描述 |\n|--------|------|--------|------|\n| `tools.exec.allow_remote` | bool | `false` | 允许从远程渠道（Telegram/Discord 等）执行 exec 工具 |\n| `tools.exec.enable_deny_patterns` | bool | `true` | 启用危险命令拦截 |\n| `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 |\n| `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 |\n\n> **安全提示：** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析，防止符号链接逃逸攻击。\n\n#### 已知限制：构建工具的子进程\n\nexec 安全守卫仅检查 PicoClaw 直接启动的命令行。它不会递归检查由 `make`、`go run`、`cargo`、`npm run` 或自定义构建脚本等开发工具产生的子进程。\n\n这意味着顶层命令通过初始守卫检查后，仍可以编译或启动其他二进制文件。实际上，应将构建脚本、Makefile、包脚本和生成的二进制文件视为与直接 shell 命令同等级别的可执行代码进行审查。\n\n对于高风险环境：\n\n* 执行前审查构建脚本。\n* 对编译并运行的工作流优先使用审批/手动审查。\n* 如果需要比内置守卫更强的隔离，请在容器或虚拟机中运行 PicoClaw。\n\n#### 错误示例\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (path outside working dir)}\n```\n\n```\n[ERROR] tool: Tool execution failed\n{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}\n```\n\n#### 禁用限制（安全风险）\n\n如果需要 Agent 访问工作区外的路径：\n\n**方法 1: 配置文件**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"restrict_to_workspace\": false\n    }\n  }\n}\n```\n\n**方法 2: 环境变量**\n\n```bash\nexport PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false\n```\n\n> ⚠️ **警告**: 禁用此限制将允许 Agent 访问系统上的任何路径。仅在受控环境中谨慎使用。\n\n#### 安全边界一致性\n\n`restrict_to_workspace` 设置在所有执行路径中一致应用：\n\n| 执行路径         | 安全边界                     |\n| ---------------- | ---------------------------- |\n| 主 Agent         | `restrict_to_workspace` ✅   |\n| 子 Agent / Spawn | 继承相同限制 ✅              |\n| 心跳任务         | 继承相同限制 ✅              |\n\n所有路径共享相同的工作区限制——无法通过子 Agent 或定时任务绕过安全边界。\n\n### 心跳 / 周期性任务 (Heartbeat)\n\nPicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件：\n\n```markdown\n# Periodic Tasks\n\n- Check my email for important messages\n- Review my calendar for upcoming events\n- Check the weather forecast\n```\n\nAgent 将每隔 30 分钟（可配置）读取此文件，并使用可用工具执行任务。\n\n#### 使用 Spawn 的异步任务\n\n对于耗时较长的任务（网络搜索、API 调用），使用 `spawn` 工具创建一个 **子 Agent (subagent)**：\n\n```markdown\n# Periodic Tasks\n\n## Quick Tasks (respond directly)\n\n- Report current time\n\n## Long Tasks (use spawn for async)\n\n- Search the web for AI news and summarize\n- Check email and report important messages\n```\n\n**关键行为：**\n\n| 特性             | 描述                                     |\n| ---------------- | ---------------------------------------- |\n| **spawn**        | 创建异步子 Agent，不阻塞主心跳进程       |\n| **独立上下文**   | 子 Agent 拥有独立上下文，无会话历史      |\n| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |\n| **非阻塞**       | spawn 后，心跳继续处理下一个任务         |\n\n**配置：**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| 选项       | 默认值 | 描述                         |\n| ---------- | ------ | ---------------------------- |\n| `enabled`  | `true` | 启用/禁用心跳                |\n| `interval` | `30`   | 检查间隔，单位分钟 (最小: 5) |\n\n**环境变量:**\n\n- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用\n- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔\n"
  },
  {
    "path": "docs/zh/docker.md",
    "content": "# 🐳 Docker 与快速开始\n\n> 返回 [README](../../README.zh.md)\n\n## 🐳 Docker Compose\n\n您也可以使用 Docker Compose 运行 PicoClaw，无需在本地安装任何环境。\n\n```bash\n# 1. 克隆仓库\ngit clone https://github.com/sipeed/picoclaw.git\ncd picoclaw\n\n# 2. 首次运行 — 自动生成 docker/data/config.json 后退出\ndocker compose -f docker/docker-compose.yml --profile gateway up\n# 容器打印 \"First-run setup complete.\" 后自动停止\n\n# 3. 填写 API Key 等配置\nvim docker/data/config.json   # 设置 provider API key、Bot Token 等\n\n# 4. 正式启动\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n> [!TIP]\n> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`，该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口，请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。\n\n```bash\n# 5. 查看日志\ndocker compose -f docker/docker-compose.yml logs -f picoclaw-gateway\n\n# 6. 停止\ndocker compose -f docker/docker-compose.yml --profile gateway down\n```\n\n### Launcher 模式 (Web 控制台)\n\n`launcher` 镜像包含所有三个二进制文件（`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`），默认启动 Web 控制台，提供基于浏览器的配置和聊天界面。\n\n```bash\ndocker compose -f docker/docker-compose.yml --profile launcher up -d\n```\n\n在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。\n\n> [!WARNING]\n> Web 控制台尚不支持身份验证。请勿将其暴露到公网。\n\n### Agent 模式 (一次性运行)\n\n```bash\n# 提问\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m \"2+2 等于几？\"\n\n# 交互模式\ndocker compose -f docker/docker-compose.yml run --rm picoclaw-agent\n```\n\n### 更新镜像\n\n```bash\ndocker compose -f docker/docker-compose.yml pull\ndocker compose -f docker/docker-compose.yml --profile gateway up -d\n```\n\n---\n\n## 🚀 快速开始\n\n> [!TIP]\n> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。\n\n**1. 初始化 (Initialize)**\n\n```bash\npicoclaw onboard\n```\n\n**2. 配置 (Configure)** (`~/.picoclaw/config.json`)\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model_name\": \"gpt-5.4\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\",\n      \"api_base\":\"https://ark.cn-beijing.volces.com/api/coding/v3\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"your-api-key\",\n      \"request_timeout\": 300\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"your-anthropic-key\"\n    }\n  ],\n  \"tools\": {\n    \"web\": {\n      \"enabled\": true,\n      \"fetch_limit_bytes\": 10485760,\n      \"format\": \"plaintext\",\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_BRAVE_API_KEY\",\n        \"max_results\": 5\n      },\n      \"tavily\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_TAVILY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"YOUR_PERPLEXITY_API_KEY\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://your-searxng-instance:8888\",\n        \"max_results\": 5\n      }\n    }\n  }\n}\n```\n\n> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。\n> `request_timeout` 为可选项，单位为秒。若省略或设置为 `<= 0`，PicoClaw 使用默认超时（120 秒）。\n\n**3. 获取 API Key**\n\n* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)\n* **网络搜索** (可选):\n  * [Brave Search](https://brave.com/search/api) - 付费 ($5/1000 次查询，约 $5-6/月)\n  * [Perplexity](https://www.perplexity.ai) - AI 驱动的搜索与聊天界面\n  * [SearXNG](https://github.com/searxng/searxng) - 自建元搜索引擎（免费，无需 API Key）\n  * [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月)\n  * DuckDuckGo - 内置回退（无需 API Key）\n\n> **注意**: 完整的配置模板请参考 `config.example.json`。\n\n**4. 对话 (Chat)**\n\n```bash\npicoclaw agent -m \"2+2 等于几？\"\n```\n\n就是这样！您在 2 分钟内就拥有了一个可工作的 AI 助手。\n\n---\n"
  },
  {
    "path": "docs/zh/providers.md",
    "content": "# 🔌 提供商与模型配置\n\n> 返回 [README](../../README.zh.md)\n\n### 提供商 (Providers)\n\n> [!NOTE]\n> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq，任意渠道的音频消息都将在 Agent 层面自动转录为文字。\n\n| 提供商               | 用途                         | 获取 API Key                                                         |\n| -------------------- | ---------------------------- | -------------------------------------------------------------------- |\n| `gemini`             | LLM (Gemini 直连)            | [aistudio.google.com](https://aistudio.google.com)                   |\n| `zhipu`              | LLM (智谱直连)               | [bigmodel.cn](https://bigmodel.cn)                                   |\n| `volcengine`         | LLM (火山引擎直连)           | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |\n| `openrouter`         | LLM (推荐，可访问所有模型)   | [openrouter.ai](https://openrouter.ai)                               |\n| `anthropic`          | LLM (Claude 直连)            | [console.anthropic.com](https://console.anthropic.com)               |\n| `openai`             | LLM (GPT 直连)               | [platform.openai.com](https://platform.openai.com)                   |\n| `deepseek`           | LLM (DeepSeek 直连)          | [platform.deepseek.com](https://platform.deepseek.com)               |\n| `qwen`               | LLM (通义千问)               | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |\n| `groq`               | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com)                         |\n| `cerebras`           | LLM (Cerebras 直连)          | [cerebras.ai](https://cerebras.ai)                                   |\n| `vivgrid`            | LLM (Vivgrid 直连)           | [vivgrid.com](https://vivgrid.com)                                   |\n| `moonshot`           | LLM (Kimi/Moonshot 直连)     | [platform.moonshot.cn](https://platform.moonshot.cn)                 |\n| `minimax`            | LLM (Minimax 直连)           | [platform.minimaxi.com](https://platform.minimaxi.com)              |\n| `avian`              | LLM (Avian 直连)             | [avian.io](https://avian.io)                                         |\n| `mistral`            | LLM (Mistral 直连)           | [console.mistral.ai](https://console.mistral.ai)                    |\n| `longcat`            | LLM (Longcat 直连)           | [longcat.ai](https://longcat.ai)                                     |\n| `modelscope`         | LLM (ModelScope 直连)        | [modelscope.cn](https://modelscope.cn)                               |\n\n### 模型配置 (model_list)\n\n> **新功能！** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式（如 `zhipu/glm-4.7`）即可添加新的 provider——**无需修改任何代码！**\n\n该设计同时支持**多 Agent 场景**，提供灵活的 Provider 选择：\n\n- **不同 Agent 使用不同 Provider**：每个 Agent 可以使用自己的 LLM provider\n- **模型回退（Fallback）**：配置主模型和备用模型，提高可靠性\n- **负载均衡**：在多个 API 端点之间分配请求\n- **集中化配置**：在一个地方管理所有 provider\n\n#### 📋 所有支持的厂商\n\n| 厂商                | `model` 前缀      | 默认 API Base                                       | 协议      | 获取 API Key                                                      |\n| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |\n| **OpenAI**          | `openai/`         | `https://api.openai.com/v1`                         | OpenAI    | [获取密钥](https://platform.openai.com)                           |\n| **Anthropic**       | `anthropic/`      | `https://api.anthropic.com/v1`                      | Anthropic | [获取密钥](https://console.anthropic.com)                         |\n| **智谱 AI (GLM)**   | `zhipu/`          | `https://open.bigmodel.cn/api/paas/v4`              | OpenAI    | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |\n| **DeepSeek**        | `deepseek/`       | `https://api.deepseek.com/v1`                       | OpenAI    | [获取密钥](https://platform.deepseek.com)                         |\n| **Google Gemini**   | `gemini/`         | `https://generativelanguage.googleapis.com/v1beta`  | OpenAI    | [获取密钥](https://aistudio.google.com/api-keys)                  |\n| **Groq**            | `groq/`           | `https://api.groq.com/openai/v1`                    | OpenAI    | [获取密钥](https://console.groq.com)                              |\n| **Moonshot**        | `moonshot/`       | `https://api.moonshot.cn/v1`                        | OpenAI    | [获取密钥](https://platform.moonshot.cn)                          |\n| **通义千问 (Qwen)** | `qwen/`           | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI    | [获取密钥](https://dashscope.console.aliyun.com)                  |\n| **NVIDIA**          | `nvidia/`         | `https://integrate.api.nvidia.com/v1`               | OpenAI    | [获取密钥](https://build.nvidia.com)                              |\n| **Ollama**          | `ollama/`         | `http://localhost:11434/v1`                         | OpenAI    | 本地（无需密钥）                                                  |\n| **OpenRouter**      | `openrouter/`     | `https://openrouter.ai/api/v1`                      | OpenAI    | [获取密钥](https://openrouter.ai/keys)                            |\n| **LiteLLM Proxy**   | `litellm/`        | `http://localhost:4000/v1`                          | OpenAI    | 你的 LiteLLM 代理密钥                                             |\n| **VLLM**            | `vllm/`           | `http://localhost:8000/v1`                          | OpenAI    | 本地                                                              |\n| **Cerebras**        | `cerebras/`       | `https://api.cerebras.ai/v1`                        | OpenAI    | [获取密钥](https://cerebras.ai)                                   |\n| **火山引擎（Doubao）** | `volcengine/`  | `https://ark.cn-beijing.volces.com/api/v3`          | OpenAI    | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |\n| **神算云**          | `shengsuanyun/`   | `https://router.shengsuanyun.com/api/v1`            | OpenAI    | -                                                                 |\n| **BytePlus**        | `byteplus/`       | `https://ark.ap-southeast.bytepluses.com/api/v3`    | OpenAI    | [获取密钥](https://www.byteplus.com)                              |\n| **Vivgrid**         | `vivgrid/`        | `https://api.vivgrid.com/v1`                        | OpenAI    | [获取密钥](https://vivgrid.com)                                   |\n| **LongCat**         | `longcat/`        | `https://api.longcat.chat/openai`                   | OpenAI    | [获取密钥](https://longcat.chat/platform)                         |\n| **ModelScope (魔搭)**| `modelscope/`    | `https://api-inference.modelscope.cn/v1`            | OpenAI    | [获取 Token](https://modelscope.cn/my/tokens)                    |\n| **Antigravity**     | `antigravity/`    | Google Cloud                                        | 自定义    | 仅 OAuth                                                          |\n| **GitHub Copilot**  | `github-copilot/` | `localhost:4321`                                    | gRPC      | -                                                                 |\n\n#### 基础配置示例\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"ark-code-latest\",\n      \"model\": \"volcengine/ark-code-latest\",\n      \"api_key\": \"sk-your-api-key\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_key\": \"sk-your-openai-key\"\n    },\n    {\n      \"model_name\": \"claude-sonnet-4.6\",\n      \"model\": \"anthropic/claude-sonnet-4.6\",\n      \"api_key\": \"sk-ant-your-key\"\n    },\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-zhipu-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"gpt-5.4\"\n    }\n  }\n}\n```\n\n#### 各厂商配置示例\n\n**OpenAI**\n\n```json\n{\n  \"model_name\": \"gpt-5.4\",\n  \"model\": \"openai/gpt-5.4\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**火山引擎（Doubao）**\n\n```json\n{\n  \"model_name\": \"ark-code-latest\",\n  \"model\": \"volcengine/ark-code-latest\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**智谱 AI (GLM)**\n\n```json\n{\n  \"model_name\": \"glm-4.7\",\n  \"model\": \"zhipu/glm-4.7\",\n  \"api_key\": \"your-key\"\n}\n```\n\n**DeepSeek**\n\n```json\n{\n  \"model_name\": \"deepseek-chat\",\n  \"model\": \"deepseek/deepseek-chat\",\n  \"api_key\": \"sk-...\"\n}\n```\n\n**Anthropic (使用 OAuth)**\n\n```json\n{\n  \"model_name\": \"claude-sonnet-4.6\",\n  \"model\": \"anthropic/claude-sonnet-4.6\",\n  \"auth_method\": \"oauth\"\n}\n```\n\n> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。\n\n**Anthropic Messages API（原生格式）**\n\n用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点：\n\n```json\n{\n  \"model_name\": \"claude-opus-4-6\",\n  \"model\": \"anthropic-messages/claude-opus-4-6\",\n  \"api_key\": \"sk-ant-your-key\",\n  \"api_base\": \"https://api.anthropic.com\"\n}\n```\n\n> 使用 `anthropic-messages` 协议的场景：\n> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理（不支持 OpenAI 兼容的 `/v1/chat/completions`）\n> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务\n> - 现有的 `anthropic` 协议返回 404 错误（说明端点不支持 OpenAI 兼容格式）\n>\n> **注意：** `anthropic` 协议使用 OpenAI 兼容格式（`/v1/chat/completions`），而 `anthropic-messages` 使用 Anthropic 原生格式（`/v1/messages`）。请根据端点支持的格式选择。\n\n**Ollama (本地)**\n\n```json\n{\n  \"model_name\": \"llama3\",\n  \"model\": \"ollama/llama3\"\n}\n```\n\n**自定义代理/API**\n\n```json\n{\n  \"model_name\": \"my-custom-model\",\n  \"model\": \"openai/custom-model\",\n  \"api_base\": \"https://my-proxy.com/v1\",\n  \"api_key\": \"sk-...\",\n  \"request_timeout\": 300\n}\n```\n\n**LiteLLM Proxy**\n\n```json\n{\n  \"model_name\": \"lite-gpt4\",\n  \"model\": \"litellm/lite-gpt4\",\n  \"api_base\": \"http://localhost:4000/v1\",\n  \"api_key\": \"sk-...\"\n}\n```\n\nPicoClaw 在发送请求前仅去除外层 `litellm/` 前缀，因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`，而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。\n\n#### 负载均衡\n\n为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询：\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api1.example.com/v1\",\n      \"api_key\": \"sk-key1\"\n    },\n    {\n      \"model_name\": \"gpt-5.4\",\n      \"model\": \"openai/gpt-5.4\",\n      \"api_base\": \"https://api2.example.com/v1\",\n      \"api_key\": \"sk-key2\"\n    }\n  ]\n}\n```\n\n#### 从旧的 `providers` 配置迁移\n\n旧的 `providers` 配置格式**已弃用**，但为向后兼容仍支持。\n\n**旧配置（已弃用）：**\n\n```json\n{\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"your-key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  },\n  \"agents\": {\n    \"defaults\": {\n      \"provider\": \"zhipu\",\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n**新配置（推荐）：**\n\n```json\n{\n  \"model_list\": [\n    {\n      \"model_name\": \"glm-4.7\",\n      \"model\": \"zhipu/glm-4.7\",\n      \"api_key\": \"your-key\"\n    }\n  ],\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"glm-4.7\"\n    }\n  }\n}\n```\n\n详细的迁移指南请参考 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。\n\n### Provider 架构\n\nPicoClaw 按协议族路由 Provider：\n\n- OpenAI 兼容协议：OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。\n- Anthropic 协议：Claude 原生 API 行为。\n- Codex/OAuth 路径：OpenAI OAuth/Token 认证路由。\n\n这使得运行时保持轻量，同时让新的 OpenAI 兼容后端基本只需配置操作（`api_base` + `api_key`）。\n\n<details>\n<summary><b>智谱 (Zhipu) 配置示例</b></summary>\n\n**1. 获取 API key 和 base URL**\n\n- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)\n\n**2. 配置**\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"workspace\": \"~/.picoclaw/workspace\",\n      \"model\": \"glm-4.7\",\n      \"max_tokens\": 8192,\n      \"temperature\": 0.7,\n      \"max_tool_iterations\": 20\n    }\n  },\n  \"providers\": {\n    \"zhipu\": {\n      \"api_key\": \"Your API Key\",\n      \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\"\n    }\n  }\n}\n```\n\n**3. 运行**\n\n```bash\npicoclaw agent -m \"你好\"\n```\n\n</details>\n\n<details>\n<summary><b>完整配置示例</b></summary>\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"anthropic/claude-opus-4-5\"\n    }\n  },\n  \"session\": {\n    \"dm_scope\": \"per-channel-peer\",\n    \"backlog_limit\": 20\n  },\n  \"providers\": {\n    \"openrouter\": {\n      \"api_key\": \"sk-or-v1-xxx\"\n    },\n    \"groq\": {\n      \"api_key\": \"gsk_xxx\"\n    }\n  },\n  \"channels\": {\n    \"telegram\": {\n      \"enabled\": true,\n      \"token\": \"123456:ABC...\",\n      \"allow_from\": [\"123456789\"]\n    },\n    \"discord\": {\n      \"enabled\": true,\n      \"token\": \"\",\n      \"allow_from\": [\"\"]\n    },\n    \"whatsapp\": {\n      \"enabled\": false,\n      \"bridge_url\": \"ws://localhost:3001\",\n      \"use_native\": false,\n      \"session_store_path\": \"\",\n      \"allow_from\": []\n    },\n    \"feishu\": {\n      \"enabled\": false,\n      \"app_id\": \"cli_xxx\",\n      \"app_secret\": \"xxx\",\n      \"encrypt_key\": \"\",\n      \"verification_token\": \"\",\n      \"allow_from\": []\n    },\n    \"qq\": {\n      \"enabled\": false,\n      \"app_id\": \"\",\n      \"app_secret\": \"\",\n      \"allow_from\": []\n    }\n  },\n  \"tools\": {\n    \"web\": {\n      \"brave\": {\n        \"enabled\": false,\n        \"api_key\": \"BSA...\",\n        \"max_results\": 5\n      },\n      \"duckduckgo\": {\n        \"enabled\": true,\n        \"max_results\": 5\n      },\n      \"perplexity\": {\n        \"enabled\": false,\n        \"api_key\": \"\",\n        \"max_results\": 5\n      },\n      \"searxng\": {\n        \"enabled\": false,\n        \"base_url\": \"http://localhost:8888\",\n        \"max_results\": 5\n      }\n    },\n    \"cron\": {\n      \"exec_timeout_minutes\": 5\n    }\n  },\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n</details>\n\n---\n\n## 📝 API Key 对比\n\n| 服务 | 价格 | 适用场景 |\n| --- | --- | --- |\n| **OpenRouter** | 免费: 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |\n| **火山引擎 CodingPlan** | ¥9.9/首月 | 最适合国内用户，多种 SOTA 模型（豆包、DeepSeek 等） |\n| **智谱 (Zhipu)** | 免费: 200K tokens/月 | 适合中国用户 |\n| **Brave Search** | $5/1000 次查询 | 网络搜索功能 |\n| **SearXNG** | 免费（自建） | 隐私优先的元搜索引擎（70+ 搜索引擎） |\n| **Groq** | 免费额度可用 | 极速推理 (Llama, Mixtral) |\n| **Cerebras** | 免费额度可用 | 极速推理 (Llama, Qwen 等) |\n| **LongCat** | 免费: 最多 5M tokens/天 | 极速推理 |\n| **ModelScope (魔搭)** | 免费: 2000 次请求/天 | 推理 (Qwen, GLM, DeepSeek 等) |\n"
  },
  {
    "path": "docs/zh/spawn-tasks.md",
    "content": "# 🔄 异步任务与 Spawn\n\n> 返回 [README](../../README.zh.md)\n\n### 使用 Spawn 的异步任务\n\n对于耗时较长的任务（网络搜索、API 调用），使用 `spawn` 工具创建一个 **子 Agent (subagent)**：\n\n```markdown\n# Periodic Tasks\n\n## Quick Tasks (respond directly)\n\n- Report current time\n\n## Long Tasks (use spawn for async)\n\n- Search the web for AI news and summarize\n- Check email and report important messages\n```\n\n**关键行为：**\n\n| 特性             | 描述                                     |\n| ---------------- | ---------------------------------------- |\n| **spawn**        | 创建异步子 Agent，不阻塞主心跳进程       |\n| **独立上下文**   | 子 Agent 拥有独立上下文，无会话历史      |\n| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |\n| **非阻塞**       | spawn 后，心跳继续处理下一个任务         |\n\n#### 子 Agent 通信原理\n\n```\n心跳触发 (Heartbeat triggers)\n    ↓\nAgent 读取 HEARTBEAT.md\n    ↓\n对于长任务: spawn 子 Agent\n    ↓                           ↓\n继续下一个任务               子 Agent 独立工作\n    ↓                           ↓\n所有任务完成                 子 Agent 使用 \"message\" 工具\n    ↓                           ↓\n响应 HEARTBEAT_OK            用户直接收到结果\n```\n\n子 Agent 可以访问工具（message, web_search 等），并且无需通过主 Agent 即可独立与用户通信。\n\n**配置：**\n\n```json\n{\n  \"heartbeat\": {\n    \"enabled\": true,\n    \"interval\": 30\n  }\n}\n```\n\n| 选项       | 默认值 | 描述                         |\n| ---------- | ------ | ---------------------------- |\n| `enabled`  | `true` | 启用/禁用心跳                |\n| `interval` | `30`   | 检查间隔，单位分钟 (最小: 5) |\n\n**环境变量:**\n\n- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用\n- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔\n"
  },
  {
    "path": "docs/zh/tools_configuration.md",
    "content": "# 🔧 工具配置\n\n> 返回 [README](../../README.zh.md)\n\nPicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。\n\n## 目录结构\n\n```json\n{\n  \"tools\": {\n    \"web\": {\n      ...\n    },\n    \"mcp\": {\n      ...\n    },\n    \"exec\": {\n      ...\n    },\n    \"cron\": {\n      ...\n    },\n    \"skills\": {\n      ...\n    }\n  }\n}\n```\n\n## Web 工具\n\nWeb 工具用于网页搜索和抓取。\n\n### Web Fetcher\n用于抓取和处理网页内容的通用设置。\n\n| 配置项              | 类型   | 默认值        | 描述                                                                                   |\n|---------------------|--------|---------------|----------------------------------------------------------------------------------------|\n| `enabled`           | bool   | true          | 启用网页抓取功能。                                                                     |\n| `fetch_limit_bytes` | int    | 10485760      | 抓取网页负载的最大大小，单位为字节（默认 10MB）。                                      |\n| `format`            | string | \"plaintext\"   | 抓取内容的输出格式。选项：`plaintext` 或 `markdown`（推荐）。                          |\n\n### Brave\n\n| 配置项        | 类型   | 默认值 | 描述               |\n|---------------|--------|--------|--------------------|\n| `enabled`     | bool   | false  | 启用 Brave 搜索    |\n| `api_key`     | string | -      | Brave Search API 密钥 |\n| `max_results` | int    | 5      | 最大结果数         |\n\n### DuckDuckGo\n\n| 配置项        | 类型 | 默认值 | 描述                  |\n|---------------|------|--------|-----------------------|\n| `enabled`     | bool | true   | 启用 DuckDuckGo 搜索  |\n| `max_results` | int  | 5      | 最大结果数            |\n\n### Perplexity\n\n| 配置项        | 类型   | 默认值 | 描述                  |\n|---------------|--------|--------|-----------------------|\n| `enabled`     | bool   | false  | 启用 Perplexity 搜索  |\n| `api_key`     | string | -      | Perplexity API 密钥   |\n| `max_results` | int    | 5      | 最大结果数            |\n\n## Exec 工具\n\nExec 工具用于执行 shell 命令。\n\n| 配置项                 | 类型  | 默认值 | 描述                           |\n|------------------------|-------|--------|--------------------------------|\n| `enabled`              | bool  | true   | 启用 exec 工具                 |\n| `enable_deny_patterns` | bool  | true   | 启用默认的危险命令拦截         |\n| `custom_deny_patterns` | array | []     | 自定义拒绝模式（正则表达式）   |\n\n### 禁用 Exec 工具\n\n要完全禁用 `exec` 工具，请将 `enabled` 设置为 `false`：\n\n**通过配置文件：**\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enabled\": false\n    }\n  }\n}\n```\n\n**通过环境变量：**\n```bash\nPICOCLAW_TOOLS_EXEC_ENABLED=false\n```\n\n> **注意：** 禁用后，代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。\n\n### 功能说明\n\n- **`enable_deny_patterns`**：设为 `false` 可完全禁用默认的危险命令拦截模式\n- **`custom_deny_patterns`**：添加自定义拒绝正则模式；匹配的命令将被拦截\n\n### 默认拦截的命令模式\n\n默认情况下，PicoClaw 会拦截以下危险命令：\n\n- 删除命令：`rm -rf`、`del /f/q`、`rmdir /s`\n- 磁盘操作：`format`、`mkfs`、`diskpart`、`dd if=`、写入 `/dev/sd*`\n- 系统操作：`shutdown`、`reboot`、`poweroff`\n- 命令替换：`$()`、`${}`、反引号\n- 管道到 shell：`| sh`、`| bash`\n- 权限提升：`sudo`、`chmod`、`chown`\n- 进程控制：`pkill`、`killall`、`kill -9`\n- 远程操作：`curl | sh`、`wget | sh`、`ssh`\n- 包管理：`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user`\n- 容器：`docker run`、`docker exec`\n- Git：`git push`、`git force`\n- 其他：`eval`、`source *.sh`\n\n### 已知架构限制\n\nexec 守卫仅验证发送给 PicoClaw 的顶层命令。它**不会**递归检查该命令启动后由构建工具或脚本生成的子进程。\n\n以下工作流在初始命令被允许后可以绕过直接命令守卫：\n\n- `make run`\n- `go run ./cmd/...`\n- `cargo run`\n- `npm run build`\n\n这意味着守卫对于拦截明显危险的直接命令很有用，但它**不是**未审查构建管道的完整沙箱。如果你的威胁模型包括工作区中的不受信任代码，请使用更强的隔离措施，如容器、虚拟机或围绕构建和运行命令的审批流程。\n\n### 配置示例\n\n```json\n{\n  \"tools\": {\n    \"exec\": {\n      \"enable_deny_patterns\": true,\n      \"custom_deny_patterns\": [\n        \"\\\\brm\\\\s+-r\\\\b\",\n        \"\\\\bkillall\\\\s+python\"\n      ]\n    }\n  }\n}\n```\n\n## Cron 工具\n\nCron 工具用于调度周期性任务。\n\n| 配置项                 | 类型 | 默认值 | 描述                                |\n|------------------------|------|--------|-------------------------------------|\n| `exec_timeout_minutes` | int  | 5      | 执行超时时间（分钟），0 表示无限制  |\n\n## MCP 工具\n\nMCP 工具支持与外部 Model Context Protocol 服务器集成。\n\n### 工具发现（延迟加载）\n\n当连接多个 MCP 服务器时，同时暴露数百个工具可能会耗尽 LLM 的上下文窗口并增加 API 成本。**Discovery** 功能通过默认*隐藏* MCP 工具来解决此问题。\n\nLLM 不会加载所有工具，而是获得一个轻量级搜索工具（使用 BM25 关键词匹配或正则表达式）。当 LLM 需要特定功能时，它会搜索隐藏的工具库。匹配的工具随后被临时\"解锁\"并注入上下文中，持续配置的轮数（`ttl`）。\n\n### 全局配置\n\n| 配置项      | 类型   | 默认值 | 描述                                 |\n|-------------|--------|--------|--------------------------------------|\n| `enabled`   | bool   | false  | 全局启用 MCP 集成                    |\n| `discovery` | object | `{}`   | 工具发现配置（见下文）               |\n| `servers`   | object | `{}`   | 服务器名称到服务器配置的映射         |\n\n### Discovery 配置（`discovery`）\n\n| 配置项               | 类型 | 默认值 | 描述                                                                                                          |\n|----------------------|------|--------|---------------------------------------------------------------------------------------------------------------|\n| `enabled`            | bool | false  | 如果为 true，MCP 工具将被隐藏并按需通过搜索加载。如果为 false，所有工具都会被加载                             |\n| `ttl`                | int  | 5      | 已发现工具保持解锁状态的对话轮数                                                                              |\n| `max_search_results` | int  | 5      | 每次搜索查询返回的最大工具数                                                                                  |\n| `use_bm25`           | bool | true   | 启用自然语言/关键词搜索工具（`tool_search_tool_bm25`）。**警告**：比正则搜索消耗更多资源                       |\n| `use_regex`          | bool | false  | 启用正则模式搜索工具（`tool_search_tool_regex`）                                                              |\n\n> **注意：** 如果 `discovery.enabled` 为 `true`，你**必须**启用至少一个搜索引擎（`use_bm25` 或 `use_regex`），\n> 否则应用程序将无法启动。\n\n### 单服务器配置\n\n| 配置项     | 类型   | 必需     | 描述                               |\n|------------|--------|----------|------------------------------------|\n| `enabled`  | bool   | 是       | 启用此 MCP 服务器                  |\n| `type`     | string | 否       | 传输类型：`stdio`、`sse`、`http`   |\n| `command`  | string | stdio    | stdio 传输的可执行命令             |\n| `args`     | array  | 否       | stdio 传输的命令参数               |\n| `env`      | object | 否       | stdio 进程的环境变量               |\n| `env_file` | string | 否       | stdio 进程的环境文件路径           |\n| `url`      | string | sse/http | `sse`/`http` 传输的端点 URL        |\n| `headers`  | object | 否       | `sse`/`http` 传输的 HTTP 头        |\n\n### 传输行为\n\n- 如果省略 `type`，传输方式将自动检测：\n    - 设置了 `url` → `sse`\n    - 设置了 `command` → `stdio`\n- `http` 和 `sse` 都使用 `url` + 可选的 `headers`。\n- `env` 和 `env_file` 仅应用于 `stdio` 服务器。\n\n### 配置示例\n\n#### 1) Stdio MCP 服务器\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"filesystem\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-filesystem\",\n            \"/tmp\"\n          ]\n        }\n      }\n    }\n  }\n}\n```\n\n#### 2) 远程 SSE/HTTP MCP 服务器\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"servers\": {\n        \"remote-mcp\": {\n          \"enabled\": true,\n          \"type\": \"sse\",\n          \"url\": \"https://example.com/mcp\",\n          \"headers\": {\n            \"Authorization\": \"Bearer YOUR_TOKEN\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n#### 3) 启用工具发现的大规模 MCP 设置\n\n*在此示例中，LLM 只会看到 `tool_search_tool_bm25`。它将仅在用户请求时动态搜索并解锁 Github 或 Postgres 工具。*\n\n```json\n{\n  \"tools\": {\n    \"mcp\": {\n      \"enabled\": true,\n      \"discovery\": {\n        \"enabled\": true,\n        \"ttl\": 5,\n        \"max_search_results\": 5,\n        \"use_bm25\": true,\n        \"use_regex\": false\n      },\n      \"servers\": {\n        \"github\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-github\"\n          ],\n          \"env\": {\n            \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"YOUR_GITHUB_TOKEN\"\n          }\n        },\n        \"postgres\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-postgres\",\n            \"postgresql://user:password@localhost/dbname\"\n          ]\n        },\n        \"slack\": {\n          \"enabled\": true,\n          \"command\": \"npx\",\n          \"args\": [\n            \"-y\",\n            \"@modelcontextprotocol/server-slack\"\n          ],\n          \"env\": {\n            \"SLACK_BOT_TOKEN\": \"YOUR_SLACK_BOT_TOKEN\",\n            \"SLACK_TEAM_ID\": \"YOUR_SLACK_TEAM_ID\"\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n## Skills 工具\n\nSkills 工具配置通过 ClawHub 等注册表进行技能发现和安装。\n\n### 注册表\n\n| 配置项                             | 类型   | 默认值               | 描述                                 |\n|------------------------------------|--------|----------------------|--------------------------------------|\n| `registries.clawhub.enabled`       | bool   | true                 | 启用 ClawHub 注册表                  |\n| `registries.clawhub.base_url`      | string | `https://clawhub.ai` | ClawHub 基础 URL                     |\n| `registries.clawhub.auth_token`    | string | `\"\"`                 | 可选的 Bearer 令牌，用于更高速率限制 |\n| `registries.clawhub.search_path`   | string | `/api/v1/search`     | 搜索 API 路径                        |\n| `registries.clawhub.skills_path`   | string | `/api/v1/skills`     | Skills API 路径                      |\n| `registries.clawhub.download_path` | string | `/api/v1/download`   | 下载 API 路径                        |\n\n### 配置示例\n\n```json\n{\n  \"tools\": {\n    \"skills\": {\n      \"registries\": {\n        \"clawhub\": {\n          \"enabled\": true,\n          \"base_url\": \"https://clawhub.ai\",\n          \"auth_token\": \"\",\n          \"search_path\": \"/api/v1/search\",\n          \"skills_path\": \"/api/v1/skills\",\n          \"download_path\": \"/api/v1/download\"\n        }\n      }\n    }\n  }\n}\n```\n\n## 环境变量\n\n所有配置选项都可以通过格式为 `PICOCLAW_TOOLS_<SECTION>_<KEY>` 的环境变量覆盖：\n\n例如：\n\n- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`\n- `PICOCLAW_TOOLS_EXEC_ENABLED=false`\n- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`\n- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`\n- `PICOCLAW_TOOLS_MCP_ENABLED=true`\n\n注意：嵌套的映射式配置（例如 `tools.mcp.servers.<name>.*`）在 `config.json` 中配置，而非通过环境变量。\n"
  },
  {
    "path": "docs/zh/troubleshooting.md",
    "content": "# 🐛 疑难解答\n\n> 返回 [README](../../README.zh.md)\n\n## \"model ... not found in model_list\" 或 OpenRouter \"free is not a valid model ID\"\n\n**症状：** 你看到以下任一错误：\n\n- `Error creating provider: model \"openrouter/free\" not found in model_list`\n- OpenRouter 返回 400：`\"free is not a valid model ID\"`\n\n**原因：** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter，你必须使用**完整的**模型 ID，而不是简写。\n\n- **错误：** `\"model\": \"free\"` → OpenRouter 收到 `free` 并拒绝。\n- **正确：** `\"model\": \"openrouter/free\"` → OpenRouter 收到 `openrouter/free`（自动免费层路由）。\n\n**修复方法：** 在 `~/.picoclaw/config.json`（或你的配置路径）中：\n\n1. **agents.defaults.model** 必须匹配 `model_list` 中的某个 `model_name`（例如 `\"openrouter-free\"`）。\n2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID，例如：\n   - `\"openrouter/free\"` – 自动免费层\n   - `\"google/gemini-2.0-flash-exp:free\"`\n   - `\"meta-llama/llama-3.1-8b-instruct:free\"`\n\n示例片段：\n\n```json\n{\n  \"agents\": {\n    \"defaults\": {\n      \"model\": \"openrouter-free\"\n    }\n  },\n  \"model_list\": [\n    {\n      \"model_name\": \"openrouter-free\",\n      \"model\": \"openrouter/free\",\n      \"api_key\": \"sk-or-v1-YOUR_OPENROUTER_KEY\",\n      \"api_base\": \"https://openrouter.ai/api/v1\"\n    }\n  ]\n}\n```\n\n在 [OpenRouter Keys](https://openrouter.ai/keys) 获取你的密钥。\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/sipeed/picoclaw\n\ngo 1.25.8\n\nrequire (\n\tfyne.io/systray v1.12.0\n\tgithub.com/adhocore/gronx v1.19.6\n\tgithub.com/anthropics/anthropic-sdk-go v1.26.0\n\tgithub.com/bwmarrin/discordgo v0.29.0\n\tgithub.com/caarlos0/env/v11 v11.4.0\n\tgithub.com/ergochat/irc-go v0.6.0\n\tgithub.com/ergochat/readline v0.1.3\n\tgithub.com/gdamore/tcell/v2 v2.13.8\n\tgithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/h2non/filetype v1.1.3\n\tgithub.com/larksuite/oapi-sdk-go/v3 v3.5.3\n\tgithub.com/mdp/qrterminal/v3 v3.2.1\n\tgithub.com/modelcontextprotocol/go-sdk v1.4.1\n\tgithub.com/mymmrac/telego v1.7.0\n\tgithub.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1\n\tgithub.com/openai/openai-go/v3 v3.22.0\n\tgithub.com/rivo/tview v0.42.0\n\tgithub.com/rs/zerolog v1.34.0\n\tgithub.com/slack-go/slack v0.17.3\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/tencent-connect/botgo v0.2.1\n\tgo.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4\n\tgolang.org/x/oauth2 v0.36.0\n\tgolang.org/x/term v0.41.0\n\tgolang.org/x/time v0.14.0\n\tgoogle.golang.org/protobuf v1.36.11\n\tgopkg.in/yaml.v3 v3.0.1\n\tmaunium.net/go/mautrix v0.26.4\n\tmodernc.org/sqlite v1.46.1\n)\n\nrequire (\n\tfilippo.io/edwards25519 v1.2.0 // indirect\n\tgithub.com/beeper/argo-go v1.1.2 // indirect\n\tgithub.com/coder/websocket v1.8.14 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/elliotchance/orderedmap/v3 v3.1.0 // indirect\n\tgithub.com/gdamore/encoding v1.0.1 // indirect\n\tgithub.com/godbus/dbus/v5 v5.1.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.3.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/segmentio/asm v1.1.3 // indirect\n\tgithub.com/segmentio/encoding v0.5.4 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/vektah/gqlparser/v2 v2.5.27 // indirect\n\tgo.mau.fi/libsignal v0.2.1 // indirect\n\tgo.mau.fi/util v0.9.7 // indirect\n\tgolang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tmodernc.org/libc v1.67.6 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n\trsc.io/qr v0.2.0 // indirect\n)\n\nrequire (\n\tgithub.com/andybalholm/brotli v1.2.0 // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/github/copilot-sdk/go v0.1.32\n\tgithub.com/go-resty/resty/v2 v2.17.1 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/google/jsonschema-go v0.4.2 // indirect\n\tgithub.com/grbit/go-json v0.11.0 // indirect\n\tgithub.com/klauspost/compress v1.18.4 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/tidwall/gjson v1.18.0 // indirect\n\tgithub.com/tidwall/match v1.2.0 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/tidwall/sjson v1.2.5 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/valyala/bytebufferpool v1.0.0 // indirect\n\tgithub.com/valyala/fasthttp v1.69.0 // indirect\n\tgithub.com/valyala/fastjson v1.6.10 // indirect\n\tgithub.com/yosida95/uritemplate/v3 v3.0.2 // indirect\n\tgolang.org/x/arch v0.24.0 // indirect\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/net v0.52.0\n\tgolang.org/x/sync v0.20.0 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=\nfilippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=\nfilippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=\nfyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=\nfyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=\ngithub.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=\ngithub.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=\ngithub.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=\ngithub.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=\ngithub.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=\ngithub.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=\ngithub.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=\ngithub.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=\ngithub.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=\ngithub.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=\ngithub.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=\ngithub.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=\ngithub.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=\ngithub.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=\ngithub.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=\ngithub.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=\ngithub.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=\ngithub.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=\ngithub.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=\ngithub.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=\ngithub.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=\ngithub.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=\ngithub.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=\ngithub.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=\ngithub.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=\ngithub.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=\ngithub.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=\ngithub.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=\ngithub.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=\ngithub.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=\ngithub.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=\ngithub.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=\ngithub.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=\ngithub.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=\ngithub.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=\ngithub.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=\ngithub.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=\ngithub.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=\ngithub.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=\ngithub.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=\ngithub.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=\ngithub.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=\ngithub.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=\ngithub.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=\ngithub.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=\ngithub.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=\ngithub.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=\ngithub.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=\ngithub.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=\ngithub.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=\ngithub.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=\ngithub.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=\ngithub.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=\ngithub.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=\ngithub.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=\ngithub.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=\ngithub.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=\ngithub.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=\ngithub.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=\ngithub.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=\ngithub.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=\ngithub.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=\ngithub.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=\ngithub.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=\ngithub.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=\ngithub.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=\ngithub.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=\ngithub.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=\ngithub.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=\ngithub.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=\ngithub.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=\ngithub.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=\ngithub.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=\ngithub.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=\ngithub.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=\ngithub.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=\ngo.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=\ngo.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=\ngo.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=\ngo.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=\ngo.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=\ngolang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=\ngolang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=\ngolang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=\ngolang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=\ngolang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmaunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=\nmaunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=\nmodernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=\nmodernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nrsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=\nrsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=\n"
  },
  {
    "path": "pkg/agent/context.go",
    "content": "package agent\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\ntype ContextBuilder struct {\n\tworkspace          string\n\tskillsLoader       *skills.SkillsLoader\n\tmemory             *MemoryStore\n\ttoolDiscoveryBM25  bool\n\ttoolDiscoveryRegex bool\n\n\t// Cache for system prompt to avoid rebuilding on every call.\n\t// This fixes issue #607: repeated reprocessing of the entire context.\n\t// The cache auto-invalidates when workspace source files change (mtime check).\n\tsystemPromptMutex  sync.RWMutex\n\tcachedSystemPrompt string\n\tcachedAt           time.Time // max observed mtime across tracked paths at cache build time\n\n\t// existedAtCache tracks which source file paths existed the last time the\n\t// cache was built. This lets sourceFilesChanged detect files that are newly\n\t// created (didn't exist at cache time, now exist) or deleted (existed at\n\t// cache time, now gone) — both of which should trigger a cache rebuild.\n\texistedAtCache map[string]bool\n\n\t// skillFilesAtCache snapshots the skill tree file set and mtimes at cache\n\t// build time. This catches nested file creations/deletions/mtime changes\n\t// that may not update the top-level skill root directory mtime.\n\tskillFilesAtCache map[string]time.Time\n}\n\nfunc (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {\n\tcb.toolDiscoveryBM25 = useBM25\n\tcb.toolDiscoveryRegex = useRegex\n\treturn cb\n}\n\nfunc getGlobalConfigDir() string {\n\tif home := os.Getenv(config.EnvHome); home != \"\" {\n\t\treturn home\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(home, \".picoclaw\")\n}\n\nfunc NewContextBuilder(workspace string) *ContextBuilder {\n\t// builtin skills: skills directory in current project\n\t// Use the skills/ directory under the current working directory\n\tbuiltinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))\n\tif builtinSkillsDir == \"\" {\n\t\twd, _ := os.Getwd()\n\t\tbuiltinSkillsDir = filepath.Join(wd, \"skills\")\n\t}\n\tglobalSkillsDir := filepath.Join(getGlobalConfigDir(), \"skills\")\n\n\treturn &ContextBuilder{\n\t\tworkspace:    workspace,\n\t\tskillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),\n\t\tmemory:       NewMemoryStore(workspace),\n\t}\n}\n\nfunc (cb *ContextBuilder) getIdentity() string {\n\tworkspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))\n\ttoolDiscovery := cb.getDiscoveryRule()\n\tversion := config.FormatVersion()\n\n\treturn fmt.Sprintf(\n\t\t`# picoclaw 🦞 (%s)\n\nYou are picoclaw, a helpful AI assistant.\n\n## Workspace\nYour workspace is at: %s\n- Memory: %s/memory/MEMORY.md\n- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md\n- Skills: %s/skills/{skill-name}/SKILL.md\n\n## Important Rules\n\n1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.\n\n2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.\n\n3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md\n\n4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.\n\n%s`,\n\t\tversion, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)\n}\n\nfunc (cb *ContextBuilder) getDiscoveryRule() string {\n\tif !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {\n\t\treturn \"\"\n\t}\n\n\tvar toolNames []string\n\tif cb.toolDiscoveryBM25 {\n\t\ttoolNames = append(toolNames, `\"tool_search_tool_bm25\"`)\n\t}\n\tif cb.toolDiscoveryRegex {\n\t\ttoolNames = append(toolNames, `\"tool_search_tool_regex\"`)\n\t}\n\n\treturn fmt.Sprintf(\n\t\t`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,\n\t\tstrings.Join(toolNames, \" or \"),\n\t)\n}\n\nfunc (cb *ContextBuilder) BuildSystemPrompt() string {\n\tparts := []string{}\n\n\t// Core identity section\n\tparts = append(parts, cb.getIdentity())\n\n\t// Bootstrap files\n\tbootstrapContent := cb.LoadBootstrapFiles()\n\tif bootstrapContent != \"\" {\n\t\tparts = append(parts, bootstrapContent)\n\t}\n\n\t// Skills - show summary, AI can read full content with read_file tool\n\tskillsSummary := cb.skillsLoader.BuildSkillsSummary()\n\tif skillsSummary != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(`# Skills\n\nThe following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.\n\n%s`, skillsSummary))\n\t}\n\n\t// Memory context\n\tmemoryContext := cb.memory.GetMemoryContext()\n\tif memoryContext != \"\" {\n\t\tparts = append(parts, \"# Memory\\n\\n\"+memoryContext)\n\t}\n\n\t// Join with \"---\" separator\n\treturn strings.Join(parts, \"\\n\\n---\\n\\n\")\n}\n\n// BuildSystemPromptWithCache returns the cached system prompt if available\n// and source files haven't changed, otherwise builds and caches it.\n// Source file changes are detected via mtime checks (cheap stat calls).\nfunc (cb *ContextBuilder) BuildSystemPromptWithCache() string {\n\t// Try read lock first — fast path when cache is valid\n\tcb.systemPromptMutex.RLock()\n\tif cb.cachedSystemPrompt != \"\" && !cb.sourceFilesChangedLocked() {\n\t\tresult := cb.cachedSystemPrompt\n\t\tcb.systemPromptMutex.RUnlock()\n\t\treturn result\n\t}\n\tcb.systemPromptMutex.RUnlock()\n\n\t// Acquire write lock for building\n\tcb.systemPromptMutex.Lock()\n\tdefer cb.systemPromptMutex.Unlock()\n\n\t// Double-check: another goroutine may have rebuilt while we waited\n\tif cb.cachedSystemPrompt != \"\" && !cb.sourceFilesChangedLocked() {\n\t\treturn cb.cachedSystemPrompt\n\t}\n\n\t// Snapshot the baseline (existence + max mtime) BEFORE building the prompt.\n\t// This way cachedAt reflects the pre-build state: if a file is modified\n\t// during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,\n\t// so the next sourceFilesChangedLocked check will correctly trigger a\n\t// rebuild. The alternative (baseline after build) risks caching stale\n\t// content with a too-new baseline, making the staleness invisible.\n\tbaseline := cb.buildCacheBaseline()\n\tprompt := cb.BuildSystemPrompt()\n\tcb.cachedSystemPrompt = prompt\n\tcb.cachedAt = baseline.maxMtime\n\tcb.existedAtCache = baseline.existed\n\tcb.skillFilesAtCache = baseline.skillFiles\n\n\tlogger.DebugCF(\"agent\", \"System prompt cached\",\n\t\tmap[string]any{\n\t\t\t\"length\": len(prompt),\n\t\t})\n\n\treturn prompt\n}\n\n// InvalidateCache clears the cached system prompt.\n// Normally not needed because the cache auto-invalidates via mtime checks,\n// but this is useful for tests or explicit reload commands.\nfunc (cb *ContextBuilder) InvalidateCache() {\n\tcb.systemPromptMutex.Lock()\n\tdefer cb.systemPromptMutex.Unlock()\n\n\tcb.cachedSystemPrompt = \"\"\n\tcb.cachedAt = time.Time{}\n\tcb.existedAtCache = nil\n\tcb.skillFilesAtCache = nil\n\n\tlogger.DebugCF(\"agent\", \"System prompt cache invalidated\", nil)\n}\n\n// sourcePaths returns non-skill workspace source files tracked for cache\n// invalidation (bootstrap files + memory). Skill roots are handled separately\n// because they require both directory-level and recursive file-level checks.\nfunc (cb *ContextBuilder) sourcePaths() []string {\n\treturn []string{\n\t\tfilepath.Join(cb.workspace, \"AGENTS.md\"),\n\t\tfilepath.Join(cb.workspace, \"SOUL.md\"),\n\t\tfilepath.Join(cb.workspace, \"USER.md\"),\n\t\tfilepath.Join(cb.workspace, \"IDENTITY.md\"),\n\t\tfilepath.Join(cb.workspace, \"memory\", \"MEMORY.md\"),\n\t}\n}\n\n// skillRoots returns all skill root directories that can affect\n// BuildSkillsSummary output (workspace/global/builtin).\nfunc (cb *ContextBuilder) skillRoots() []string {\n\tif cb.skillsLoader == nil {\n\t\treturn []string{filepath.Join(cb.workspace, \"skills\")}\n\t}\n\n\troots := cb.skillsLoader.SkillRoots()\n\tif len(roots) == 0 {\n\t\treturn []string{filepath.Join(cb.workspace, \"skills\")}\n\t}\n\treturn roots\n}\n\n// cacheBaseline holds the file existence snapshot and the latest observed\n// mtime across all tracked paths. Used as the cache reference point.\ntype cacheBaseline struct {\n\texisted    map[string]bool\n\tskillFiles map[string]time.Time\n\tmaxMtime   time.Time\n}\n\n// buildCacheBaseline records which tracked paths currently exist and computes\n// the latest mtime across all tracked files + skills directory contents.\n// Called under write lock when the cache is built.\nfunc (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {\n\tskillRoots := cb.skillRoots()\n\n\t// All paths whose existence we track: source files + all skill roots.\n\tallPaths := append(cb.sourcePaths(), skillRoots...)\n\n\texisted := make(map[string]bool, len(allPaths))\n\tskillFiles := make(map[string]time.Time)\n\tvar maxMtime time.Time\n\n\tfor _, p := range allPaths {\n\t\tinfo, err := os.Stat(p)\n\t\texisted[p] = err == nil\n\t\tif err == nil && info.ModTime().After(maxMtime) {\n\t\t\tmaxMtime = info.ModTime()\n\t\t}\n\t}\n\n\t// Walk all skill roots recursively to snapshot skill files and mtimes.\n\t// Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.\n\tfor _, root := range skillRoots {\n\t\t_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {\n\t\t\tif walkErr == nil && !d.IsDir() {\n\t\t\t\tif info, err := os.Stat(path); err == nil {\n\t\t\t\t\tskillFiles[path] = info.ModTime()\n\t\t\t\t\tif info.ModTime().After(maxMtime) {\n\t\t\t\t\t\tmaxMtime = info.ModTime()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\n\t// If no tracked files exist yet (empty workspace), maxMtime is zero.\n\t// Use a very old non-zero time so that:\n\t// 1. cachedAt.IsZero() won't trigger perpetual rebuilds.\n\t// 2. Any real file created afterwards has mtime > cachedAt, so it\n\t//    will be detected by fileChangedSince (unlike time.Now() which\n\t//    could race with a file whose mtime <= Now).\n\tif maxMtime.IsZero() {\n\t\tmaxMtime = time.Unix(1, 0)\n\t}\n\n\treturn cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime}\n}\n\n// sourceFilesChangedLocked checks whether any workspace source file has been\n// modified, created, or deleted since the cache was last built.\n//\n// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.\n// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the\n// lock itself (it would deadlock when called from BuildSystemPromptWithCache\n// which already holds RLock or Lock).\nfunc (cb *ContextBuilder) sourceFilesChangedLocked() bool {\n\tif cb.cachedAt.IsZero() {\n\t\treturn true\n\t}\n\n\t// Check tracked source files (bootstrap + memory).\n\tif slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {\n\t\treturn true\n\t}\n\n\t// --- Skill roots (workspace/global/builtin) ---\n\t//\n\t// For each root:\n\t// 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince.\n\t// 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot.\n\tfor _, root := range cb.skillRoots() {\n\t\tif cb.fileChangedSince(root) {\n\t\t\treturn true\n\t\t}\n\t}\n\tif skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// fileChangedSince returns true if a tracked source file has been modified,\n// newly created, or deleted since the cache was built.\n//\n// Four cases:\n//   - existed at cache time, exists now -> check mtime\n//   - existed at cache time, gone now   -> changed (deleted)\n//   - absent at cache time,  exists now -> changed (created)\n//   - absent at cache time,  gone now   -> no change\nfunc (cb *ContextBuilder) fileChangedSince(path string) bool {\n\t// Defensive: if existedAtCache was never initialized, treat as changed\n\t// so the cache rebuilds rather than silently serving stale data.\n\tif cb.existedAtCache == nil {\n\t\treturn true\n\t}\n\n\texistedBefore := cb.existedAtCache[path]\n\tinfo, err := os.Stat(path)\n\texistsNow := err == nil\n\n\tif existedBefore != existsNow {\n\t\treturn true // file was created or deleted\n\t}\n\tif !existsNow {\n\t\treturn false // didn't exist before, doesn't exist now\n\t}\n\treturn info.ModTime().After(cb.cachedAt)\n}\n\n// errWalkStop is a sentinel error used to stop filepath.WalkDir early.\n// Using a dedicated error (instead of fs.SkipAll) makes the early-exit\n// intent explicit and avoids the nilerr linter warning that would fire\n// if the callback returned nil when its err parameter is non-nil.\nvar errWalkStop = errors.New(\"walk stop\")\n\n// skillFilesChangedSince compares the current recursive skill file tree\n// against the cache-time snapshot. Any create/delete/mtime drift invalidates\n// the cache.\nfunc skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool {\n\t// Defensive: if the snapshot was never initialized, force rebuild.\n\tif filesAtCache == nil {\n\t\treturn true\n\t}\n\n\t// Check cached files still exist and keep the same mtime.\n\tfor path, cachedMtime := range filesAtCache {\n\t\tinfo, err := os.Stat(path)\n\t\tif err != nil {\n\t\t\t// A previously tracked file disappeared (or became inaccessible):\n\t\t\t// either way, cached skill summary may now be stale.\n\t\t\treturn true\n\t\t}\n\t\tif !info.ModTime().Equal(cachedMtime) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Check no new files appeared under any skill root.\n\tchanged := false\n\tfor _, root := range skillRoots {\n\t\tif strings.TrimSpace(root) == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\terr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {\n\t\t\tif walkErr != nil {\n\t\t\t\t// Treat unexpected walk errors as changed to avoid stale cache.\n\t\t\t\tif !os.IsNotExist(walkErr) {\n\t\t\t\t\tchanged = true\n\t\t\t\t\treturn errWalkStop\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif d.IsDir() {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif _, ok := filesAtCache[path]; !ok {\n\t\t\t\tchanged = true\n\t\t\t\treturn errWalkStop\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\n\t\tif changed {\n\t\t\treturn true\n\t\t}\n\t\tif err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {\n\t\t\tlogger.DebugCF(\"agent\", \"skills walk error\", map[string]any{\"error\": err.Error()})\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (cb *ContextBuilder) LoadBootstrapFiles() string {\n\tbootstrapFiles := []string{\n\t\t\"AGENTS.md\",\n\t\t\"SOUL.md\",\n\t\t\"USER.md\",\n\t\t\"IDENTITY.md\",\n\t}\n\n\tvar sb strings.Builder\n\tfor _, filename := range bootstrapFiles {\n\t\tfilePath := filepath.Join(cb.workspace, filename)\n\t\tif data, err := os.ReadFile(filePath); err == nil {\n\t\t\tfmt.Fprintf(&sb, \"## %s\\n\\n%s\\n\\n\", filename, data)\n\t\t}\n\t}\n\n\treturn sb.String()\n}\n\n// buildDynamicContext returns a short dynamic context string with per-request info.\n// This changes every request (time, session) so it is NOT part of the cached prompt.\n// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:\n//   - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block\n//   - OpenAI / Codex: prompt_cache_key for prefix-based caching\n//\n// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching\n// See: https://platform.openai.com/docs/guides/prompt-caching\nfunc formatCurrentSenderLine(senderID, senderDisplayName string) string {\n\tsenderID = strings.TrimSpace(senderID)\n\tsenderDisplayName = strings.TrimSpace(senderDisplayName)\n\n\tswitch {\n\tcase senderDisplayName != \"\" && senderID != \"\":\n\t\treturn fmt.Sprintf(\"Current sender: %s (ID: %s)\", senderDisplayName, senderID)\n\tcase senderDisplayName != \"\":\n\t\treturn fmt.Sprintf(\"Current sender: %s\", senderDisplayName)\n\tcase senderID != \"\":\n\t\treturn fmt.Sprintf(\"Current sender: %s\", senderID)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string {\n\tnow := time.Now().Format(\"2006-01-02 15:04 (Monday)\")\n\trt := fmt.Sprintf(\"%s %s, Go %s\", runtime.GOOS, runtime.GOARCH, runtime.Version())\n\n\tvar sb strings.Builder\n\tfmt.Fprintf(&sb, \"## Current Time\\n%s\\n\\n## Runtime\\n%s\", now, rt)\n\n\tif channel != \"\" && chatID != \"\" {\n\t\tfmt.Fprintf(&sb, \"\\n\\n## Current Session\\nChannel: %s\\nChat ID: %s\", channel, chatID)\n\t}\n\tif senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != \"\" {\n\t\tfmt.Fprintf(&sb, \"\\n\\n## Current Sender\\n%s\", senderLine)\n\t}\n\n\treturn sb.String()\n}\n\nfunc (cb *ContextBuilder) BuildMessages(\n\thistory []providers.Message,\n\tsummary string,\n\tcurrentMessage string,\n\tmedia []string,\n\tchannel, chatID, senderID, senderDisplayName string,\n) []providers.Message {\n\tmessages := []providers.Message{}\n\n\t// The static part (identity, bootstrap, skills, memory) is cached locally to\n\t// avoid repeated file I/O and string building on every call (fixes issue #607).\n\t// Dynamic parts (time, session, summary) are appended per request.\n\t// Everything is sent as a single system message for provider compatibility:\n\t// - Anthropic adapter extracts messages[0] (Role==\"system\") and maps its content\n\t//   to the top-level \"system\" parameter in the Messages API request. A single\n\t//   contiguous system block makes this extraction straightforward.\n\t// - Codex maps only the first system message to its instructions field.\n\t// - OpenAI-compat passes messages through as-is.\n\tstaticPrompt := cb.BuildSystemPromptWithCache()\n\n\t// Build short dynamic context (time, runtime, session) — changes per request\n\tdynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)\n\n\t// Compose a single system message: static (cached) + dynamic + optional summary.\n\t// Keeping all system content in one message ensures every provider adapter can\n\t// extract it correctly (Anthropic adapter -> top-level system param,\n\t// Codex -> instructions field).\n\t//\n\t// SystemParts carries the same content as structured blocks so that\n\t// cache-aware adapters (Anthropic) can set per-block cache_control.\n\t// The static block is marked \"ephemeral\" — its prefix hash is stable\n\t// across requests, enabling LLM-side KV cache reuse.\n\tstringParts := []string{staticPrompt, dynamicCtx}\n\n\tcontentBlocks := []providers.ContentBlock{\n\t\t{Type: \"text\", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: \"ephemeral\"}},\n\t\t{Type: \"text\", Text: dynamicCtx},\n\t}\n\n\tif summary != \"\" {\n\t\tsummaryText := fmt.Sprintf(\n\t\t\t\"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation \"+\n\t\t\t\t\"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\\n\\n%s\",\n\t\t\tsummary)\n\t\tstringParts = append(stringParts, summaryText)\n\t\tcontentBlocks = append(contentBlocks, providers.ContentBlock{Type: \"text\", Text: summaryText})\n\t}\n\n\tfullSystemPrompt := strings.Join(stringParts, \"\\n\\n---\\n\\n\")\n\n\t// Log system prompt summary for debugging (debug mode only).\n\t// Read cachedSystemPrompt under lock to avoid a data race with\n\t// concurrent InvalidateCache / BuildSystemPromptWithCache writes.\n\tcb.systemPromptMutex.RLock()\n\tisCached := cb.cachedSystemPrompt != \"\"\n\tcb.systemPromptMutex.RUnlock()\n\n\tlogger.DebugCF(\"agent\", \"System prompt built\",\n\t\tmap[string]any{\n\t\t\t\"static_chars\":  len(staticPrompt),\n\t\t\t\"dynamic_chars\": len(dynamicCtx),\n\t\t\t\"total_chars\":   len(fullSystemPrompt),\n\t\t\t\"has_summary\":   summary != \"\",\n\t\t\t\"cached\":        isCached,\n\t\t})\n\n\t// Log preview of system prompt (avoid logging huge content)\n\tpreview := utils.Truncate(fullSystemPrompt, 500)\n\tlogger.DebugCF(\"agent\", \"System prompt preview\",\n\t\tmap[string]any{\n\t\t\t\"preview\": preview,\n\t\t})\n\n\thistory = sanitizeHistoryForProvider(history)\n\n\t// Single system message containing all context — compatible with all providers.\n\t// SystemParts enables cache-aware adapters to set per-block cache_control;\n\t// Content is the concatenated fallback for adapters that don't read SystemParts.\n\tmessages = append(messages, providers.Message{\n\t\tRole:        \"system\",\n\t\tContent:     fullSystemPrompt,\n\t\tSystemParts: contentBlocks,\n\t})\n\n\t// Add conversation history\n\tmessages = append(messages, history...)\n\n\t// Add current user message\n\tif strings.TrimSpace(currentMessage) != \"\" {\n\t\tmsg := providers.Message{\n\t\t\tRole:    \"user\",\n\t\t\tContent: currentMessage,\n\t\t}\n\t\tif len(media) > 0 {\n\t\t\tmsg.Media = media\n\t\t}\n\t\tmessages = append(messages, msg)\n\t}\n\n\treturn messages\n}\n\nfunc sanitizeHistoryForProvider(history []providers.Message) []providers.Message {\n\tif len(history) == 0 {\n\t\treturn history\n\t}\n\n\tsanitized := make([]providers.Message, 0, len(history))\n\tfor _, msg := range history {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\t// Drop system messages from history. BuildMessages always\n\t\t\t// constructs its own single system message (static + dynamic +\n\t\t\t// summary); extra system messages would break providers that\n\t\t\t// only accept one (Anthropic, Codex).\n\t\t\tlogger.DebugCF(\"agent\", \"Dropping system message from history\", map[string]any{})\n\t\t\tcontinue\n\n\t\tcase \"tool\":\n\t\t\tif len(sanitized) == 0 {\n\t\t\t\tlogger.DebugCF(\"agent\", \"Dropping orphaned leading tool message\", map[string]any{})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Walk backwards to find the nearest assistant message,\n\t\t\t// skipping over any preceding tool messages (multi-tool-call case).\n\t\t\tfoundAssistant := false\n\t\t\tfor i := len(sanitized) - 1; i >= 0; i-- {\n\t\t\t\tif sanitized[i].Role == \"tool\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif sanitized[i].Role == \"assistant\" && len(sanitized[i].ToolCalls) > 0 {\n\t\t\t\t\tfoundAssistant = true\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif !foundAssistant {\n\t\t\t\tlogger.DebugCF(\"agent\", \"Dropping orphaned tool message\", map[string]any{})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsanitized = append(sanitized, msg)\n\n\t\tcase \"assistant\":\n\t\t\tif len(msg.ToolCalls) > 0 {\n\t\t\t\tif len(sanitized) == 0 {\n\t\t\t\t\tlogger.DebugCF(\"agent\", \"Dropping assistant tool-call turn at history start\", map[string]any{})\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tprev := sanitized[len(sanitized)-1]\n\t\t\t\tif prev.Role != \"user\" && prev.Role != \"tool\" {\n\t\t\t\t\tlogger.DebugCF(\n\t\t\t\t\t\t\"agent\",\n\t\t\t\t\t\t\"Dropping assistant tool-call turn with invalid predecessor\",\n\t\t\t\t\t\tmap[string]any{\"prev_role\": prev.Role},\n\t\t\t\t\t)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tsanitized = append(sanitized, msg)\n\n\t\tdefault:\n\t\t\tsanitized = append(sanitized, msg)\n\t\t}\n\t}\n\n\t// Second pass: ensure every assistant message with tool_calls has matching\n\t// tool result messages following it. This is required by strict providers\n\t// like DeepSeek that enforce: \"An assistant message with 'tool_calls' must\n\t// be followed by tool messages responding to each 'tool_call_id'.\"\n\tfinal := make([]providers.Message, 0, len(sanitized))\n\tfor i := 0; i < len(sanitized); i++ {\n\t\tmsg := sanitized[i]\n\t\tif msg.Role == \"assistant\" && len(msg.ToolCalls) > 0 {\n\t\t\t// Collect expected tool_call IDs\n\t\t\texpected := make(map[string]bool, len(msg.ToolCalls))\n\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\texpected[tc.ID] = false\n\t\t\t}\n\n\t\t\t// Check following messages for matching tool results\n\t\t\ttoolMsgCount := 0\n\t\t\tfor j := i + 1; j < len(sanitized); j++ {\n\t\t\t\tif sanitized[j].Role != \"tool\" {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ttoolMsgCount++\n\t\t\t\tif _, exists := expected[sanitized[j].ToolCallID]; exists {\n\t\t\t\t\texpected[sanitized[j].ToolCallID] = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If any tool_call_id is missing, drop this assistant message and its partial tool messages\n\t\t\tallFound := true\n\t\t\tfor toolCallID, found := range expected {\n\t\t\t\tif !found {\n\t\t\t\t\tallFound = false\n\t\t\t\t\tlogger.DebugCF(\n\t\t\t\t\t\t\"agent\",\n\t\t\t\t\t\t\"Dropping assistant message with incomplete tool results\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"missing_tool_call_id\": toolCallID,\n\t\t\t\t\t\t\t\"expected_count\":       len(expected),\n\t\t\t\t\t\t\t\"found_count\":          toolMsgCount,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !allFound {\n\t\t\t\t// Skip this assistant message and its tool messages\n\t\t\t\ti += toolMsgCount\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tfinal = append(final, msg)\n\t}\n\n\treturn final\n}\n\nfunc (cb *ContextBuilder) AddToolResult(\n\tmessages []providers.Message,\n\ttoolCallID, toolName, result string,\n) []providers.Message {\n\tmessages = append(messages, providers.Message{\n\t\tRole:       \"tool\",\n\t\tContent:    result,\n\t\tToolCallID: toolCallID,\n\t})\n\treturn messages\n}\n\nfunc (cb *ContextBuilder) AddAssistantMessage(\n\tmessages []providers.Message,\n\tcontent string,\n\ttoolCalls []map[string]any,\n) []providers.Message {\n\tmsg := providers.Message{\n\t\tRole:    \"assistant\",\n\t\tContent: content,\n\t}\n\t// Always add assistant message, whether or not it has tool calls\n\tmessages = append(messages, msg)\n\treturn messages\n}\n\n// GetSkillsInfo returns information about loaded skills.\nfunc (cb *ContextBuilder) GetSkillsInfo() map[string]any {\n\tallSkills := cb.skillsLoader.ListSkills()\n\tskillNames := make([]string, 0, len(allSkills))\n\tfor _, s := range allSkills {\n\t\tskillNames = append(skillNames, s.Name)\n\t}\n\treturn map[string]any{\n\t\t\"total\":     len(allSkills),\n\t\t\"available\": len(allSkills),\n\t\t\"names\":     skillNames,\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/context_cache_test.go",
    "content": "package agent\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// setupWorkspace creates a temporary workspace with standard directories and optional files.\n// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).\nfunc setupWorkspace(t *testing.T, files map[string]string) string {\n\tt.Helper()\n\ttmpDir, err := os.MkdirTemp(\"\", \"picoclaw-test-*\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.MkdirAll(filepath.Join(tmpDir, \"memory\"), 0o755)\n\tos.MkdirAll(filepath.Join(tmpDir, \"skills\"), 0o755)\n\tfor name, content := range files {\n\t\tdir := filepath.Dir(filepath.Join(tmpDir, name))\n\t\tos.MkdirAll(dir, 0o755)\n\t\tif err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\treturn tmpDir\n}\n\n// TestSingleSystemMessage verifies that BuildMessages always produces exactly one\n// system message regardless of summary/history variations.\n// Fix: multiple system messages break Anthropic (top-level system param) and\n// Codex (only reads last system message as instructions).\nfunc TestSingleSystemMessage(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"IDENTITY.md\": \"# Identity\\nTest agent.\",\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\ttests := []struct {\n\t\tname    string\n\t\thistory []providers.Message\n\t\tsummary string\n\t\tmessage string\n\t}{\n\t\t{\n\t\t\tname:    \"no summary, no history\",\n\t\t\tsummary: \"\",\n\t\t\tmessage: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:    \"with summary\",\n\t\t\tsummary: \"Previous conversation discussed X\",\n\t\t\tmessage: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname: \"with history and summary\",\n\t\t\thistory: []providers.Message{\n\t\t\t\t{Role: \"user\", Content: \"hi\"},\n\t\t\t\t{Role: \"assistant\", Content: \"hello\"},\n\t\t\t},\n\t\t\tsummary: strings.Repeat(\"Long summary text. \", 50),\n\t\t\tmessage: \"new message\",\n\t\t},\n\t\t{\n\t\t\tname: \"system message in history is filtered\",\n\t\t\thistory: []providers.Message{\n\t\t\t\t{Role: \"system\", Content: \"stale system prompt from previous session\"},\n\t\t\t\t{Role: \"user\", Content: \"hi\"},\n\t\t\t\t{Role: \"assistant\", Content: \"hello\"},\n\t\t\t},\n\t\t\tsummary: \"\",\n\t\t\tmessage: \"new message\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmsgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, \"test\", \"chat1\", \"\", \"\")\n\n\t\t\tsystemCount := 0\n\t\t\tfor _, m := range msgs {\n\t\t\t\tif m.Role == \"system\" {\n\t\t\t\t\tsystemCount++\n\t\t\t\t}\n\t\t\t}\n\t\t\tif systemCount != 1 {\n\t\t\t\tt.Errorf(\"expected exactly 1 system message, got %d\", systemCount)\n\t\t\t}\n\t\t\tif msgs[0].Role != \"system\" {\n\t\t\t\tt.Errorf(\"first message should be system, got %s\", msgs[0].Role)\n\t\t\t}\n\t\t\tif msgs[len(msgs)-1].Role != \"user\" {\n\t\t\t\tt.Errorf(\"last message should be user, got %s\", msgs[len(msgs)-1].Role)\n\t\t\t}\n\n\t\t\t// System message must contain identity (static) and time (dynamic)\n\t\t\tsys := msgs[0].Content\n\t\t\tif !strings.Contains(sys, \"picoclaw\") {\n\t\t\t\tt.Error(\"system message missing identity\")\n\t\t\t}\n\t\t\tif !strings.Contains(sys, \"Current Time\") {\n\t\t\t\tt.Error(\"system message missing dynamic time context\")\n\t\t\t}\n\n\t\t\t// Summary handling\n\t\t\tif tt.summary != \"\" {\n\t\t\t\tif !strings.Contains(sys, \"CONTEXT_SUMMARY:\") {\n\t\t\t\t\tt.Error(\"summary present but CONTEXT_SUMMARY prefix missing\")\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(sys, tt.summary[:20]) {\n\t\t\t\t\tt.Error(\"summary content not found in system message\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif strings.Contains(sys, \"CONTEXT_SUMMARY:\") {\n\t\t\t\t\tt.Error(\"CONTEXT_SUMMARY should not appear without summary\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"IDENTITY.md\": \"# Identity\\nTest agent.\",\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\ttests := []struct {\n\t\tname              string\n\t\tsenderID          string\n\t\tsenderDisplayName string\n\t\twantLine          string\n\t\twantSection       bool\n\t}{\n\t\t{\n\t\t\tname:              \"both id and display name\",\n\t\t\tsenderID:          \"feishu:ou_xxx\",\n\t\t\tsenderDisplayName: \"Zhang San\",\n\t\t\twantLine:          \"Current sender: Zhang San (ID: feishu:ou_xxx)\",\n\t\t\twantSection:       true,\n\t\t},\n\t\t{\n\t\t\tname:              \"display name only\",\n\t\t\tsenderDisplayName: \"Alice\",\n\t\t\twantLine:          \"Current sender: Alice\",\n\t\t\twantSection:       true,\n\t\t},\n\t\t{\n\t\t\tname:        \"id only\",\n\t\t\tsenderID:    \"discord:123\",\n\t\t\twantLine:    \"Current sender: discord:123\",\n\t\t\twantSection: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no sender info\",\n\t\t\twantSection: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmsgs := cb.BuildMessages(nil, \"\", \"hello\", nil, \"discord\", \"chat1\", tt.senderID, tt.senderDisplayName)\n\t\t\tsys := msgs[0].Content\n\n\t\t\tif tt.wantSection {\n\t\t\t\tif !strings.Contains(sys, \"## Current Sender\") {\n\t\t\t\t\tt.Fatalf(\"system prompt missing Current Sender section:\\n%s\", sys)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(sys, tt.wantLine) {\n\t\t\t\t\tt.Fatalf(\"system prompt missing sender line %q:\\n%s\", tt.wantLine, sys)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif strings.Contains(sys, \"## Current Sender\") {\n\t\t\t\tt.Fatalf(\"system prompt should omit Current Sender section:\\n%s\", sys)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMtimeAutoInvalidation verifies that the cache detects source file changes\n// via mtime without requiring explicit InvalidateCache().\n// Fix: original implementation had no auto-invalidation — edits to bootstrap files,\n// memory, or skills were invisible until process restart.\nfunc TestMtimeAutoInvalidation(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tfile       string // relative path inside workspace\n\t\tcontentV1  string\n\t\tcontentV2  string\n\t\tcheckField string // substring to verify in rebuilt prompt\n\t}{\n\t\t{\n\t\t\tname:       \"bootstrap file change\",\n\t\t\tfile:       \"IDENTITY.md\",\n\t\t\tcontentV1:  \"# Original Identity\",\n\t\t\tcontentV2:  \"# Updated Identity\",\n\t\t\tcheckField: \"Updated Identity\",\n\t\t},\n\t\t{\n\t\t\tname:       \"memory file change\",\n\t\t\tfile:       \"memory/MEMORY.md\",\n\t\t\tcontentV1:  \"# Memory\\nUser likes Go.\",\n\t\t\tcontentV2:  \"# Memory\\nUser likes Rust.\",\n\t\t\tcheckField: \"User likes Rust\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})\n\t\t\tdefer os.RemoveAll(tmpDir)\n\n\t\t\tcb := NewContextBuilder(tmpDir)\n\n\t\t\tsp1 := cb.BuildSystemPromptWithCache()\n\n\t\t\t// Overwrite file and set future mtime to ensure detection.\n\t\t\t// Use 2s offset for filesystem mtime resolution safety (some FS\n\t\t\t// have 1s or coarser granularity, especially in CI containers).\n\t\t\tfullPath := filepath.Join(tmpDir, tt.file)\n\t\t\tos.WriteFile(fullPath, []byte(tt.contentV2), 0o644)\n\t\t\tfuture := time.Now().Add(2 * time.Second)\n\t\t\tos.Chtimes(fullPath, future, future)\n\n\t\t\t// Verify sourceFilesChangedLocked detects the mtime change\n\t\t\tcb.systemPromptMutex.RLock()\n\t\t\tchanged := cb.sourceFilesChangedLocked()\n\t\t\tcb.systemPromptMutex.RUnlock()\n\t\t\tif !changed {\n\t\t\t\tt.Fatalf(\"sourceFilesChangedLocked() should detect %s change\", tt.file)\n\t\t\t}\n\n\t\t\t// Should auto-rebuild without explicit InvalidateCache()\n\t\t\tsp2 := cb.BuildSystemPromptWithCache()\n\t\t\tif sp1 == sp2 {\n\t\t\t\tt.Errorf(\"cache not rebuilt after %s change\", tt.file)\n\t\t\t}\n\t\t\tif !strings.Contains(sp2, tt.checkField) {\n\t\t\t\tt.Errorf(\"rebuilt prompt missing expected content %q\", tt.checkField)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Skills directory mtime change\n\tt.Run(\"skills dir change\", func(t *testing.T) {\n\t\ttmpDir := setupWorkspace(t, nil)\n\t\tdefer os.RemoveAll(tmpDir)\n\n\t\tcb := NewContextBuilder(tmpDir)\n\t\t_ = cb.BuildSystemPromptWithCache() // populate cache\n\n\t\t// Touch skills directory (simulate new skill installed)\n\t\tskillsDir := filepath.Join(tmpDir, \"skills\")\n\t\tfuture := time.Now().Add(2 * time.Second)\n\t\tos.Chtimes(skillsDir, future, future)\n\n\t\t// Verify sourceFilesChangedLocked detects it (cache is rebuilt)\n\t\t// We confirm by checking internal state: a second call should rebuild.\n\t\tcb.systemPromptMutex.RLock()\n\t\tchanged := cb.sourceFilesChangedLocked()\n\t\tcb.systemPromptMutex.RUnlock()\n\t\tif !changed {\n\t\t\tt.Error(\"sourceFilesChangedLocked() should detect skills dir mtime change\")\n\t\t}\n\t})\n}\n\n// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild\n// even when source files haven't changed (useful for tests and reload commands).\nfunc TestExplicitInvalidateCache(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"IDENTITY.md\": \"# Test Identity\",\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\tsp1 := cb.BuildSystemPromptWithCache()\n\tcb.InvalidateCache()\n\tsp2 := cb.BuildSystemPromptWithCache()\n\n\tif sp1 != sp2 {\n\t\tt.Error(\"prompt should be identical after invalidate+rebuild when files unchanged\")\n\t}\n\n\t// Verify cachedAt was reset\n\tcb.InvalidateCache()\n\tcb.systemPromptMutex.RLock()\n\tif !cb.cachedAt.IsZero() {\n\t\tt.Error(\"cachedAt should be zero after InvalidateCache()\")\n\t}\n\tcb.systemPromptMutex.RUnlock()\n}\n\n// TestCacheStability verifies that the static prompt is stable across repeated calls\n// when no files change (regression test for issue #607).\nfunc TestCacheStability(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"IDENTITY.md\": \"# Identity\\nContent\",\n\t\t\"SOUL.md\":     \"# Soul\\nContent\",\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\tresults := make([]string, 5)\n\tfor i := range results {\n\t\tresults[i] = cb.BuildSystemPromptWithCache()\n\t}\n\tfor i := 1; i < len(results); i++ {\n\t\tif results[i] != results[0] {\n\t\t\tt.Errorf(\"cached prompt changed between call 0 and %d\", i)\n\t\t}\n\t}\n\n\t// Static prompt must NOT contain per-request data\n\tif strings.Contains(results[0], \"Current Time\") {\n\t\tt.Error(\"static cached prompt should not contain time (added dynamically)\")\n\t}\n}\n\n// TestNewFileCreationInvalidatesCache verifies that creating a source file that\n// did not exist when the cache was built triggers a cache rebuild.\n// This catches the \"from nothing to something\" edge case that the old\n// modifiedSince (return false on stat error) would miss.\nfunc TestNewFileCreationInvalidatesCache(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tfile       string // relative path inside workspace\n\t\tcontent    string\n\t\tcheckField string // substring to verify in rebuilt prompt\n\t}{\n\t\t{\n\t\t\tname:       \"new bootstrap file\",\n\t\t\tfile:       \"SOUL.md\",\n\t\t\tcontent:    \"# Soul\\nBe kind and helpful.\",\n\t\t\tcheckField: \"Be kind and helpful\",\n\t\t},\n\t\t{\n\t\t\tname:       \"new memory file\",\n\t\t\tfile:       \"memory/MEMORY.md\",\n\t\t\tcontent:    \"# Memory\\nUser prefers dark mode.\",\n\t\t\tcheckField: \"User prefers dark mode\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Start with an empty workspace (no bootstrap/memory files)\n\t\t\ttmpDir := setupWorkspace(t, nil)\n\t\t\tdefer os.RemoveAll(tmpDir)\n\n\t\t\tcb := NewContextBuilder(tmpDir)\n\n\t\t\t// Populate cache — file does not exist yet\n\t\t\tsp1 := cb.BuildSystemPromptWithCache()\n\t\t\tif strings.Contains(sp1, tt.checkField) {\n\t\t\t\tt.Fatalf(\"prompt should not contain %q before file is created\", tt.checkField)\n\t\t\t}\n\n\t\t\t// Create the file after cache was built\n\t\t\tfullPath := filepath.Join(tmpDir, tt.file)\n\t\t\tos.MkdirAll(filepath.Dir(fullPath), 0o755)\n\t\t\tif err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\t// Set future mtime to guarantee detection\n\t\t\tfuture := time.Now().Add(2 * time.Second)\n\t\t\tos.Chtimes(fullPath, future, future)\n\n\t\t\t// Cache should auto-invalidate because file went from absent -> present\n\t\t\tsp2 := cb.BuildSystemPromptWithCache()\n\t\t\tif !strings.Contains(sp2, tt.checkField) {\n\t\t\t\tt.Errorf(\"cache not invalidated on new file creation: expected %q in prompt\", tt.checkField)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSkillFileContentChange verifies that modifying a skill file's content\n// (not just the directory structure) invalidates the cache.\n// This is the scenario where directory mtime alone is insufficient — on most\n// filesystems, editing a file inside a directory does NOT update the parent\n// directory's mtime.\nfunc TestSkillFileContentChange(t *testing.T) {\n\tskillMD := `---\nname: test-skill\ndescription: \"A test skill\"\n---\n# Test Skill v1\nOriginal content.`\n\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"skills/test-skill/SKILL.md\": skillMD,\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\t// Populate cache\n\tsp1 := cb.BuildSystemPromptWithCache()\n\t_ = sp1 // cache is warm\n\n\t// Modify the skill file content (without touching the skills/ directory)\n\tupdatedSkillMD := `---\nname: test-skill\ndescription: \"An updated test skill\"\n---\n# Test Skill v2\nUpdated content.`\n\n\tskillPath := filepath.Join(tmpDir, \"skills\", \"test-skill\", \"SKILL.md\")\n\tif err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Set future mtime on the skill file only (NOT the directory)\n\tfuture := time.Now().Add(2 * time.Second)\n\tos.Chtimes(skillPath, future, future)\n\n\t// Verify that sourceFilesChangedLocked detects the content change\n\tcb.systemPromptMutex.RLock()\n\tchanged := cb.sourceFilesChangedLocked()\n\tcb.systemPromptMutex.RUnlock()\n\tif !changed {\n\t\tt.Error(\"sourceFilesChangedLocked() should detect skill file content change\")\n\t}\n\n\t// Verify cache is actually rebuilt with new content\n\tsp2 := cb.BuildSystemPromptWithCache()\n\tif sp1 == sp2 && strings.Contains(sp1, \"test-skill\") {\n\t\t// If the skill appeared in the prompt and the prompt didn't change,\n\t\t// the cache was not invalidated.\n\t\tt.Error(\"cache should be invalidated when skill file content changes\")\n\t}\n}\n\n// TestGlobalSkillFileContentChange verifies that modifying a global skill\n// (~/.picoclaw/skills) invalidates the cached system prompt.\nfunc TestGlobalSkillFileContentChange(t *testing.T) {\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\ttmpDir := setupWorkspace(t, nil)\n\tdefer os.RemoveAll(tmpDir)\n\n\tglobalSkillPath := filepath.Join(tmpHome, \".picoclaw\", \"skills\", \"global-skill\", \"SKILL.md\")\n\tif err := os.MkdirAll(filepath.Dir(globalSkillPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tv1 := `---\nname: global-skill\ndescription: global-v1\n---\n# Global Skill v1`\n\tif err := os.WriteFile(globalSkillPath, []byte(v1), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcb := NewContextBuilder(tmpDir)\n\tsp1 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp1, \"global-v1\") {\n\t\tt.Fatal(\"expected initial prompt to contain global skill description\")\n\t}\n\n\tv2 := `---\nname: global-skill\ndescription: global-v2\n---\n# Global Skill v2`\n\tif err := os.WriteFile(globalSkillPath, []byte(v2), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfuture := time.Now().Add(2 * time.Second)\n\tif err := os.Chtimes(globalSkillPath, future, future); err != nil {\n\t\tt.Fatalf(\"failed to update mtime for %s: %v\", globalSkillPath, err)\n\t}\n\n\tcb.systemPromptMutex.RLock()\n\tchanged := cb.sourceFilesChangedLocked()\n\tcb.systemPromptMutex.RUnlock()\n\tif !changed {\n\t\tt.Fatal(\"sourceFilesChangedLocked() should detect global skill file content change\")\n\t}\n\n\tsp2 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp2, \"global-v2\") {\n\t\tt.Error(\"rebuilt prompt should contain updated global skill description\")\n\t}\n\tif sp1 == sp2 {\n\t\tt.Error(\"cache should be invalidated when global skill file content changes\")\n\t}\n}\n\n// TestBuiltinSkillFileContentChange verifies that modifying a builtin skill\n// invalidates the cached system prompt.\nfunc TestBuiltinSkillFileContentChange(t *testing.T) {\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\ttmpDir := setupWorkspace(t, nil)\n\tdefer os.RemoveAll(tmpDir)\n\n\tbuiltinRoot := t.TempDir()\n\tt.Setenv(\"PICOCLAW_BUILTIN_SKILLS\", builtinRoot)\n\n\tbuiltinSkillPath := filepath.Join(builtinRoot, \"builtin-skill\", \"SKILL.md\")\n\tif err := os.MkdirAll(filepath.Dir(builtinSkillPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tv1 := `---\nname: builtin-skill\ndescription: builtin-v1\n---\n# Builtin Skill v1`\n\tif err := os.WriteFile(builtinSkillPath, []byte(v1), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcb := NewContextBuilder(tmpDir)\n\tsp1 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp1, \"builtin-v1\") {\n\t\tt.Fatal(\"expected initial prompt to contain builtin skill description\")\n\t}\n\n\tv2 := `---\nname: builtin-skill\ndescription: builtin-v2\n---\n# Builtin Skill v2`\n\tif err := os.WriteFile(builtinSkillPath, []byte(v2), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfuture := time.Now().Add(2 * time.Second)\n\tif err := os.Chtimes(builtinSkillPath, future, future); err != nil {\n\t\tt.Fatalf(\"failed to update mtime for %s: %v\", builtinSkillPath, err)\n\t}\n\n\tcb.systemPromptMutex.RLock()\n\tchanged := cb.sourceFilesChangedLocked()\n\tcb.systemPromptMutex.RUnlock()\n\tif !changed {\n\t\tt.Fatal(\"sourceFilesChangedLocked() should detect builtin skill file content change\")\n\t}\n\n\tsp2 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp2, \"builtin-v2\") {\n\t\tt.Error(\"rebuilt prompt should contain updated builtin skill description\")\n\t}\n\tif sp1 == sp2 {\n\t\tt.Error(\"cache should be invalidated when builtin skill file content changes\")\n\t}\n}\n\n// TestSkillFileDeletionInvalidatesCache verifies that deleting a nested skill\n// file invalidates the cached system prompt.\nfunc TestSkillFileDeletionInvalidatesCache(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"skills/delete-me/SKILL.md\": `---\nname: delete-me\ndescription: delete-me-v1\n---\n# Delete Me`,\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\tsp1 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp1, \"delete-me-v1\") {\n\t\tt.Fatal(\"expected initial prompt to contain skill description\")\n\t}\n\n\tskillPath := filepath.Join(tmpDir, \"skills\", \"delete-me\", \"SKILL.md\")\n\tif err := os.Remove(skillPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcb.systemPromptMutex.RLock()\n\tchanged := cb.sourceFilesChangedLocked()\n\tcb.systemPromptMutex.RUnlock()\n\tif !changed {\n\t\tt.Fatal(\"sourceFilesChangedLocked() should detect deleted skill file\")\n\t}\n\n\tsp2 := cb.BuildSystemPromptWithCache()\n\tif strings.Contains(sp2, \"delete-me-v1\") {\n\t\tt.Error(\"rebuilt prompt should not contain deleted skill description\")\n\t}\n\tif sp1 == sp2 {\n\t\tt.Error(\"cache should be invalidated when skill file is deleted\")\n\t}\n}\n\n// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines\n// can safely call BuildSystemPromptWithCache concurrently without producing\n// empty results, panics, or data races.\n// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache\nfunc TestConcurrentBuildSystemPromptWithCache(t *testing.T) {\n\ttmpDir := setupWorkspace(t, map[string]string{\n\t\t\"IDENTITY.md\":          \"# Identity\\nConcurrency test agent.\",\n\t\t\"SOUL.md\":              \"# Soul\\nBe helpful.\",\n\t\t\"memory/MEMORY.md\":     \"# Memory\\nUser prefers Go.\",\n\t\t\"skills/demo/SKILL.md\": \"---\\nname: demo\\ndescription: \\\"demo skill\\\"\\n---\\n# Demo\",\n\t})\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\tconst goroutines = 20\n\tconst iterations = 50\n\n\tvar wg sync.WaitGroup\n\terrs := make(chan string, goroutines*iterations)\n\n\tfor g := range goroutines {\n\t\twg.Add(1)\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor i := range iterations {\n\t\t\t\tresult := cb.BuildSystemPromptWithCache()\n\t\t\t\tif result == \"\" {\n\t\t\t\t\terrs <- \"empty prompt returned\"\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(result, \"picoclaw\") {\n\t\t\t\t\terrs <- \"prompt missing identity\"\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Also exercise BuildMessages concurrently\n\t\t\t\tmsgs := cb.BuildMessages(nil, \"\", \"hello\", nil, \"test\", \"chat\", \"\", \"\")\n\t\t\t\tif len(msgs) < 2 {\n\t\t\t\t\terrs <- \"BuildMessages returned fewer than 2 messages\"\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif msgs[0].Role != \"system\" {\n\t\t\t\t\terrs <- \"first message not system\"\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Occasionally invalidate to exercise the write path\n\t\t\t\tif i%10 == 0 {\n\t\t\t\t\tcb.InvalidateCache()\n\t\t\t\t}\n\t\t\t}\n\t\t}(g)\n\t}\n\n\twg.Wait()\n\tclose(errs)\n\n\tfor errMsg := range errs {\n\t\tt.Errorf(\"concurrent access error: %s\", errMsg)\n\t}\n}\n\n// BenchmarkBuildMessagesWithCache measures caching performance.\n\n// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is\n// built on an empty workspace (no tracked files exist), creating a file\n// afterwards still triggers cache invalidation. This validates the\n// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,\n// so fileChangedSince correctly detects the absent -> present transition AND\n// the mtime comparison succeeds even without artificially inflated Chtimes.\nfunc TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {\n\t// Empty workspace: no bootstrap files, no memory, no skills content.\n\ttmpDir := setupWorkspace(t, nil)\n\tdefer os.RemoveAll(tmpDir)\n\n\tcb := NewContextBuilder(tmpDir)\n\n\t// Build cache — all tracked files are absent, maxMtime falls back to epoch.\n\tsp1 := cb.BuildSystemPromptWithCache()\n\n\t// Create a bootstrap file with natural mtime (no Chtimes manipulation).\n\t// The file's mtime should be the current wall-clock time, which is\n\t// strictly after time.Unix(1, 0).\n\tsoulPath := filepath.Join(tmpDir, \"SOUL.md\")\n\tif err := os.WriteFile(soulPath, []byte(\"# Soul\\nNewly created.\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Cache should detect the new file via existedAtCache (absent -> present).\n\tcb.systemPromptMutex.RLock()\n\tchanged := cb.sourceFilesChangedLocked()\n\tcb.systemPromptMutex.RUnlock()\n\tif !changed {\n\t\tt.Fatal(\"sourceFilesChangedLocked should detect newly created file on empty workspace\")\n\t}\n\n\tsp2 := cb.BuildSystemPromptWithCache()\n\tif !strings.Contains(sp2, \"Newly created\") {\n\t\tt.Error(\"rebuilt prompt should contain new file content\")\n\t}\n\tif sp1 == sp2 {\n\t\tt.Error(\"cache should have been invalidated after file creation\")\n\t}\n}\n\n// BenchmarkBuildMessagesWithCache measures caching performance.\nfunc BenchmarkBuildMessagesWithCache(b *testing.B) {\n\ttmpDir, _ := os.MkdirTemp(\"\", \"picoclaw-bench-*\")\n\tdefer os.RemoveAll(tmpDir)\n\n\tos.MkdirAll(filepath.Join(tmpDir, \"memory\"), 0o755)\n\tos.MkdirAll(filepath.Join(tmpDir, \"skills\"), 0o755)\n\tfor _, name := range []string{\"IDENTITY.md\", \"SOUL.md\", \"USER.md\"} {\n\t\tos.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat(\"Content.\\n\", 10)), 0o644)\n\t}\n\n\tcb := NewContextBuilder(tmpDir)\n\thistory := []providers.Message{\n\t\t{Role: \"user\", Content: \"previous message\"},\n\t\t{Role: \"assistant\", Content: \"previous response\"},\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = cb.BuildMessages(history, \"summary\", \"new message\", nil, \"cli\", \"test\", \"\", \"\")\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/context_test.go",
    "content": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nfunc msg(role, content string) providers.Message {\n\treturn providers.Message{Role: role, Content: content}\n}\n\nfunc assistantWithTools(toolIDs ...string) providers.Message {\n\tcalls := make([]providers.ToolCall, len(toolIDs))\n\tfor i, id := range toolIDs {\n\t\tcalls[i] = providers.ToolCall{ID: id, Type: \"function\"}\n\t}\n\treturn providers.Message{Role: \"assistant\", ToolCalls: calls}\n}\n\nfunc toolResult(id string) providers.Message {\n\treturn providers.Message{Role: \"tool\", Content: \"result\", ToolCallID: id}\n}\n\nfunc TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {\n\tresult := sanitizeHistoryForProvider(nil)\n\tif len(result) != 0 {\n\t\tt.Fatalf(\"expected empty, got %d messages\", len(result))\n\t}\n\n\tresult = sanitizeHistoryForProvider([]providers.Message{})\n\tif len(result) != 0 {\n\t\tt.Fatalf(\"expected empty, got %d messages\", len(result))\n\t}\n}\n\nfunc TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"hello\"),\n\t\tassistantWithTools(\"A\"),\n\t\ttoolResult(\"A\"),\n\t\tmsg(\"assistant\", \"done\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 4 {\n\t\tt.Fatalf(\"expected 4 messages, got %d\", len(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"tool\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"do two things\"),\n\t\tassistantWithTools(\"A\", \"B\"),\n\t\ttoolResult(\"A\"),\n\t\ttoolResult(\"B\"),\n\t\tmsg(\"assistant\", \"both done\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 5 {\n\t\tt.Fatalf(\"expected 5 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"tool\", \"tool\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"hi\"),\n\t\tmsg(\"assistant\", \"thinking\"),\n\t\tassistantWithTools(\"A\"),\n\t\ttoolResult(\"A\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {\n\thistory := []providers.Message{\n\t\ttoolResult(\"A\"),\n\t\tmsg(\"user\", \"hello\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 message, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\")\n}\n\nfunc TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"hello\"),\n\t\ttoolResult(\"A\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 message, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\")\n}\n\nfunc TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"hello\"),\n\t\tmsg(\"assistant\", \"hi\"),\n\t\ttoolResult(\"A\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {\n\thistory := []providers.Message{\n\t\tassistantWithTools(\"A\"),\n\t\ttoolResult(\"A\"),\n\t\tmsg(\"user\", \"hello\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 message, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\")\n}\n\nfunc TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"do two things\"),\n\t\tassistantWithTools(\"A\", \"B\"),\n\t\ttoolResult(\"A\"),\n\t\ttoolResult(\"B\"),\n\t\tmsg(\"assistant\", \"done\"),\n\t\tmsg(\"user\", \"hi\"),\n\t\tassistantWithTools(\"C\"),\n\t\ttoolResult(\"C\"),\n\t\tmsg(\"assistant\", \"done again\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 9 {\n\t\tt.Fatalf(\"expected 9 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"tool\", \"tool\", \"assistant\", \"user\", \"assistant\", \"tool\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"start\"),\n\t\tassistantWithTools(\"A\", \"B\"),\n\t\ttoolResult(\"A\"),\n\t\ttoolResult(\"B\"),\n\t\tassistantWithTools(\"C\", \"D\"),\n\t\ttoolResult(\"C\"),\n\t\ttoolResult(\"D\"),\n\t\tmsg(\"assistant\", \"all done\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 8 {\n\t\tt.Fatalf(\"expected 8 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"tool\", \"tool\", \"assistant\", \"tool\", \"tool\", \"assistant\")\n}\n\nfunc TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"hello\"),\n\t\tmsg(\"assistant\", \"hi\"),\n\t\tmsg(\"user\", \"how are you\"),\n\t\tmsg(\"assistant\", \"fine\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\tif len(result) != 4 {\n\t\tt.Fatalf(\"expected 4 messages, got %d\", len(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"user\", \"assistant\")\n}\n\nfunc roles(msgs []providers.Message) []string {\n\tr := make([]string, len(msgs))\n\tfor i, m := range msgs {\n\t\tr[i] = m.Role\n\t}\n\treturn r\n}\n\nfunc assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {\n\tt.Helper()\n\tif len(msgs) != len(expected) {\n\t\tt.Fatalf(\"role count mismatch: got %v, want %v\", roles(msgs), expected)\n\t}\n\tfor i, exp := range expected {\n\t\tif msgs[i].Role != exp {\n\t\t\tt.Errorf(\"message[%d]: got role %q, want %q\", i, msgs[i].Role, exp)\n\t\t}\n\t}\n}\n\n// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation\n// that ensures assistant messages with tool_calls have ALL matching tool results.\n// This fixes the DeepSeek error: \"An assistant message with 'tool_calls' must be\n// followed by tool messages responding to each 'tool_call_id'.\"\nfunc TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {\n\t// Assistant expects tool results for both A and B, but only A is present\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"do two things\"),\n\t\tassistantWithTools(\"A\", \"B\"),\n\t\ttoolResult(\"A\"),\n\t\t// toolResult(\"B\") is missing - this would cause DeepSeek to fail\n\t\tmsg(\"user\", \"next question\"),\n\t\tmsg(\"assistant\", \"answer\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\t// The assistant message with incomplete tool results should be dropped,\n\t// along with its partial tool result. The remaining messages are:\n\t// user (\"do two things\"), user (\"next question\"), assistant (\"answer\")\n\tif len(result) != 3 {\n\t\tt.Fatalf(\"expected 3 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"user\", \"assistant\")\n}\n\n// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where\n// an assistant message has tool_calls but no tool results follow at all.\nfunc TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"do something\"),\n\t\tassistantWithTools(\"A\"),\n\t\t// No tool results at all\n\t\tmsg(\"user\", \"hello\"),\n\t\tmsg(\"assistant\", \"hi\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\t// The assistant message with no tool results should be dropped.\n\t// Remaining: user (\"do something\"), user (\"hello\"), assistant (\"hi\")\n\tif len(result) != 3 {\n\t\tt.Fatalf(\"expected 3 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"user\", \"assistant\")\n}\n\n// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that\n// incomplete tool results in the middle of a conversation are properly handled.\nfunc TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {\n\thistory := []providers.Message{\n\t\tmsg(\"user\", \"first\"),\n\t\tassistantWithTools(\"A\"),\n\t\ttoolResult(\"A\"),\n\t\tmsg(\"assistant\", \"done\"),\n\t\tmsg(\"user\", \"second\"),\n\t\tassistantWithTools(\"B\", \"C\"),\n\t\ttoolResult(\"B\"),\n\t\t// toolResult(\"C\") is missing\n\t\tmsg(\"user\", \"third\"),\n\t\tassistantWithTools(\"D\"),\n\t\ttoolResult(\"D\"),\n\t\tmsg(\"assistant\", \"all done\"),\n\t}\n\n\tresult := sanitizeHistoryForProvider(history)\n\t// First round is complete (user, assistant+tools, tool, assistant),\n\t// second round is incomplete and dropped (assistant+tools, partial tool),\n\t// third round is complete (user, assistant+tools, tool, assistant).\n\t// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant\n\tif len(result) != 9 {\n\t\tt.Fatalf(\"expected 9 messages, got %d: %+v\", len(result), roles(result))\n\t}\n\tassertRoles(t, result, \"user\", \"assistant\", \"tool\", \"assistant\", \"user\", \"user\", \"assistant\", \"tool\", \"assistant\")\n}\n"
  },
  {
    "path": "pkg/agent/instance.go",
    "content": "package agent\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/memory\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/routing\"\n\t\"github.com/sipeed/picoclaw/pkg/session\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\n// AgentInstance represents a fully configured agent with its own workspace,\n// session manager, context builder, and tool registry.\ntype AgentInstance struct {\n\tID                        string\n\tName                      string\n\tModel                     string\n\tFallbacks                 []string\n\tWorkspace                 string\n\tMaxIterations             int\n\tMaxTokens                 int\n\tTemperature               float64\n\tThinkingLevel             ThinkingLevel\n\tContextWindow             int\n\tSummarizeMessageThreshold int\n\tSummarizeTokenPercent     int\n\tProvider                  providers.LLMProvider\n\tSessions                  session.SessionStore\n\tContextBuilder            *ContextBuilder\n\tTools                     *tools.ToolRegistry\n\tSubagents                 *config.SubagentsConfig\n\tSkillsFilter              []string\n\tCandidates                []providers.FallbackCandidate\n\n\t// Router is non-nil when model routing is configured and the light model\n\t// was successfully resolved. It scores each incoming message and decides\n\t// whether to route to LightCandidates or stay with Candidates.\n\tRouter *routing.Router\n\t// LightCandidates holds the resolved provider candidates for the light model.\n\t// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.\n\tLightCandidates []providers.FallbackCandidate\n}\n\n// NewAgentInstance creates an agent instance from config.\nfunc NewAgentInstance(\n\tagentCfg *config.AgentConfig,\n\tdefaults *config.AgentDefaults,\n\tcfg *config.Config,\n\tprovider providers.LLMProvider,\n) *AgentInstance {\n\tworkspace := resolveAgentWorkspace(agentCfg, defaults)\n\tos.MkdirAll(workspace, 0o755)\n\n\tmodel := resolveAgentModel(agentCfg, defaults)\n\tfallbacks := resolveAgentFallbacks(agentCfg, defaults)\n\n\trestrict := defaults.RestrictToWorkspace\n\treadRestrict := restrict && !defaults.AllowReadOutsideWorkspace\n\n\t// Compile path whitelist patterns from config.\n\tallowReadPaths := buildAllowReadPatterns(cfg)\n\tallowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)\n\n\ttoolsRegistry := tools.NewToolRegistry()\n\n\tif cfg.Tools.IsToolEnabled(\"read_file\") {\n\t\tmaxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize\n\t\ttoolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))\n\t}\n\tif cfg.Tools.IsToolEnabled(\"write_file\") {\n\t\ttoolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))\n\t}\n\tif cfg.Tools.IsToolEnabled(\"list_dir\") {\n\t\ttoolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))\n\t}\n\tif cfg.Tools.IsToolEnabled(\"exec\") {\n\t\texecTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"agent\", \"Failed to initialize exec tool; continuing without exec\",\n\t\t\t\tmap[string]any{\"error\": err.Error()})\n\t\t} else {\n\t\t\ttoolsRegistry.Register(execTool)\n\t\t}\n\t}\n\n\tif cfg.Tools.IsToolEnabled(\"edit_file\") {\n\t\ttoolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))\n\t}\n\tif cfg.Tools.IsToolEnabled(\"append_file\") {\n\t\ttoolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))\n\t}\n\n\tsessionsDir := filepath.Join(workspace, \"sessions\")\n\tsessions := initSessionStore(sessionsDir)\n\n\tmcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled\n\tcontextBuilder := NewContextBuilder(workspace).WithToolDiscovery(\n\t\tmcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,\n\t\tmcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,\n\t)\n\n\tagentID := routing.DefaultAgentID\n\tagentName := \"\"\n\tvar subagents *config.SubagentsConfig\n\tvar skillsFilter []string\n\n\tif agentCfg != nil {\n\t\tagentID = routing.NormalizeAgentID(agentCfg.ID)\n\t\tagentName = agentCfg.Name\n\t\tsubagents = agentCfg.Subagents\n\t\tskillsFilter = agentCfg.Skills\n\t}\n\n\tmaxIter := defaults.MaxToolIterations\n\tif maxIter == 0 {\n\t\tmaxIter = 20\n\t}\n\n\tmaxTokens := defaults.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 8192\n\t}\n\n\ttemperature := 0.7\n\tif defaults.Temperature != nil {\n\t\ttemperature = *defaults.Temperature\n\t}\n\n\tvar thinkingLevelStr string\n\tif mc, err := cfg.GetModelConfig(model); err == nil {\n\t\tthinkingLevelStr = mc.ThinkingLevel\n\t}\n\tthinkingLevel := parseThinkingLevel(thinkingLevelStr)\n\n\tsummarizeMessageThreshold := defaults.SummarizeMessageThreshold\n\tif summarizeMessageThreshold == 0 {\n\t\tsummarizeMessageThreshold = 20\n\t}\n\n\tsummarizeTokenPercent := defaults.SummarizeTokenPercent\n\tif summarizeTokenPercent == 0 {\n\t\tsummarizeTokenPercent = 75\n\t}\n\n\t// Resolve fallback candidates\n\tcandidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)\n\n\t// Model routing setup: pre-resolve light model candidates at creation time\n\t// to avoid repeated model_list lookups on every incoming message.\n\tvar router *routing.Router\n\tvar lightCandidates []providers.FallbackCandidate\n\tif rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != \"\" {\n\t\tresolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)\n\t\tif len(resolved) > 0 {\n\t\t\trouter = routing.New(routing.RouterConfig{\n\t\t\t\tLightModel: rc.LightModel,\n\t\t\t\tThreshold:  rc.Threshold,\n\t\t\t})\n\t\t\tlightCandidates = resolved\n\t\t} else {\n\t\t\tlogger.WarnCF(\"agent\", \"Routing light model not found; routing disabled\",\n\t\t\t\tmap[string]any{\"light_model\": rc.LightModel, \"agent_id\": agentID})\n\t\t}\n\t}\n\n\treturn &AgentInstance{\n\t\tID:                        agentID,\n\t\tName:                      agentName,\n\t\tModel:                     model,\n\t\tFallbacks:                 fallbacks,\n\t\tWorkspace:                 workspace,\n\t\tMaxIterations:             maxIter,\n\t\tMaxTokens:                 maxTokens,\n\t\tTemperature:               temperature,\n\t\tThinkingLevel:             thinkingLevel,\n\t\tContextWindow:             maxTokens,\n\t\tSummarizeMessageThreshold: summarizeMessageThreshold,\n\t\tSummarizeTokenPercent:     summarizeTokenPercent,\n\t\tProvider:                  provider,\n\t\tSessions:                  sessions,\n\t\tContextBuilder:            contextBuilder,\n\t\tTools:                     toolsRegistry,\n\t\tSubagents:                 subagents,\n\t\tSkillsFilter:              skillsFilter,\n\t\tCandidates:                candidates,\n\t\tRouter:                    router,\n\t\tLightCandidates:           lightCandidates,\n\t}\n}\n\n// resolveAgentWorkspace determines the workspace directory for an agent.\nfunc resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {\n\tif agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != \"\" {\n\t\treturn expandHome(strings.TrimSpace(agentCfg.Workspace))\n\t}\n\t// Use the configured default workspace (respects PICOCLAW_HOME)\n\tif agentCfg == nil || agentCfg.Default || agentCfg.ID == \"\" || routing.NormalizeAgentID(agentCfg.ID) == \"main\" {\n\t\treturn expandHome(defaults.Workspace)\n\t}\n\t// For named agents without explicit workspace, use default workspace with agent ID suffix\n\tid := routing.NormalizeAgentID(agentCfg.ID)\n\treturn filepath.Join(expandHome(defaults.Workspace), \"..\", \"workspace-\"+id)\n}\n\n// resolveAgentModel resolves the primary model for an agent.\nfunc resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {\n\tif agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != \"\" {\n\t\treturn strings.TrimSpace(agentCfg.Model.Primary)\n\t}\n\treturn defaults.GetModelName()\n}\n\n// resolveAgentFallbacks resolves the fallback models for an agent.\nfunc resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {\n\tif agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {\n\t\treturn agentCfg.Model.Fallbacks\n\t}\n\treturn defaults.ModelFallbacks\n}\n\nfunc compilePatterns(patterns []string) []*regexp.Regexp {\n\tcompiled := make([]*regexp.Regexp, 0, len(patterns))\n\tfor _, p := range patterns {\n\t\tre, err := regexp.Compile(p)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Warning: invalid path pattern %q: %v\\n\", p, err)\n\t\t\tcontinue\n\t\t}\n\t\tcompiled = append(compiled, re)\n\t}\n\treturn compiled\n}\n\nfunc buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp {\n\tvar configured []string\n\tif cfg != nil {\n\t\tconfigured = cfg.Tools.AllowReadPaths\n\t}\n\n\tcompiled := compilePatterns(configured)\n\tmediaDirPattern := regexp.MustCompile(mediaTempDirPattern())\n\tfor _, pattern := range compiled {\n\t\tif pattern.String() == mediaDirPattern.String() {\n\t\t\treturn compiled\n\t\t}\n\t}\n\n\treturn append(compiled, mediaDirPattern)\n}\n\nfunc mediaTempDirPattern() string {\n\tsep := regexp.QuoteMeta(string(os.PathSeparator))\n\treturn \"^\" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + \"(?:\" + sep + \"|$)\"\n}\n\n// Close releases resources held by the agent's session store.\nfunc (a *AgentInstance) Close() error {\n\tif a.Sessions != nil {\n\t\treturn a.Sessions.Close()\n\t}\n\treturn nil\n}\n\n// initSessionStore creates the session persistence backend.\n// It uses the JSONL store by default and auto-migrates legacy JSON sessions.\n// Falls back to SessionManager if the JSONL store cannot be initialized or\n// if migration fails (which indicates the store cannot write reliably).\nfunc initSessionStore(dir string) session.SessionStore {\n\tstore, err := memory.NewJSONLStore(dir)\n\tif err != nil {\n\t\tlogger.WarnCF(\"agent\", \"Memory JSONL store init failed; falling back to json sessions\",\n\t\t\tmap[string]any{\"error\": err.Error()})\n\t\treturn session.NewSessionManager(dir)\n\t}\n\n\tif n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {\n\t\t// Migration failure means the store could not write data.\n\t\t// Fall back to SessionManager to avoid a split state where\n\t\t// some sessions are in JSONL and others remain in JSON.\n\t\tlogger.WarnCF(\"agent\", \"Memory migration failed; falling back to json sessions\",\n\t\t\tmap[string]any{\"error\": merr.Error()})\n\t\tstore.Close()\n\t\treturn session.NewSessionManager(dir)\n\t} else if n > 0 {\n\t\tlogger.InfoCF(\"agent\", \"Memory migrated to JSONL\", map[string]any{\"sessions_migrated\": n})\n\t}\n\n\treturn session.NewJSONLBackend(store)\n}\n\nfunc expandHome(path string) string {\n\tif path == \"\" {\n\t\treturn path\n\t}\n\tif path[0] == '~' {\n\t\thome, _ := os.UserHomeDir()\n\t\tif len(path) > 1 && path[1] == '/' {\n\t\t\treturn home + path[1:]\n\t\t}\n\t\treturn home\n\t}\n\treturn path\n}\n"
  },
  {
    "path": "pkg/agent/instance_test.go",
    "content": "package agent\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nfunc TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-instance-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         1234,\n\t\t\t\tMaxToolIterations: 5,\n\t\t\t},\n\t\t},\n\t}\n\n\tconfiguredTemp := 1.0\n\tcfg.Agents.Defaults.Temperature = &configuredTemp\n\n\tprovider := &mockProvider{}\n\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)\n\n\tif agent.MaxTokens != 1234 {\n\t\tt.Fatalf(\"MaxTokens = %d, want %d\", agent.MaxTokens, 1234)\n\t}\n\tif agent.Temperature != 1.0 {\n\t\tt.Fatalf(\"Temperature = %f, want %f\", agent.Temperature, 1.0)\n\t}\n}\n\nfunc TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-instance-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         1234,\n\t\t\t\tMaxToolIterations: 5,\n\t\t\t},\n\t\t},\n\t}\n\n\tconfiguredTemp := 0.0\n\tcfg.Agents.Defaults.Temperature = &configuredTemp\n\n\tprovider := &mockProvider{}\n\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)\n\n\tif agent.Temperature != 0.0 {\n\t\tt.Fatalf(\"Temperature = %f, want %f\", agent.Temperature, 0.0)\n\t}\n}\n\nfunc TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-instance-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         1234,\n\t\t\t\tMaxToolIterations: 5,\n\t\t\t},\n\t\t},\n\t}\n\n\tprovider := &mockProvider{}\n\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)\n\n\tif agent.Temperature != 0.7 {\n\t\tt.Fatalf(\"Temperature = %f, want %f\", agent.Temperature, 0.7)\n\t}\n}\n\nfunc TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\taliasName    string\n\t\tmodelName    string\n\t\tapiBase      string\n\t\twantProvider string\n\t\twantModel    string\n\t}{\n\t\t{\n\t\t\tname:         \"alias with provider prefix\",\n\t\t\taliasName:    \"step-3.5-flash\",\n\t\t\tmodelName:    \"openrouter/stepfun/step-3.5-flash:free\",\n\t\t\tapiBase:      \"https://openrouter.ai/api/v1\",\n\t\t\twantProvider: \"openrouter\",\n\t\t\twantModel:    \"stepfun/step-3.5-flash:free\",\n\t\t},\n\t\t{\n\t\t\tname:         \"alias without provider prefix\",\n\t\t\taliasName:    \"glm-5\",\n\t\t\tmodelName:    \"glm-5\",\n\t\t\tapiBase:      \"https://api.z.ai/api/coding/paas/v4\",\n\t\t\twantProvider: \"openai\",\n\t\t\twantModel:    \"glm-5\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"agent-instance-test-*\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t\t\t}\n\t\t\tdefer os.RemoveAll(tmpDir)\n\n\t\t\tcfg := &config.Config{\n\t\t\t\tAgents: config.AgentsConfig{\n\t\t\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\t\t\tWorkspace: tmpDir,\n\t\t\t\t\t\tModel:     tt.aliasName,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tModelList: []config.ModelConfig{\n\t\t\t\t\t{\n\t\t\t\t\t\tModelName: tt.aliasName,\n\t\t\t\t\t\tModel:     tt.modelName,\n\t\t\t\t\t\tAPIBase:   tt.apiBase,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tprovider := &mockProvider{}\n\t\t\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)\n\n\t\t\tif len(agent.Candidates) != 1 {\n\t\t\t\tt.Fatalf(\"len(Candidates) = %d, want 1\", len(agent.Candidates))\n\t\t\t}\n\t\t\tif agent.Candidates[0].Provider != tt.wantProvider {\n\t\t\t\tt.Fatalf(\"candidate provider = %q, want %q\", agent.Candidates[0].Provider, tt.wantProvider)\n\t\t\t}\n\t\t\tif agent.Candidates[0].Model != tt.wantModel {\n\t\t\t\tt.Fatalf(\"candidate model = %q, want %q\", agent.Candidates[0].Model, tt.wantModel)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {\n\tworkspace := t.TempDir()\n\tmediaDir := media.TempDir()\n\tif err := os.MkdirAll(mediaDir, 0o700); err != nil {\n\t\tt.Fatalf(\"MkdirAll(mediaDir) error = %v\", err)\n\t}\n\n\tmediaFile, err := os.CreateTemp(mediaDir, \"instance-tool-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"CreateTemp(mediaDir) error = %v\", err)\n\t}\n\tmediaPath := mediaFile.Name()\n\tif _, err := mediaFile.WriteString(\"attachment content\"); err != nil {\n\t\tmediaFile.Close()\n\t\tt.Fatalf(\"WriteString(mediaFile) error = %v\", err)\n\t}\n\tif err := mediaFile.Close(); err != nil {\n\t\tt.Fatalf(\"Close(mediaFile) error = %v\", err)\n\t}\n\tt.Cleanup(func() { _ = os.Remove(mediaPath) })\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:           workspace,\n\t\t\t\tModelName:           \"test-model\",\n\t\t\t\tRestrictToWorkspace: true,\n\t\t\t},\n\t\t},\n\t\tTools: config.ToolsConfig{\n\t\t\tReadFile: config.ReadFileToolConfig{Enabled: true},\n\t\t\tListDir:  config.ToolConfig{Enabled: true},\n\t\t\tExec: config.ExecConfig{\n\t\t\t\tToolConfig:         config.ToolConfig{Enabled: true},\n\t\t\t\tEnableDenyPatterns: true,\n\t\t\t\tAllowRemote:        true,\n\t\t\t},\n\t\t},\n\t}\n\n\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})\n\n\treadTool, ok := agent.Tools.Get(\"read_file\")\n\tif !ok {\n\t\tt.Fatal(\"read_file tool not registered\")\n\t}\n\treadResult := readTool.Execute(context.Background(), map[string]any{\"path\": mediaPath})\n\tif readResult.IsError {\n\t\tt.Fatalf(\"read_file should allow media temp dir, got: %s\", readResult.ForLLM)\n\t}\n\tif !strings.Contains(readResult.ForLLM, \"attachment content\") {\n\t\tt.Fatalf(\"read_file output missing media content: %s\", readResult.ForLLM)\n\t}\n\n\tlistTool, ok := agent.Tools.Get(\"list_dir\")\n\tif !ok {\n\t\tt.Fatal(\"list_dir tool not registered\")\n\t}\n\tlistResult := listTool.Execute(context.Background(), map[string]any{\"path\": mediaDir})\n\tif listResult.IsError {\n\t\tt.Fatalf(\"list_dir should allow media temp dir, got: %s\", listResult.ForLLM)\n\t}\n\tif !strings.Contains(listResult.ForLLM, filepath.Base(mediaPath)) {\n\t\tt.Fatalf(\"list_dir output missing media file: %s\", listResult.ForLLM)\n\t}\n\n\texecTool, ok := agent.Tools.Get(\"exec\")\n\tif !ok {\n\t\tt.Fatal(\"exec tool not registered\")\n\t}\n\texecResult := execTool.Execute(context.Background(), map[string]any{\n\t\t\"command\":     \"cat \" + filepath.Base(mediaPath),\n\t\t\"working_dir\": mediaDir,\n\t})\n\tif execResult.IsError {\n\t\tt.Fatalf(\"exec should allow media temp dir, got: %s\", execResult.ForLLM)\n\t}\n\tif !strings.Contains(execResult.ForLLM, \"attachment content\") {\n\t\tt.Fatalf(\"exec output missing media content: %s\", execResult.ForLLM)\n\t}\n}\n\nfunc TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {\n\tworkspace := t.TempDir()\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace: workspace,\n\t\t\t\tModelName: \"test-model\",\n\t\t\t},\n\t\t},\n\t\tTools: config.ToolsConfig{\n\t\t\tReadFile: config.ReadFileToolConfig{Enabled: true},\n\t\t\tExec: config.ExecConfig{\n\t\t\t\tToolConfig:         config.ToolConfig{Enabled: true},\n\t\t\t\tEnableDenyPatterns: true,\n\t\t\t\tCustomDenyPatterns: []string{\"[invalid-regex\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tagent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})\n\tif agent == nil {\n\t\tt.Fatal(\"expected agent instance, got nil\")\n\t}\n\n\tif _, ok := agent.Tools.Get(\"exec\"); ok {\n\t\tt.Fatal(\"exec tool should not be registered when exec config is invalid\")\n\t}\n\n\tif _, ok := agent.Tools.Get(\"read_file\"); !ok {\n\t\tt.Fatal(\"read_file tool should still be registered\")\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/loop.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/routing\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n\t\"github.com/sipeed/picoclaw/pkg/state\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n\t\"github.com/sipeed/picoclaw/pkg/voice\"\n)\n\ntype AgentLoop struct {\n\tbus            *bus.MessageBus\n\tcfg            *config.Config\n\tregistry       *AgentRegistry\n\tstate          *state.Manager\n\trunning        atomic.Bool\n\tsummarizing    sync.Map\n\tfallback       *providers.FallbackChain\n\tchannelManager *channels.Manager\n\tmediaStore     media.MediaStore\n\ttranscriber    voice.Transcriber\n\tcmdRegistry    *commands.Registry\n\tmcp            mcpRuntime\n\tmu             sync.RWMutex\n\treloadFunc     func() error\n\t// Track active requests for safe provider cleanup\n\tactiveRequests sync.WaitGroup\n}\n\n// processOptions configures how a message is processed\ntype processOptions struct {\n\tSessionKey        string   // Session identifier for history/context\n\tChannel           string   // Target channel for tool execution\n\tChatID            string   // Target chat ID for tool execution\n\tSenderID          string   // Current sender ID for dynamic context\n\tSenderDisplayName string   // Current sender display name for dynamic context\n\tUserMessage       string   // User message content (may include prefix)\n\tMedia             []string // media:// refs from inbound message\n\tDefaultResponse   string   // Response when LLM returns empty\n\tEnableSummary     bool     // Whether to trigger summarization\n\tSendResponse      bool     // Whether to send response via bus\n\tNoHistory         bool     // If true, don't load session history (for heartbeat)\n}\n\nconst (\n\tdefaultResponse           = \"I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json.\"\n\tsessionKeyAgentPrefix     = \"agent:\"\n\tmetadataKeyAccountID      = \"account_id\"\n\tmetadataKeyGuildID        = \"guild_id\"\n\tmetadataKeyTeamID         = \"team_id\"\n\tmetadataKeyParentPeerKind = \"parent_peer_kind\"\n\tmetadataKeyParentPeerID   = \"parent_peer_id\"\n)\n\nfunc NewAgentLoop(\n\tcfg *config.Config,\n\tmsgBus *bus.MessageBus,\n\tprovider providers.LLMProvider,\n) *AgentLoop {\n\tregistry := NewAgentRegistry(cfg, provider)\n\n\t// Register shared tools to all agents\n\tregisterSharedTools(cfg, msgBus, registry, provider)\n\n\t// Set up shared fallback chain\n\tcooldown := providers.NewCooldownTracker()\n\tfallbackChain := providers.NewFallbackChain(cooldown)\n\n\t// Create state manager using default agent's workspace for channel recording\n\tdefaultAgent := registry.GetDefaultAgent()\n\tvar stateManager *state.Manager\n\tif defaultAgent != nil {\n\t\tstateManager = state.NewManager(defaultAgent.Workspace)\n\t}\n\n\tal := &AgentLoop{\n\t\tbus:         msgBus,\n\t\tcfg:         cfg,\n\t\tregistry:    registry,\n\t\tstate:       stateManager,\n\t\tsummarizing: sync.Map{},\n\t\tfallback:    fallbackChain,\n\t\tcmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),\n\t}\n\n\treturn al\n}\n\n// registerSharedTools registers tools that are shared across all agents (web, message, spawn).\nfunc registerSharedTools(\n\tcfg *config.Config,\n\tmsgBus *bus.MessageBus,\n\tregistry *AgentRegistry,\n\tprovider providers.LLMProvider,\n) {\n\tallowReadPaths := buildAllowReadPatterns(cfg)\n\n\tfor _, agentID := range registry.ListAgentIDs() {\n\t\tagent, ok := registry.GetAgent(agentID)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif cfg.Tools.IsToolEnabled(\"web\") {\n\t\t\tsearchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{\n\t\t\t\tBraveAPIKeys:         config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),\n\t\t\t\tBraveMaxResults:      cfg.Tools.Web.Brave.MaxResults,\n\t\t\t\tBraveEnabled:         cfg.Tools.Web.Brave.Enabled,\n\t\t\t\tTavilyAPIKeys:        config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),\n\t\t\t\tTavilyBaseURL:        cfg.Tools.Web.Tavily.BaseURL,\n\t\t\t\tTavilyMaxResults:     cfg.Tools.Web.Tavily.MaxResults,\n\t\t\t\tTavilyEnabled:        cfg.Tools.Web.Tavily.Enabled,\n\t\t\t\tDuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,\n\t\t\t\tDuckDuckGoEnabled:    cfg.Tools.Web.DuckDuckGo.Enabled,\n\t\t\t\tPerplexityAPIKeys: config.MergeAPIKeys(\n\t\t\t\t\tcfg.Tools.Web.Perplexity.APIKey,\n\t\t\t\t\tcfg.Tools.Web.Perplexity.APIKeys,\n\t\t\t\t),\n\t\t\t\tPerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,\n\t\t\t\tPerplexityEnabled:    cfg.Tools.Web.Perplexity.Enabled,\n\t\t\t\tSearXNGBaseURL:       cfg.Tools.Web.SearXNG.BaseURL,\n\t\t\t\tSearXNGMaxResults:    cfg.Tools.Web.SearXNG.MaxResults,\n\t\t\t\tSearXNGEnabled:       cfg.Tools.Web.SearXNG.Enabled,\n\t\t\t\tGLMSearchAPIKey:      cfg.Tools.Web.GLMSearch.APIKey,\n\t\t\t\tGLMSearchBaseURL:     cfg.Tools.Web.GLMSearch.BaseURL,\n\t\t\t\tGLMSearchEngine:      cfg.Tools.Web.GLMSearch.SearchEngine,\n\t\t\t\tGLMSearchMaxResults:  cfg.Tools.Web.GLMSearch.MaxResults,\n\t\t\t\tGLMSearchEnabled:     cfg.Tools.Web.GLMSearch.Enabled,\n\t\t\t\tProxy:                cfg.Tools.Web.Proxy,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorCF(\"agent\", \"Failed to create web search tool\", map[string]any{\"error\": err.Error()})\n\t\t\t} else if searchTool != nil {\n\t\t\t\tagent.Tools.Register(searchTool)\n\t\t\t}\n\t\t}\n\t\tif cfg.Tools.IsToolEnabled(\"web_fetch\") {\n\t\t\tfetchTool, err := tools.NewWebFetchToolWithProxy(\n\t\t\t\t50000,\n\t\t\t\tcfg.Tools.Web.Proxy,\n\t\t\t\tcfg.Tools.Web.Format,\n\t\t\t\tcfg.Tools.Web.FetchLimitBytes,\n\t\t\t\tcfg.Tools.Web.PrivateHostWhitelist)\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t\t\t} else {\n\t\t\t\tagent.Tools.Register(fetchTool)\n\t\t\t}\n\t\t}\n\n\t\t// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms\n\t\tif cfg.Tools.IsToolEnabled(\"i2c\") {\n\t\t\tagent.Tools.Register(tools.NewI2CTool())\n\t\t}\n\t\tif cfg.Tools.IsToolEnabled(\"spi\") {\n\t\t\tagent.Tools.Register(tools.NewSPITool())\n\t\t}\n\n\t\t// Message tool\n\t\tif cfg.Tools.IsToolEnabled(\"message\") {\n\t\t\tmessageTool := tools.NewMessageTool()\n\t\t\tmessageTool.SetSendCallback(func(channel, chatID, content string) error {\n\t\t\t\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\t\tdefer pubCancel()\n\t\t\t\treturn msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\t\t\t\tChannel: channel,\n\t\t\t\t\tChatID:  chatID,\n\t\t\t\t\tContent: content,\n\t\t\t\t})\n\t\t\t})\n\t\t\tagent.Tools.Register(messageTool)\n\t\t}\n\n\t\t// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)\n\t\tif cfg.Tools.IsToolEnabled(\"send_file\") {\n\t\t\tsendFileTool := tools.NewSendFileTool(\n\t\t\t\tagent.Workspace,\n\t\t\t\tcfg.Agents.Defaults.RestrictToWorkspace,\n\t\t\t\tcfg.Agents.Defaults.GetMaxMediaSize(),\n\t\t\t\tnil,\n\t\t\t\tallowReadPaths,\n\t\t\t)\n\t\t\tagent.Tools.Register(sendFileTool)\n\t\t}\n\n\t\t// Skill discovery and installation tools\n\t\tskills_enabled := cfg.Tools.IsToolEnabled(\"skills\")\n\t\tfind_skills_enable := cfg.Tools.IsToolEnabled(\"find_skills\")\n\t\tinstall_skills_enable := cfg.Tools.IsToolEnabled(\"install_skill\")\n\t\tif skills_enabled && (find_skills_enable || install_skills_enable) {\n\t\t\tregistryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{\n\t\t\t\tMaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,\n\t\t\t\tClawHub:               skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),\n\t\t\t})\n\n\t\t\tif find_skills_enable {\n\t\t\t\tsearchCache := skills.NewSearchCache(\n\t\t\t\t\tcfg.Tools.Skills.SearchCache.MaxSize,\n\t\t\t\t\ttime.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,\n\t\t\t\t)\n\t\t\t\tagent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))\n\t\t\t}\n\n\t\t\tif install_skills_enable {\n\t\t\t\tagent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))\n\t\t\t}\n\t\t}\n\n\t\t// Spawn and spawn_status tools share a SubagentManager.\n\t\t// Construct it when either tool is enabled (both require subagent).\n\t\tspawnEnabled := cfg.Tools.IsToolEnabled(\"spawn\")\n\t\tspawnStatusEnabled := cfg.Tools.IsToolEnabled(\"spawn_status\")\n\t\tif (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled(\"subagent\") {\n\t\t\tsubagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)\n\t\t\tsubagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)\n\t\t\t// Clone the parent's tool registry so subagents can use all\n\t\t\t// tools registered so far (file, web, etc.) but NOT spawn/\n\t\t\t// spawn_status which are added below — preventing recursive\n\t\t\t// subagent spawning.\n\t\t\tsubagentManager.SetTools(agent.Tools.Clone())\n\t\t\tif spawnEnabled {\n\t\t\t\tspawnTool := tools.NewSpawnTool(subagentManager)\n\t\t\t\tcurrentAgentID := agentID\n\t\t\t\tspawnTool.SetAllowlistChecker(func(targetAgentID string) bool {\n\t\t\t\t\treturn registry.CanSpawnSubagent(currentAgentID, targetAgentID)\n\t\t\t\t})\n\t\t\t\tagent.Tools.Register(spawnTool)\n\t\t\t}\n\t\t\tif spawnStatusEnabled {\n\t\t\t\tagent.Tools.Register(tools.NewSpawnStatusTool(subagentManager))\n\t\t\t}\n\t\t} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled(\"subagent\") {\n\t\t\tlogger.WarnCF(\"agent\", \"spawn/spawn_status tools require subagent to be enabled\", nil)\n\t\t}\n\t}\n}\n\nfunc (al *AgentLoop) Run(ctx context.Context) error {\n\tal.running.Store(true)\n\n\tif err := al.ensureMCPInitialized(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tfor al.running.Load() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil\n\t\tcase msg, ok := <-al.bus.InboundChan():\n\t\t\tif !ok {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\t// Process message\n\t\t\tfunc() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tif al.channelManager != nil {\n\t\t\t\t\t\tal.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t\t// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.\n\t\t\t\t// Currently disabled because files are deleted before the LLM can access their content.\n\t\t\t\t// defer func() {\n\t\t\t\t// \tif al.mediaStore != nil && msg.MediaScope != \"\" {\n\t\t\t\t// \t\tif releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {\n\t\t\t\t// \t\t\tlogger.WarnCF(\"agent\", \"Failed to release media\", map[string]any{\n\t\t\t\t// \t\t\t\t\"scope\": msg.MediaScope,\n\t\t\t\t// \t\t\t\t\"error\": releaseErr.Error(),\n\t\t\t\t// \t\t\t})\n\t\t\t\t// \t\t}\n\t\t\t\t// \t}\n\t\t\t\t// }()\n\n\t\t\t\tresponse, err := al.processMessage(ctx, msg)\n\t\t\t\tif err != nil {\n\t\t\t\t\tresponse = fmt.Sprintf(\"Error processing message: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif response != \"\" {\n\t\t\t\t\t// Check if the message tool already sent a response during this round.\n\t\t\t\t\t// If so, skip publishing to avoid duplicate messages to the user.\n\t\t\t\t\t// Use default agent's tools to check (message tool is shared).\n\t\t\t\t\talreadySent := false\n\t\t\t\t\tdefaultAgent := al.GetRegistry().GetDefaultAgent()\n\t\t\t\t\tif defaultAgent != nil {\n\t\t\t\t\t\tif tool, ok := defaultAgent.Tools.Get(\"message\"); ok {\n\t\t\t\t\t\t\tif mt, ok := tool.(*tools.MessageTool); ok {\n\t\t\t\t\t\t\t\talreadySent = mt.HasSentInRound()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !alreadySent {\n\t\t\t\t\t\tal.bus.PublishOutbound(ctx, bus.OutboundMessage{\n\t\t\t\t\t\t\tChannel: msg.Channel,\n\t\t\t\t\t\t\tChatID:  msg.ChatID,\n\t\t\t\t\t\t\tContent: response,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tlogger.InfoCF(\"agent\", \"Published outbound response\",\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"channel\":     msg.Channel,\n\t\t\t\t\t\t\t\t\"chat_id\":     msg.ChatID,\n\t\t\t\t\t\t\t\t\"content_len\": len(response),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.DebugCF(\n\t\t\t\t\t\t\t\"agent\",\n\t\t\t\t\t\t\t\"Skipped outbound (message tool already sent)\",\n\t\t\t\t\t\t\tmap[string]any{\"channel\": msg.Channel},\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}()\n\t\tdefault:\n\t\t\ttime.Sleep(time.Microsecond * 200)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (al *AgentLoop) Stop() {\n\tal.running.Store(false)\n}\n\n// Close releases resources held by agent session stores. Call after Stop.\nfunc (al *AgentLoop) Close() {\n\tmcpManager := al.mcp.takeManager()\n\n\tif mcpManager != nil {\n\t\tif err := mcpManager.Close(); err != nil {\n\t\t\tlogger.ErrorCF(\"agent\", \"Failed to close MCP manager\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t}\n\t}\n\n\tal.GetRegistry().Close()\n}\n\nfunc (al *AgentLoop) RegisterTool(tool tools.Tool) {\n\tregistry := al.GetRegistry()\n\tfor _, agentID := range registry.ListAgentIDs() {\n\t\tif agent, ok := registry.GetAgent(agentID); ok {\n\t\t\tagent.Tools.Register(tool)\n\t\t}\n\t}\n}\n\nfunc (al *AgentLoop) SetChannelManager(cm *channels.Manager) {\n\tal.channelManager = cm\n}\n\n// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization.\n// It uses a context to allow timeout control from the caller.\n// Returns an error if the reload fails or context is canceled.\nfunc (al *AgentLoop) ReloadProviderAndConfig(\n\tctx context.Context,\n\tprovider providers.LLMProvider,\n\tcfg *config.Config,\n) error {\n\t// Validate inputs\n\tif provider == nil {\n\t\treturn fmt.Errorf(\"provider cannot be nil\")\n\t}\n\tif cfg == nil {\n\t\treturn fmt.Errorf(\"config cannot be nil\")\n\t}\n\n\t// Create new registry with updated config and provider\n\t// Wrap in defer/recover to handle any panics gracefully\n\tvar registry *AgentRegistry\n\tvar panicErr error\n\tdone := make(chan struct{}, 1)\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif r := recover(); r != nil {\n\t\t\t\tpanicErr = fmt.Errorf(\"panic during registry creation: %v\", r)\n\t\t\t\tlogger.ErrorCF(\"agent\", \"Panic during registry creation\",\n\t\t\t\t\tmap[string]any{\"panic\": r})\n\t\t\t}\n\t\t\tclose(done)\n\t\t}()\n\n\t\tregistry = NewAgentRegistry(cfg, provider)\n\t}()\n\n\t// Wait for completion or context cancellation\n\tselect {\n\tcase <-done:\n\t\tif registry == nil {\n\t\t\tif panicErr != nil {\n\t\t\t\treturn fmt.Errorf(\"registry creation failed: %w\", panicErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"registry creation failed (nil result)\")\n\t\t}\n\tcase <-ctx.Done():\n\t\treturn fmt.Errorf(\"context canceled during registry creation: %w\", ctx.Err())\n\t}\n\n\t// Check context again before proceeding\n\tif err := ctx.Err(); err != nil {\n\t\treturn fmt.Errorf(\"context canceled after registry creation: %w\", err)\n\t}\n\n\t// Ensure shared tools are re-registered on the new registry\n\tregisterSharedTools(cfg, al.bus, registry, provider)\n\n\t// Atomically swap the config and registry under write lock\n\t// This ensures readers see a consistent pair\n\tal.mu.Lock()\n\toldRegistry := al.registry\n\n\t// Store new values\n\tal.cfg = cfg\n\tal.registry = registry\n\n\t// Also update fallback chain with new config\n\tal.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())\n\n\tal.mu.Unlock()\n\n\t// Close old provider after releasing the lock\n\t// This prevents blocking readers while closing\n\tif oldProvider, ok := extractProvider(oldRegistry); ok {\n\t\tif stateful, ok := oldProvider.(providers.StatefulProvider); ok {\n\t\t\t// Give in-flight requests a moment to complete\n\t\t\t// Use a reasonable timeout that balances cleanup vs resource usage\n\t\t\tselect {\n\t\t\tcase <-time.After(100 * time.Millisecond):\n\t\t\t\tstateful.Close()\n\t\t\tcase <-ctx.Done():\n\t\t\t\t// Context canceled, close immediately but log warning\n\t\t\t\tlogger.WarnCF(\"agent\", \"Context canceled during provider cleanup, forcing close\",\n\t\t\t\t\tmap[string]any{\"error\": ctx.Err()})\n\t\t\t\tstateful.Close()\n\t\t\t}\n\t\t}\n\t}\n\n\tlogger.InfoCF(\"agent\", \"Provider and config reloaded successfully\",\n\t\tmap[string]any{\n\t\t\t\"model\": cfg.Agents.Defaults.GetModelName(),\n\t\t})\n\n\treturn nil\n}\n\n// GetRegistry returns the current registry (thread-safe)\nfunc (al *AgentLoop) GetRegistry() *AgentRegistry {\n\tal.mu.RLock()\n\tdefer al.mu.RUnlock()\n\treturn al.registry\n}\n\n// GetConfig returns the current config (thread-safe)\nfunc (al *AgentLoop) GetConfig() *config.Config {\n\tal.mu.RLock()\n\tdefer al.mu.RUnlock()\n\treturn al.cfg\n}\n\n// SetMediaStore injects a MediaStore for media lifecycle management.\nfunc (al *AgentLoop) SetMediaStore(s media.MediaStore) {\n\tal.mediaStore = s\n\n\t// Propagate store to send_file tools in all agents.\n\tregistry := al.GetRegistry()\n\tregistry.ForEachTool(\"send_file\", func(t tools.Tool) {\n\t\tif sf, ok := t.(*tools.SendFileTool); ok {\n\t\t\tsf.SetMediaStore(s)\n\t\t}\n\t})\n}\n\n// SetTranscriber injects a voice transcriber for agent-level audio transcription.\nfunc (al *AgentLoop) SetTranscriber(t voice.Transcriber) {\n\tal.transcriber = t\n}\n\n// SetReloadFunc sets the callback function for triggering config reload.\nfunc (al *AgentLoop) SetReloadFunc(fn func() error) {\n\tal.reloadFunc = fn\n}\n\nvar audioAnnotationRe = regexp.MustCompile(`\\[(voice|audio)(?::[^\\]]*)?\\]`)\n\n// transcribeAudioInMessage resolves audio media refs, transcribes them, and\n// replaces audio annotations in msg.Content with the transcribed text.\n// Returns the (possibly modified) message and true if audio was transcribed.\nfunc (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {\n\tif al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {\n\t\treturn msg, false\n\t}\n\n\t// Transcribe each audio media ref in order.\n\tvar transcriptions []string\n\tfor _, ref := range msg.Media {\n\t\tpath, meta, err := al.mediaStore.ResolveWithMeta(ref)\n\t\tif err != nil {\n\t\t\tlogger.WarnCF(\"voice\", \"Failed to resolve media ref\", map[string]any{\"ref\": ref, \"error\": err})\n\t\t\tcontinue\n\t\t}\n\t\tif !utils.IsAudioFile(meta.Filename, meta.ContentType) {\n\t\t\tcontinue\n\t\t}\n\t\tresult, err := al.transcriber.Transcribe(ctx, path)\n\t\tif err != nil {\n\t\t\tlogger.WarnCF(\"voice\", \"Transcription failed\", map[string]any{\"ref\": ref, \"error\": err})\n\t\t\ttranscriptions = append(transcriptions, \"\")\n\t\t\tcontinue\n\t\t}\n\t\ttranscriptions = append(transcriptions, result.Text)\n\t}\n\n\tif len(transcriptions) == 0 {\n\t\treturn msg, false\n\t}\n\n\tal.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)\n\n\t// Replace audio annotations sequentially with transcriptions.\n\tidx := 0\n\tnewContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {\n\t\tif idx >= len(transcriptions) {\n\t\t\treturn match\n\t\t}\n\t\ttext := transcriptions[idx]\n\t\tidx++\n\t\treturn \"[voice: \" + text + \"]\"\n\t})\n\n\t// Append any remaining transcriptions not matched by an annotation.\n\tfor ; idx < len(transcriptions); idx++ {\n\t\tnewContent += \"\\n[voice: \" + transcriptions[idx] + \"]\"\n\t}\n\n\tmsg.Content = newContent\n\treturn msg, true\n}\n\n// sendTranscriptionFeedback sends feedback to the user with the result of\n// audio transcription if the option is enabled. It uses Manager.SendMessage\n// which executes synchronously (rate limiting, splitting, retry) so that\n// ordering with the subsequent placeholder is guaranteed.\nfunc (al *AgentLoop) sendTranscriptionFeedback(\n\tctx context.Context,\n\tchannel, chatID, messageID string,\n\tvalidTexts []string,\n) {\n\tif !al.cfg.Voice.EchoTranscription {\n\t\treturn\n\t}\n\tif al.channelManager == nil {\n\t\treturn\n\t}\n\n\tvar nonEmpty []string\n\tfor _, t := range validTexts {\n\t\tif t != \"\" {\n\t\t\tnonEmpty = append(nonEmpty, t)\n\t\t}\n\t}\n\n\tvar feedbackMsg string\n\tif len(nonEmpty) > 0 {\n\t\tfeedbackMsg = \"Transcript: \" + strings.Join(nonEmpty, \"\\n\")\n\t} else {\n\t\tfeedbackMsg = \"No voice detected in the audio\"\n\t}\n\n\terr := al.channelManager.SendMessage(ctx, bus.OutboundMessage{\n\t\tChannel:          channel,\n\t\tChatID:           chatID,\n\t\tContent:          feedbackMsg,\n\t\tReplyToMessageID: messageID,\n\t})\n\tif err != nil {\n\t\tlogger.WarnCF(\"voice\", \"Failed to send transcription feedback\", map[string]any{\"error\": err.Error()})\n\t}\n}\n\n// inferMediaType determines the media type (\"image\", \"audio\", \"video\", \"file\")\n// from a filename and MIME content type.\nfunc inferMediaType(filename, contentType string) string {\n\tct := strings.ToLower(contentType)\n\tfn := strings.ToLower(filename)\n\n\tif strings.HasPrefix(ct, \"image/\") {\n\t\treturn \"image\"\n\t}\n\tif strings.HasPrefix(ct, \"audio/\") || ct == \"application/ogg\" {\n\t\treturn \"audio\"\n\t}\n\tif strings.HasPrefix(ct, \"video/\") {\n\t\treturn \"video\"\n\t}\n\n\t// Fallback: infer from extension\n\text := filepath.Ext(fn)\n\tswitch ext {\n\tcase \".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\", \".svg\":\n\t\treturn \"image\"\n\tcase \".mp3\", \".wav\", \".ogg\", \".m4a\", \".flac\", \".aac\", \".wma\", \".opus\":\n\t\treturn \"audio\"\n\tcase \".mp4\", \".avi\", \".mov\", \".webm\", \".mkv\":\n\t\treturn \"video\"\n\t}\n\n\treturn \"file\"\n}\n\n// RecordLastChannel records the last active channel for this workspace.\n// This uses the atomic state save mechanism to prevent data loss on crash.\nfunc (al *AgentLoop) RecordLastChannel(channel string) error {\n\tif al.state == nil {\n\t\treturn nil\n\t}\n\treturn al.state.SetLastChannel(channel)\n}\n\n// RecordLastChatID records the last active chat ID for this workspace.\n// This uses the atomic state save mechanism to prevent data loss on crash.\nfunc (al *AgentLoop) RecordLastChatID(chatID string) error {\n\tif al.state == nil {\n\t\treturn nil\n\t}\n\treturn al.state.SetLastChatID(chatID)\n}\n\nfunc (al *AgentLoop) ProcessDirect(\n\tctx context.Context,\n\tcontent, sessionKey string,\n) (string, error) {\n\treturn al.ProcessDirectWithChannel(ctx, content, sessionKey, \"cli\", \"direct\")\n}\n\nfunc (al *AgentLoop) ProcessDirectWithChannel(\n\tctx context.Context,\n\tcontent, sessionKey, channel, chatID string,\n) (string, error) {\n\tif err := al.ensureMCPInitialized(ctx); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmsg := bus.InboundMessage{\n\t\tChannel:    channel,\n\t\tSenderID:   \"cron\",\n\t\tChatID:     chatID,\n\t\tContent:    content,\n\t\tSessionKey: sessionKey,\n\t}\n\n\treturn al.processMessage(ctx, msg)\n}\n\n// ProcessHeartbeat processes a heartbeat request without session history.\n// Each heartbeat is independent and doesn't accumulate context.\nfunc (al *AgentLoop) ProcessHeartbeat(\n\tctx context.Context,\n\tcontent, channel, chatID string,\n) (string, error) {\n\tagent := al.GetRegistry().GetDefaultAgent()\n\tif agent == nil {\n\t\treturn \"\", fmt.Errorf(\"no default agent for heartbeat\")\n\t}\n\treturn al.runAgentLoop(ctx, agent, processOptions{\n\t\tSessionKey:      \"heartbeat\",\n\t\tChannel:         channel,\n\t\tChatID:          chatID,\n\t\tUserMessage:     content,\n\t\tDefaultResponse: defaultResponse,\n\t\tEnableSummary:   false,\n\t\tSendResponse:    false,\n\t\tNoHistory:       true, // Don't load session history for heartbeat\n\t})\n}\n\nfunc (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {\n\t// Add message preview to log (show full content for error messages)\n\tvar logContent string\n\tif strings.Contains(msg.Content, \"Error:\") || strings.Contains(msg.Content, \"error\") {\n\t\tlogContent = msg.Content // Full content for errors\n\t} else {\n\t\tlogContent = utils.Truncate(msg.Content, 80)\n\t}\n\tlogger.InfoCF(\n\t\t\"agent\",\n\t\tfmt.Sprintf(\"Processing message from %s:%s: %s\", msg.Channel, msg.SenderID, logContent),\n\t\tmap[string]any{\n\t\t\t\"channel\":     msg.Channel,\n\t\t\t\"chat_id\":     msg.ChatID,\n\t\t\t\"sender_id\":   msg.SenderID,\n\t\t\t\"session_key\": msg.SessionKey,\n\t\t},\n\t)\n\n\tvar hadAudio bool\n\tmsg, hadAudio = al.transcribeAudioInMessage(ctx, msg)\n\n\t// For audio messages the placeholder was deferred by the channel.\n\t// Now that transcription (and optional feedback) is done, send it.\n\tif hadAudio && al.channelManager != nil {\n\t\tal.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)\n\t}\n\n\t// Route system messages to processSystemMessage\n\tif msg.Channel == \"system\" {\n\t\treturn al.processSystemMessage(ctx, msg)\n\t}\n\n\troute, agent, routeErr := al.resolveMessageRoute(msg)\n\tif routeErr != nil {\n\t\treturn \"\", routeErr\n\t}\n\n\t// Reset message-tool state for this round so we don't skip publishing due to a previous round.\n\tif tool, ok := agent.Tools.Get(\"message\"); ok {\n\t\tif resetter, ok := tool.(interface{ ResetSentInRound() }); ok {\n\t\t\tresetter.ResetSentInRound()\n\t\t}\n\t}\n\n\t// Resolve session key from route, while preserving explicit agent-scoped keys.\n\tscopeKey := resolveScopeKey(route, msg.SessionKey)\n\tsessionKey := scopeKey\n\n\tlogger.InfoCF(\"agent\", \"Routed message\",\n\t\tmap[string]any{\n\t\t\t\"agent_id\":      agent.ID,\n\t\t\t\"scope_key\":     scopeKey,\n\t\t\t\"session_key\":   sessionKey,\n\t\t\t\"matched_by\":    route.MatchedBy,\n\t\t\t\"route_agent\":   route.AgentID,\n\t\t\t\"route_channel\": route.Channel,\n\t\t})\n\n\topts := processOptions{\n\t\tSessionKey:        sessionKey,\n\t\tChannel:           msg.Channel,\n\t\tChatID:            msg.ChatID,\n\t\tSenderID:          msg.SenderID,\n\t\tSenderDisplayName: msg.Sender.DisplayName,\n\t\tUserMessage:       msg.Content,\n\t\tMedia:             msg.Media,\n\t\tDefaultResponse:   defaultResponse,\n\t\tEnableSummary:     true,\n\t\tSendResponse:      false,\n\t}\n\n\t// context-dependent commands check their own Runtime fields and report\n\t// \"unavailable\" when the required capability is nil.\n\tif response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {\n\t\treturn response, nil\n\t}\n\n\treturn al.runAgentLoop(ctx, agent, opts)\n}\n\nfunc (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {\n\tregistry := al.GetRegistry()\n\troute := registry.ResolveRoute(routing.RouteInput{\n\t\tChannel:    msg.Channel,\n\t\tAccountID:  inboundMetadata(msg, metadataKeyAccountID),\n\t\tPeer:       extractPeer(msg),\n\t\tParentPeer: extractParentPeer(msg),\n\t\tGuildID:    inboundMetadata(msg, metadataKeyGuildID),\n\t\tTeamID:     inboundMetadata(msg, metadataKeyTeamID),\n\t})\n\n\tagent, ok := registry.GetAgent(route.AgentID)\n\tif !ok {\n\t\tagent = registry.GetDefaultAgent()\n\t}\n\tif agent == nil {\n\t\treturn routing.ResolvedRoute{}, nil, fmt.Errorf(\"no agent available for route (agent_id=%s)\", route.AgentID)\n\t}\n\n\treturn route, agent, nil\n}\n\nfunc resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {\n\tif msgSessionKey != \"\" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) {\n\t\treturn msgSessionKey\n\t}\n\treturn route.SessionKey\n}\n\nfunc (al *AgentLoop) processSystemMessage(\n\tctx context.Context,\n\tmsg bus.InboundMessage,\n) (string, error) {\n\tif msg.Channel != \"system\" {\n\t\treturn \"\", fmt.Errorf(\n\t\t\t\"processSystemMessage called with non-system message channel: %s\",\n\t\t\tmsg.Channel,\n\t\t)\n\t}\n\n\tlogger.InfoCF(\"agent\", \"Processing system message\",\n\t\tmap[string]any{\n\t\t\t\"sender_id\": msg.SenderID,\n\t\t\t\"chat_id\":   msg.ChatID,\n\t\t})\n\n\t// Parse origin channel from chat_id (format: \"channel:chat_id\")\n\tvar originChannel, originChatID string\n\tif idx := strings.Index(msg.ChatID, \":\"); idx > 0 {\n\t\toriginChannel = msg.ChatID[:idx]\n\t\toriginChatID = msg.ChatID[idx+1:]\n\t} else {\n\t\toriginChannel = \"cli\"\n\t\toriginChatID = msg.ChatID\n\t}\n\n\t// Extract subagent result from message content\n\t// Format: \"Task 'label' completed.\\n\\nResult:\\n<actual content>\"\n\tcontent := msg.Content\n\tif idx := strings.Index(content, \"Result:\\n\"); idx >= 0 {\n\t\tcontent = content[idx+8:] // Extract just the result part\n\t}\n\n\t// Skip internal channels - only log, don't send to user\n\tif constants.IsInternalChannel(originChannel) {\n\t\tlogger.InfoCF(\"agent\", \"Subagent completed (internal channel)\",\n\t\t\tmap[string]any{\n\t\t\t\t\"sender_id\":   msg.SenderID,\n\t\t\t\t\"content_len\": len(content),\n\t\t\t\t\"channel\":     originChannel,\n\t\t\t})\n\t\treturn \"\", nil\n\t}\n\n\t// Use default agent for system messages\n\tagent := al.GetRegistry().GetDefaultAgent()\n\tif agent == nil {\n\t\treturn \"\", fmt.Errorf(\"no default agent for system message\")\n\t}\n\n\t// Use the origin session for context\n\tsessionKey := routing.BuildAgentMainSessionKey(agent.ID)\n\n\treturn al.runAgentLoop(ctx, agent, processOptions{\n\t\tSessionKey:      sessionKey,\n\t\tChannel:         originChannel,\n\t\tChatID:          originChatID,\n\t\tUserMessage:     fmt.Sprintf(\"[System: %s] %s\", msg.SenderID, msg.Content),\n\t\tDefaultResponse: \"Background task completed.\",\n\t\tEnableSummary:   false,\n\t\tSendResponse:    true,\n\t})\n}\n\n// runAgentLoop is the core message processing logic.\nfunc (al *AgentLoop) runAgentLoop(\n\tctx context.Context,\n\tagent *AgentInstance,\n\topts processOptions,\n) (string, error) {\n\t// 0. Record last channel for heartbeat notifications (skip internal channels and cli)\n\tif opts.Channel != \"\" && opts.ChatID != \"\" {\n\t\tif !constants.IsInternalChannel(opts.Channel) {\n\t\t\tchannelKey := fmt.Sprintf(\"%s:%s\", opts.Channel, opts.ChatID)\n\t\t\tif err := al.RecordLastChannel(channelKey); err != nil {\n\t\t\t\tlogger.WarnCF(\n\t\t\t\t\t\"agent\",\n\t\t\t\t\t\"Failed to record last channel\",\n\t\t\t\t\tmap[string]any{\"error\": err.Error()},\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 1. Build messages (skip history for heartbeat)\n\tvar history []providers.Message\n\tvar summary string\n\tif !opts.NoHistory {\n\t\thistory = agent.Sessions.GetHistory(opts.SessionKey)\n\t\tsummary = agent.Sessions.GetSummary(opts.SessionKey)\n\t}\n\tmessages := agent.ContextBuilder.BuildMessages(\n\t\thistory,\n\t\tsummary,\n\t\topts.UserMessage,\n\t\topts.Media,\n\t\topts.Channel,\n\t\topts.ChatID,\n\t\topts.SenderID,\n\t\topts.SenderDisplayName,\n\t)\n\n\t// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content\n\tcfg := al.GetConfig()\n\tmaxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()\n\tmessages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)\n\n\t// 2. Save user message to session\n\tagent.Sessions.AddMessage(opts.SessionKey, \"user\", opts.UserMessage)\n\n\t// 3. Run LLM iteration loop\n\tfinalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// If last tool had ForUser content and we already sent it, we might not need to send final response\n\t// This is controlled by the tool's Silent flag and ForUser content\n\n\t// 4. Handle empty response\n\tif finalContent == \"\" {\n\t\tfinalContent = opts.DefaultResponse\n\t}\n\n\t// 5. Save final assistant message to session\n\tagent.Sessions.AddMessage(opts.SessionKey, \"assistant\", finalContent)\n\tagent.Sessions.Save(opts.SessionKey)\n\n\t// 6. Optional: summarization\n\tif opts.EnableSummary {\n\t\tal.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)\n\t}\n\n\t// 7. Optional: send response via bus\n\tif opts.SendResponse {\n\t\tal.bus.PublishOutbound(ctx, bus.OutboundMessage{\n\t\t\tChannel: opts.Channel,\n\t\t\tChatID:  opts.ChatID,\n\t\t\tContent: finalContent,\n\t\t})\n\t}\n\n\t// 8. Log response\n\tresponsePreview := utils.Truncate(finalContent, 120)\n\tlogger.InfoCF(\"agent\", fmt.Sprintf(\"Response: %s\", responsePreview),\n\t\tmap[string]any{\n\t\t\t\"agent_id\":     agent.ID,\n\t\t\t\"session_key\":  opts.SessionKey,\n\t\t\t\"iterations\":   iteration,\n\t\t\t\"final_length\": len(finalContent),\n\t\t})\n\n\treturn finalContent, nil\n}\n\nfunc (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {\n\tif al.channelManager == nil {\n\t\treturn \"\"\n\t}\n\tif ch, ok := al.channelManager.GetChannel(channelName); ok {\n\t\treturn ch.ReasoningChannelID()\n\t}\n\treturn \"\"\n}\n\nfunc (al *AgentLoop) handleReasoning(\n\tctx context.Context,\n\treasoningContent, channelName, channelID string,\n) {\n\tif reasoningContent == \"\" || channelName == \"\" || channelID == \"\" {\n\t\treturn\n\t}\n\n\t// Check context cancellation before attempting to publish,\n\t// since PublishOutbound's select may race between send and ctx.Done().\n\tif ctx.Err() != nil {\n\t\treturn\n\t}\n\n\t// Use a short timeout so the goroutine does not block indefinitely when\n\t// the outbound bus is full.  Reasoning output is best-effort; dropping it\n\t// is acceptable to avoid goroutine accumulation.\n\tpubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer pubCancel()\n\n\tif err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\tChannel: channelName,\n\t\tChatID:  channelID,\n\t\tContent: reasoningContent,\n\t}); err != nil {\n\t\t// Treat context.DeadlineExceeded / context.Canceled as expected\n\t\t// (bus full under load, or parent canceled).  Check the error\n\t\t// itself rather than ctx.Err(), because pubCtx may time out\n\t\t// (5 s) while the parent ctx is still active.\n\t\t// Also treat ErrBusClosed as expected — it occurs during normal\n\t\t// shutdown when the bus is closed before all goroutines finish.\n\t\tif errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||\n\t\t\terrors.Is(err, bus.ErrBusClosed) {\n\t\t\tlogger.DebugCF(\"agent\", \"Reasoning publish skipped (timeout/cancel)\", map[string]any{\n\t\t\t\t\"channel\": channelName,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t} else {\n\t\t\tlogger.WarnCF(\"agent\", \"Failed to publish reasoning (best-effort)\", map[string]any{\n\t\t\t\t\"channel\": channelName,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t}\n\t}\n}\n\n// runLLMIteration executes the LLM call loop with tool handling.\nfunc (al *AgentLoop) runLLMIteration(\n\tctx context.Context,\n\tagent *AgentInstance,\n\tmessages []providers.Message,\n\topts processOptions,\n) (string, int, error) {\n\titeration := 0\n\tvar finalContent string\n\n\t// Determine effective model tier for this conversation turn.\n\t// selectCandidates evaluates routing once and the decision is sticky for\n\t// all tool-follow-up iterations within the same turn so that a multi-step\n\t// tool chain doesn't switch models mid-way through.\n\tactiveCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages)\n\n\tfor iteration < agent.MaxIterations {\n\t\titeration++\n\n\t\tlogger.DebugCF(\"agent\", \"LLM iteration\",\n\t\t\tmap[string]any{\n\t\t\t\t\"agent_id\":  agent.ID,\n\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\"max\":       agent.MaxIterations,\n\t\t\t})\n\n\t\t// Build tool definitions\n\t\tproviderToolDefs := agent.Tools.ToProviderDefs()\n\n\t\t// Determine whether the provider's native web search should replace\n\t\t// the client-side web_search tool for this request. Only enable when web\n\t\t// search is actually enabled and registered (so users who disabled web\n\t\t// access do not get provider-side search or billing).\n\t\t_, hasWebSearch := agent.Tools.Get(\"web_search\")\n\t\tuseNativeSearch := al.cfg.Tools.Web.PreferNative &&\n\t\t\tisNativeSearchProvider(agent.Provider) &&\n\t\t\thasWebSearch\n\n\t\tif useNativeSearch {\n\t\t\tproviderToolDefs = filterClientWebSearch(providerToolDefs)\n\t\t}\n\n\t\t// Log LLM request details\n\t\tlogger.DebugCF(\"agent\", \"LLM request\",\n\t\t\tmap[string]any{\n\t\t\t\t\"agent_id\":          agent.ID,\n\t\t\t\t\"iteration\":         iteration,\n\t\t\t\t\"model\":             activeModel,\n\t\t\t\t\"messages_count\":    len(messages),\n\t\t\t\t\"tools_count\":       len(providerToolDefs),\n\t\t\t\t\"native_search\":     useNativeSearch,\n\t\t\t\t\"max_tokens\":        agent.MaxTokens,\n\t\t\t\t\"temperature\":       agent.Temperature,\n\t\t\t\t\"system_prompt_len\": len(messages[0].Content),\n\t\t\t})\n\n\t\t// Log full messages (detailed)\n\t\tlogger.DebugCF(\"agent\", \"Full LLM request\",\n\t\t\tmap[string]any{\n\t\t\t\t\"iteration\":     iteration,\n\t\t\t\t\"messages_json\": formatMessagesForLog(messages),\n\t\t\t\t\"tools_json\":    formatToolsForLog(providerToolDefs),\n\t\t\t})\n\n\t\t// Call LLM with fallback chain if multiple candidates are configured.\n\t\tvar response *providers.LLMResponse\n\t\tvar err error\n\n\t\tllmOpts := map[string]any{\n\t\t\t\"max_tokens\":       agent.MaxTokens,\n\t\t\t\"temperature\":      agent.Temperature,\n\t\t\t\"prompt_cache_key\": agent.ID,\n\t\t}\n\t\tif useNativeSearch {\n\t\t\tllmOpts[\"native_search\"] = true\n\t\t}\n\t\t// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,\n\t\t// so checking != ThinkingOff is sufficient.\n\t\tif agent.ThinkingLevel != ThinkingOff {\n\t\t\tif tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {\n\t\t\t\tllmOpts[\"thinking_level\"] = string(agent.ThinkingLevel)\n\t\t\t} else {\n\t\t\t\tlogger.WarnCF(\"agent\", \"thinking_level is set but current provider does not support it, ignoring\",\n\t\t\t\t\tmap[string]any{\"agent_id\": agent.ID, \"thinking_level\": string(agent.ThinkingLevel)})\n\t\t\t}\n\t\t}\n\n\t\tcallLLM := func() (*providers.LLMResponse, error) {\n\t\t\tal.activeRequests.Add(1)\n\t\t\tdefer al.activeRequests.Done()\n\n\t\t\tif len(activeCandidates) > 1 && al.fallback != nil {\n\t\t\t\tfbResult, fbErr := al.fallback.Execute(\n\t\t\t\t\tctx,\n\t\t\t\t\tactiveCandidates,\n\t\t\t\t\tfunc(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {\n\t\t\t\t\t\treturn agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\tif fbErr != nil {\n\t\t\t\t\treturn nil, fbErr\n\t\t\t\t}\n\t\t\t\tif fbResult.Provider != \"\" && len(fbResult.Attempts) > 0 {\n\t\t\t\t\tlogger.InfoCF(\n\t\t\t\t\t\t\"agent\",\n\t\t\t\t\t\tfmt.Sprintf(\"Fallback: succeeded with %s/%s after %d attempts\",\n\t\t\t\t\t\t\tfbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),\n\t\t\t\t\t\tmap[string]any{\"agent_id\": agent.ID, \"iteration\": iteration},\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\treturn fbResult.Response, nil\n\t\t\t}\n\t\t\treturn agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts)\n\t\t}\n\n\t\t// Retry loop for context/token errors\n\t\tmaxRetries := 2\n\t\tfor retry := 0; retry <= maxRetries; retry++ {\n\t\t\tresponse, err = callLLM()\n\t\t\tif err == nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\terrMsg := strings.ToLower(err.Error())\n\n\t\t\t// Check if this is a network/HTTP timeout — not a context window error.\n\t\t\tisTimeoutError := errors.Is(err, context.DeadlineExceeded) ||\n\t\t\t\tstrings.Contains(errMsg, \"deadline exceeded\") ||\n\t\t\t\tstrings.Contains(errMsg, \"client.timeout\") ||\n\t\t\t\tstrings.Contains(errMsg, \"timed out\") ||\n\t\t\t\tstrings.Contains(errMsg, \"timeout exceeded\")\n\n\t\t\t// Detect real context window / token limit errors, excluding network timeouts.\n\t\t\tisContextError := !isTimeoutError && (strings.Contains(errMsg, \"context_length_exceeded\") ||\n\t\t\t\tstrings.Contains(errMsg, \"context window\") ||\n\t\t\t\tstrings.Contains(errMsg, \"maximum context length\") ||\n\t\t\t\tstrings.Contains(errMsg, \"token limit\") ||\n\t\t\t\tstrings.Contains(errMsg, \"too many tokens\") ||\n\t\t\t\tstrings.Contains(errMsg, \"max_tokens\") ||\n\t\t\t\tstrings.Contains(errMsg, \"invalidparameter\") ||\n\t\t\t\tstrings.Contains(errMsg, \"prompt is too long\") ||\n\t\t\t\tstrings.Contains(errMsg, \"request too large\"))\n\n\t\t\tif isTimeoutError && retry < maxRetries {\n\t\t\t\tbackoff := time.Duration(retry+1) * 5 * time.Second\n\t\t\t\tlogger.WarnCF(\"agent\", \"Timeout error, retrying after backoff\", map[string]any{\n\t\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t\t\"retry\":   retry,\n\t\t\t\t\t\"backoff\": backoff.String(),\n\t\t\t\t})\n\t\t\t\ttime.Sleep(backoff)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif isContextError && retry < maxRetries {\n\t\t\t\tlogger.WarnCF(\n\t\t\t\t\t\"agent\",\n\t\t\t\t\t\"Context window error detected, attempting compression\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t\t\"retry\": retry,\n\t\t\t\t\t},\n\t\t\t\t)\n\n\t\t\t\tif retry == 0 && !constants.IsInternalChannel(opts.Channel) {\n\t\t\t\t\tal.bus.PublishOutbound(ctx, bus.OutboundMessage{\n\t\t\t\t\t\tChannel: opts.Channel,\n\t\t\t\t\t\tChatID:  opts.ChatID,\n\t\t\t\t\t\tContent: \"Context window exceeded. Compressing history and retrying...\",\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tal.forceCompression(agent, opts.SessionKey)\n\t\t\t\tnewHistory := agent.Sessions.GetHistory(opts.SessionKey)\n\t\t\t\tnewSummary := agent.Sessions.GetSummary(opts.SessionKey)\n\t\t\t\tmessages = agent.ContextBuilder.BuildMessages(\n\t\t\t\t\tnewHistory, newSummary, \"\",\n\t\t\t\t\tnil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"agent\", \"LLM call failed\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"agent_id\":  agent.ID,\n\t\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\t\"model\":     activeModel,\n\t\t\t\t\t\"error\":     err.Error(),\n\t\t\t\t})\n\t\t\treturn \"\", iteration, fmt.Errorf(\"LLM call failed after retries: %w\", err)\n\t\t}\n\n\t\tgo al.handleReasoning(\n\t\t\tctx,\n\t\t\tresponse.Reasoning,\n\t\t\topts.Channel,\n\t\t\tal.targetReasoningChannelID(opts.Channel),\n\t\t)\n\n\t\tlogger.DebugCF(\"agent\", \"LLM response\",\n\t\t\tmap[string]any{\n\t\t\t\t\"agent_id\":       agent.ID,\n\t\t\t\t\"iteration\":      iteration,\n\t\t\t\t\"content_chars\":  len(response.Content),\n\t\t\t\t\"tool_calls\":     len(response.ToolCalls),\n\t\t\t\t\"reasoning\":      response.Reasoning,\n\t\t\t\t\"target_channel\": al.targetReasoningChannelID(opts.Channel),\n\t\t\t\t\"channel\":        opts.Channel,\n\t\t\t})\n\t\t// Check if no tool calls - then check reasoning content if any\n\t\tif len(response.ToolCalls) == 0 {\n\t\t\tfinalContent = response.Content\n\t\t\tif finalContent == \"\" && response.ReasoningContent != \"\" {\n\t\t\t\tfinalContent = response.ReasoningContent\n\t\t\t}\n\t\t\tlogger.InfoCF(\"agent\", \"LLM response without tool calls (direct answer)\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"agent_id\":      agent.ID,\n\t\t\t\t\t\"iteration\":     iteration,\n\t\t\t\t\t\"content_chars\": len(finalContent),\n\t\t\t\t})\n\t\t\tbreak\n\t\t}\n\n\t\tnormalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))\n\t\tfor _, tc := range response.ToolCalls {\n\t\t\tnormalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))\n\t\t}\n\n\t\t// Log tool calls\n\t\ttoolNames := make([]string, 0, len(normalizedToolCalls))\n\t\tfor _, tc := range normalizedToolCalls {\n\t\t\ttoolNames = append(toolNames, tc.Name)\n\t\t}\n\t\tlogger.InfoCF(\"agent\", \"LLM requested tool calls\",\n\t\t\tmap[string]any{\n\t\t\t\t\"agent_id\":  agent.ID,\n\t\t\t\t\"tools\":     toolNames,\n\t\t\t\t\"count\":     len(normalizedToolCalls),\n\t\t\t\t\"iteration\": iteration,\n\t\t\t})\n\n\t\t// Build assistant message with tool calls\n\t\tassistantMsg := providers.Message{\n\t\t\tRole:             \"assistant\",\n\t\t\tContent:          response.Content,\n\t\t\tReasoningContent: response.ReasoningContent,\n\t\t}\n\t\tfor _, tc := range normalizedToolCalls {\n\t\t\targumentsJSON, _ := json.Marshal(tc.Arguments)\n\t\t\t// Copy ExtraContent to ensure thought_signature is persisted for Gemini 3\n\t\t\textraContent := tc.ExtraContent\n\t\t\tthoughtSignature := \"\"\n\t\t\tif tc.Function != nil {\n\t\t\t\tthoughtSignature = tc.Function.ThoughtSignature\n\t\t\t}\n\n\t\t\tassistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{\n\t\t\t\tID:   tc.ID,\n\t\t\t\tType: \"function\",\n\t\t\t\tName: tc.Name,\n\t\t\t\tFunction: &providers.FunctionCall{\n\t\t\t\t\tName:             tc.Name,\n\t\t\t\t\tArguments:        string(argumentsJSON),\n\t\t\t\t\tThoughtSignature: thoughtSignature,\n\t\t\t\t},\n\t\t\t\tExtraContent:     extraContent,\n\t\t\t\tThoughtSignature: thoughtSignature,\n\t\t\t})\n\t\t}\n\t\tmessages = append(messages, assistantMsg)\n\n\t\t// Save assistant message with tool calls to session\n\t\tagent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)\n\n\t\t// Execute tool calls in parallel\n\t\ttype indexedAgentResult struct {\n\t\t\tresult *tools.ToolResult\n\t\t\ttc     providers.ToolCall\n\t\t}\n\n\t\tagentResults := make([]indexedAgentResult, len(normalizedToolCalls))\n\t\tvar wg sync.WaitGroup\n\n\t\tfor i, tc := range normalizedToolCalls {\n\t\t\tagentResults[i].tc = tc\n\n\t\t\twg.Add(1)\n\t\t\tgo func(idx int, tc providers.ToolCall) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\targsJSON, _ := json.Marshal(tc.Arguments)\n\t\t\t\targsPreview := utils.Truncate(string(argsJSON), 200)\n\t\t\t\tlogger.InfoCF(\"agent\", fmt.Sprintf(\"Tool call: %s(%s)\", tc.Name, argsPreview),\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"agent_id\":  agent.ID,\n\t\t\t\t\t\t\"tool\":      tc.Name,\n\t\t\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\t})\n\n\t\t\t\t// Send tool feedback to chat channel if enabled\n\t\t\t\tif al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && opts.Channel != \"\" {\n\t\t\t\t\tfeedbackPreview := utils.Truncate(\n\t\t\t\t\t\tstring(argsJSON),\n\t\t\t\t\t\tal.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),\n\t\t\t\t\t)\n\t\t\t\t\tfeedbackMsg := fmt.Sprintf(\"\\U0001f527 `%s`\\n```\\n%s\\n```\", tc.Name, feedbackPreview)\n\t\t\t\t\tfbCtx, fbCancel := context.WithTimeout(ctx, 3*time.Second)\n\t\t\t\t\t_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{\n\t\t\t\t\t\tChannel: opts.Channel,\n\t\t\t\t\t\tChatID:  opts.ChatID,\n\t\t\t\t\t\tContent: feedbackMsg,\n\t\t\t\t\t})\n\t\t\t\t\tfbCancel()\n\t\t\t\t}\n\n\t\t\t\t// Create async callback for tools that implement AsyncExecutor.\n\t\t\t\t// When the background work completes, this publishes the result\n\t\t\t\t// as an inbound system message so processSystemMessage routes it\n\t\t\t\t// back to the user via the normal agent loop.\n\t\t\t\tasyncCallback := func(_ context.Context, result *tools.ToolResult) {\n\t\t\t\t\t// Send ForUser content directly to the user (immediate feedback),\n\t\t\t\t\t// mirroring the synchronous tool execution path.\n\t\t\t\t\tif !result.Silent && result.ForUser != \"\" {\n\t\t\t\t\t\toutCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\t\t\t\tdefer outCancel()\n\t\t\t\t\t\t_ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{\n\t\t\t\t\t\t\tChannel: opts.Channel,\n\t\t\t\t\t\t\tChatID:  opts.ChatID,\n\t\t\t\t\t\t\tContent: result.ForUser,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\n\t\t\t\t\t// Determine content for the agent loop (ForLLM or error).\n\t\t\t\t\tcontent := result.ForLLM\n\t\t\t\t\tif content == \"\" && result.Err != nil {\n\t\t\t\t\t\tcontent = result.Err.Error()\n\t\t\t\t\t}\n\t\t\t\t\tif content == \"\" {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tlogger.InfoCF(\"agent\", \"Async tool completed, publishing result\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"tool\":        tc.Name,\n\t\t\t\t\t\t\t\"content_len\": len(content),\n\t\t\t\t\t\t\t\"channel\":     opts.Channel,\n\t\t\t\t\t\t})\n\n\t\t\t\t\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\t\t\tdefer pubCancel()\n\t\t\t\t\t_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{\n\t\t\t\t\t\tChannel:  \"system\",\n\t\t\t\t\t\tSenderID: fmt.Sprintf(\"async:%s\", tc.Name),\n\t\t\t\t\t\tChatID:   fmt.Sprintf(\"%s:%s\", opts.Channel, opts.ChatID),\n\t\t\t\t\t\tContent:  content,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\ttoolResult := agent.Tools.ExecuteWithContext(\n\t\t\t\t\tctx,\n\t\t\t\t\ttc.Name,\n\t\t\t\t\ttc.Arguments,\n\t\t\t\t\topts.Channel,\n\t\t\t\t\topts.ChatID,\n\t\t\t\t\tasyncCallback,\n\t\t\t\t)\n\t\t\t\tagentResults[idx].result = toolResult\n\t\t\t}(i, tc)\n\t\t}\n\t\twg.Wait()\n\n\t\t// Process results in original order (send to user, save to session)\n\t\tfor _, r := range agentResults {\n\t\t\t// Send ForUser content to user immediately if not Silent\n\t\t\tif !r.result.Silent && r.result.ForUser != \"\" && opts.SendResponse {\n\t\t\t\tal.bus.PublishOutbound(ctx, bus.OutboundMessage{\n\t\t\t\t\tChannel: opts.Channel,\n\t\t\t\t\tChatID:  opts.ChatID,\n\t\t\t\t\tContent: r.result.ForUser,\n\t\t\t\t})\n\t\t\t\tlogger.DebugCF(\"agent\", \"Sent tool result to user\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"tool\":        r.tc.Name,\n\t\t\t\t\t\t\"content_len\": len(r.result.ForUser),\n\t\t\t\t\t})\n\t\t\t}\n\n\t\t\t// If tool returned media refs, publish them as outbound media\n\t\t\tif len(r.result.Media) > 0 {\n\t\t\t\tparts := make([]bus.MediaPart, 0, len(r.result.Media))\n\t\t\t\tfor _, ref := range r.result.Media {\n\t\t\t\t\tpart := bus.MediaPart{Ref: ref}\n\t\t\t\t\tif al.mediaStore != nil {\n\t\t\t\t\t\tif _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {\n\t\t\t\t\t\t\tpart.Filename = meta.Filename\n\t\t\t\t\t\t\tpart.ContentType = meta.ContentType\n\t\t\t\t\t\t\tpart.Type = inferMediaType(meta.Filename, meta.ContentType)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tparts = append(parts, part)\n\t\t\t\t}\n\t\t\t\tal.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{\n\t\t\t\t\tChannel: opts.Channel,\n\t\t\t\t\tChatID:  opts.ChatID,\n\t\t\t\t\tParts:   parts,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Determine content for LLM based on tool result\n\t\t\tcontentForLLM := r.result.ForLLM\n\t\t\tif contentForLLM == \"\" && r.result.Err != nil {\n\t\t\t\tcontentForLLM = r.result.Err.Error()\n\t\t\t}\n\n\t\t\ttoolResultMsg := providers.Message{\n\t\t\t\tRole:       \"tool\",\n\t\t\t\tContent:    contentForLLM,\n\t\t\t\tToolCallID: r.tc.ID,\n\t\t\t}\n\t\t\tmessages = append(messages, toolResultMsg)\n\n\t\t\t// Save tool result message to session\n\t\t\tagent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)\n\t\t}\n\n\t\t// Tick down TTL of discovered tools after processing tool results.\n\t\t// Only reached when tool calls were made (the loop continues);\n\t\t// the break on no-tool-call responses skips this.\n\t\t// NOTE: This is safe because processMessage is sequential per agent.\n\t\t// If per-agent concurrency is added, TTL consistency between\n\t\t// ToProviderDefs and Get must be re-evaluated.\n\t\tagent.Tools.TickTTL()\n\t\tlogger.DebugCF(\"agent\", \"TTL tick after tool execution\", map[string]any{\n\t\t\t\"agent_id\": agent.ID, \"iteration\": iteration,\n\t\t})\n\t}\n\n\treturn finalContent, iteration, nil\n}\n\n// selectCandidates returns the model candidates and resolved model name to use\n// for a conversation turn. When model routing is configured and the incoming\n// message scores below the complexity threshold, it returns the light model\n// candidates instead of the primary ones.\n//\n// The returned (candidates, model) pair is used for all LLM calls within one\n// turn — tool follow-up iterations use the same tier as the initial call so\n// that a multi-step tool chain doesn't switch models mid-way.\nfunc (al *AgentLoop) selectCandidates(\n\tagent *AgentInstance,\n\tuserMsg string,\n\thistory []providers.Message,\n) (candidates []providers.FallbackCandidate, model string) {\n\tif agent.Router == nil || len(agent.LightCandidates) == 0 {\n\t\treturn agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)\n\t}\n\n\t_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)\n\tif !usedLight {\n\t\tlogger.DebugCF(\"agent\", \"Model routing: primary model selected\",\n\t\t\tmap[string]any{\n\t\t\t\t\"agent_id\":  agent.ID,\n\t\t\t\t\"score\":     score,\n\t\t\t\t\"threshold\": agent.Router.Threshold(),\n\t\t\t})\n\t\treturn agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)\n\t}\n\n\tlogger.InfoCF(\"agent\", \"Model routing: light model selected\",\n\t\tmap[string]any{\n\t\t\t\"agent_id\":    agent.ID,\n\t\t\t\"light_model\": agent.Router.LightModel(),\n\t\t\t\"score\":       score,\n\t\t\t\"threshold\":   agent.Router.Threshold(),\n\t\t})\n\treturn agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel())\n}\n\n// maybeSummarize triggers summarization if the session history exceeds thresholds.\nfunc (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {\n\tnewHistory := agent.Sessions.GetHistory(sessionKey)\n\ttokenEstimate := al.estimateTokens(newHistory)\n\tthreshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100\n\n\tif len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {\n\t\tsummarizeKey := agent.ID + \":\" + sessionKey\n\t\tif _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {\n\t\t\tgo func() {\n\t\t\t\tdefer al.summarizing.Delete(summarizeKey)\n\t\t\t\tlogger.Debug(\"Memory threshold reached. Optimizing conversation history...\")\n\t\t\t\tal.summarizeSession(agent, sessionKey)\n\t\t\t}()\n\t\t}\n\t}\n}\n\n// forceCompression aggressively reduces context when the limit is hit.\n// It drops the oldest 50% of messages (keeping system prompt and last user message).\nfunc (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {\n\thistory := agent.Sessions.GetHistory(sessionKey)\n\tif len(history) <= 4 {\n\t\treturn\n\t}\n\n\t// Keep system prompt (usually [0]) and the very last message (user's trigger)\n\t// We want to drop the oldest half of the *conversation*\n\t// Assuming [0] is system, [1:] is conversation\n\tconversation := history[1 : len(history)-1]\n\tif len(conversation) == 0 {\n\t\treturn\n\t}\n\n\t// Helper to find the mid-point of the conversation\n\tmid := len(conversation) / 2\n\n\t// New history structure:\n\t// 1. System Prompt (with compression note appended)\n\t// 2. Second half of conversation\n\t// 3. Last message\n\n\tdroppedCount := mid\n\tkeptConversation := conversation[mid:]\n\n\tnewHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)\n\n\t// Append compression note to the original system prompt instead of adding a new system message\n\t// This avoids having two consecutive system messages which some APIs (like Zhipu) reject\n\tcompressionNote := fmt.Sprintf(\n\t\t\"\\n\\n[System Note: Emergency compression dropped %d oldest messages due to context limit]\",\n\t\tdroppedCount,\n\t)\n\tenhancedSystemPrompt := history[0]\n\tenhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote\n\tnewHistory = append(newHistory, enhancedSystemPrompt)\n\n\tnewHistory = append(newHistory, keptConversation...)\n\tnewHistory = append(newHistory, history[len(history)-1]) // Last message\n\n\t// Update session\n\tagent.Sessions.SetHistory(sessionKey, newHistory)\n\tagent.Sessions.Save(sessionKey)\n\n\tlogger.WarnCF(\"agent\", \"Forced compression executed\", map[string]any{\n\t\t\"session_key\":  sessionKey,\n\t\t\"dropped_msgs\": droppedCount,\n\t\t\"new_count\":    len(newHistory),\n\t})\n}\n\n// GetStartupInfo returns information about loaded tools and skills for logging.\nfunc (al *AgentLoop) GetStartupInfo() map[string]any {\n\tinfo := make(map[string]any)\n\n\tregistry := al.GetRegistry()\n\tagent := registry.GetDefaultAgent()\n\tif agent == nil {\n\t\treturn info\n\t}\n\n\t// Tools info\n\ttoolsList := agent.Tools.List()\n\tinfo[\"tools\"] = map[string]any{\n\t\t\"count\": len(toolsList),\n\t\t\"names\": toolsList,\n\t}\n\n\t// Skills info\n\tinfo[\"skills\"] = agent.ContextBuilder.GetSkillsInfo()\n\n\t// Agents info\n\tinfo[\"agents\"] = map[string]any{\n\t\t\"count\": len(registry.ListAgentIDs()),\n\t\t\"ids\":   registry.ListAgentIDs(),\n\t}\n\n\treturn info\n}\n\n// formatMessagesForLog formats messages for logging\nfunc formatMessagesForLog(messages []providers.Message) string {\n\tif len(messages) == 0 {\n\t\treturn \"[]\"\n\t}\n\n\tvar sb strings.Builder\n\tsb.WriteString(\"[\\n\")\n\tfor i, msg := range messages {\n\t\tfmt.Fprintf(&sb, \"  [%d] Role: %s\\n\", i, msg.Role)\n\t\tif len(msg.ToolCalls) > 0 {\n\t\t\tsb.WriteString(\"  ToolCalls:\\n\")\n\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\tfmt.Fprintf(&sb, \"    - ID: %s, Type: %s, Name: %s\\n\", tc.ID, tc.Type, tc.Name)\n\t\t\t\tif tc.Function != nil {\n\t\t\t\t\tfmt.Fprintf(\n\t\t\t\t\t\t&sb,\n\t\t\t\t\t\t\"      Arguments: %s\\n\",\n\t\t\t\t\t\tutils.Truncate(tc.Function.Arguments, 200),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif msg.Content != \"\" {\n\t\t\tcontent := utils.Truncate(msg.Content, 200)\n\t\t\tfmt.Fprintf(&sb, \"  Content: %s\\n\", content)\n\t\t}\n\t\tif msg.ToolCallID != \"\" {\n\t\t\tfmt.Fprintf(&sb, \"  ToolCallID: %s\\n\", msg.ToolCallID)\n\t\t}\n\t\tsb.WriteString(\"\\n\")\n\t}\n\tsb.WriteString(\"]\")\n\treturn sb.String()\n}\n\n// formatToolsForLog formats tool definitions for logging\nfunc formatToolsForLog(toolDefs []providers.ToolDefinition) string {\n\tif len(toolDefs) == 0 {\n\t\treturn \"[]\"\n\t}\n\n\tvar sb strings.Builder\n\tsb.WriteString(\"[\\n\")\n\tfor i, tool := range toolDefs {\n\t\tfmt.Fprintf(&sb, \"  [%d] Type: %s, Name: %s\\n\", i, tool.Type, tool.Function.Name)\n\t\tfmt.Fprintf(&sb, \"      Description: %s\\n\", tool.Function.Description)\n\t\tif len(tool.Function.Parameters) > 0 {\n\t\t\tfmt.Fprintf(\n\t\t\t\t&sb,\n\t\t\t\t\"      Parameters: %s\\n\",\n\t\t\t\tutils.Truncate(fmt.Sprintf(\"%v\", tool.Function.Parameters), 200),\n\t\t\t)\n\t\t}\n\t}\n\tsb.WriteString(\"]\")\n\treturn sb.String()\n}\n\n// summarizeSession summarizes the conversation history for a session.\nfunc (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {\n\tctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)\n\tdefer cancel()\n\n\thistory := agent.Sessions.GetHistory(sessionKey)\n\tsummary := agent.Sessions.GetSummary(sessionKey)\n\n\t// Keep last 4 messages for continuity\n\tif len(history) <= 4 {\n\t\treturn\n\t}\n\n\ttoSummarize := history[:len(history)-4]\n\n\t// Oversized Message Guard\n\tmaxMessageTokens := agent.ContextWindow / 2\n\tvalidMessages := make([]providers.Message, 0)\n\tomitted := false\n\n\tfor _, m := range toSummarize {\n\t\tif m.Role != \"user\" && m.Role != \"assistant\" {\n\t\t\tcontinue\n\t\t}\n\t\tmsgTokens := len(m.Content) / 2\n\t\tif msgTokens > maxMessageTokens {\n\t\t\tomitted = true\n\t\t\tcontinue\n\t\t}\n\t\tvalidMessages = append(validMessages, m)\n\t}\n\n\tif len(validMessages) == 0 {\n\t\treturn\n\t}\n\n\tconst (\n\t\tmaxSummarizationMessages = 10\n\t\tllmMaxRetries            = 3\n\t\tllmTemperature           = 0.3\n\t\tfallbackMaxContentLength = 200\n\t)\n\n\t// Multi-Part Summarization\n\tvar finalSummary string\n\tif len(validMessages) > maxSummarizationMessages {\n\t\tmid := len(validMessages) / 2\n\n\t\tmid = al.findNearestUserMessage(validMessages, mid)\n\n\t\tpart1 := validMessages[:mid]\n\t\tpart2 := validMessages[mid:]\n\n\t\ts1, _ := al.summarizeBatch(ctx, agent, part1, \"\")\n\t\ts2, _ := al.summarizeBatch(ctx, agent, part2, \"\")\n\n\t\tmergePrompt := fmt.Sprintf(\n\t\t\t\"Merge these two conversation summaries into one cohesive summary:\\n\\n1: %s\\n\\n2: %s\",\n\t\t\ts1,\n\t\t\ts2,\n\t\t)\n\n\t\tresp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)\n\t\tif err == nil && resp.Content != \"\" {\n\t\t\tfinalSummary = resp.Content\n\t\t} else {\n\t\t\tfinalSummary = s1 + \" \" + s2\n\t\t}\n\t} else {\n\t\tfinalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)\n\t}\n\n\tif omitted && finalSummary != \"\" {\n\t\tfinalSummary += \"\\n[Note: Some oversized messages were omitted from this summary for efficiency.]\"\n\t}\n\n\tif finalSummary != \"\" {\n\t\tagent.Sessions.SetSummary(sessionKey, finalSummary)\n\t\tagent.Sessions.TruncateHistory(sessionKey, 4)\n\t\tagent.Sessions.Save(sessionKey)\n\t}\n}\n\n// findNearestUserMessage finds the nearest user message to the given index.\n// It searches backward first, then forward if no user message is found.\nfunc (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {\n\toriginalMid := mid\n\n\tfor mid > 0 && messages[mid].Role != \"user\" {\n\t\tmid--\n\t}\n\n\tif messages[mid].Role == \"user\" {\n\t\treturn mid\n\t}\n\n\tmid = originalMid\n\tfor mid < len(messages) && messages[mid].Role != \"user\" {\n\t\tmid++\n\t}\n\n\tif mid < len(messages) {\n\t\treturn mid\n\t}\n\n\treturn originalMid\n}\n\n// retryLLMCall calls the LLM with retry logic.\nfunc (al *AgentLoop) retryLLMCall(\n\tctx context.Context,\n\tagent *AgentInstance,\n\tprompt string,\n\tmaxRetries int,\n) (*providers.LLMResponse, error) {\n\tconst (\n\t\tllmTemperature = 0.3\n\t)\n\n\tvar resp *providers.LLMResponse\n\tvar err error\n\n\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\tal.activeRequests.Add(1)\n\t\tresp, err = func() (*providers.LLMResponse, error) {\n\t\t\tdefer al.activeRequests.Done()\n\t\t\treturn agent.Provider.Chat(\n\t\t\t\tctx,\n\t\t\t\t[]providers.Message{{Role: \"user\", Content: prompt}},\n\t\t\t\tnil,\n\t\t\t\tagent.Model,\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"max_tokens\":       agent.MaxTokens,\n\t\t\t\t\t\"temperature\":      llmTemperature,\n\t\t\t\t\t\"prompt_cache_key\": agent.ID,\n\t\t\t\t},\n\t\t\t)\n\t\t}()\n\n\t\tif err == nil && resp != nil && resp.Content != \"\" {\n\t\t\treturn resp, nil\n\t\t}\n\t\tif attempt < maxRetries-1 {\n\t\t\ttime.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)\n\t\t}\n\t}\n\n\treturn resp, err\n}\n\n// summarizeBatch summarizes a batch of messages.\nfunc (al *AgentLoop) summarizeBatch(\n\tctx context.Context,\n\tagent *AgentInstance,\n\tbatch []providers.Message,\n\texistingSummary string,\n) (string, error) {\n\tconst (\n\t\tllmMaxRetries             = 3\n\t\tllmTemperature            = 0.3\n\t\tfallbackMinContentLength  = 200\n\t\tfallbackMaxContentPercent = 10\n\t)\n\n\tvar sb strings.Builder\n\tsb.WriteString(\n\t\t\"Provide a concise summary of this conversation segment, preserving core context and key points.\\n\",\n\t)\n\tif existingSummary != \"\" {\n\t\tsb.WriteString(\"Existing context: \")\n\t\tsb.WriteString(existingSummary)\n\t\tsb.WriteString(\"\\n\")\n\t}\n\tsb.WriteString(\"\\nCONVERSATION:\\n\")\n\tfor _, m := range batch {\n\t\tfmt.Fprintf(&sb, \"%s: %s\\n\", m.Role, m.Content)\n\t}\n\tprompt := sb.String()\n\n\tresponse, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)\n\tif err == nil && response.Content != \"\" {\n\t\treturn strings.TrimSpace(response.Content), nil\n\t}\n\n\tvar fallback strings.Builder\n\tfallback.WriteString(\"Conversation summary: \")\n\tfor i, m := range batch {\n\t\tif i > 0 {\n\t\t\tfallback.WriteString(\" | \")\n\t\t}\n\t\tcontent := strings.TrimSpace(m.Content)\n\t\trunes := []rune(content)\n\t\tif len(runes) == 0 {\n\t\t\tfallback.WriteString(fmt.Sprintf(\"%s: \", m.Role))\n\t\t\tcontinue\n\t\t}\n\n\t\tkeepLength := len(runes) * fallbackMaxContentPercent / 100\n\t\tif keepLength < fallbackMinContentLength {\n\t\t\tkeepLength = fallbackMinContentLength\n\t\t}\n\n\t\tif keepLength > len(runes) {\n\t\t\tkeepLength = len(runes)\n\t\t}\n\n\t\tcontent = string(runes[:keepLength])\n\t\tif keepLength < len(runes) {\n\t\t\tcontent += \"...\"\n\t\t}\n\t\tfallback.WriteString(fmt.Sprintf(\"%s: %s\", m.Role, content))\n\t}\n\treturn fallback.String(), nil\n}\n\n// estimateTokens estimates the number of tokens in a message list.\n// Uses a safe heuristic of 2.5 characters per token to account for CJK and other\n// overheads better than the previous 3 chars/token.\nfunc (al *AgentLoop) estimateTokens(messages []providers.Message) int {\n\ttotalChars := 0\n\tfor _, m := range messages {\n\t\ttotalChars += utf8.RuneCountInString(m.Content)\n\t}\n\t// 2.5 chars per token = totalChars * 2 / 5\n\treturn totalChars * 2 / 5\n}\n\nfunc (al *AgentLoop) handleCommand(\n\tctx context.Context,\n\tmsg bus.InboundMessage,\n\tagent *AgentInstance,\n\topts *processOptions,\n) (string, bool) {\n\tif !commands.HasCommandPrefix(msg.Content) {\n\t\treturn \"\", false\n\t}\n\n\tif al.cmdRegistry == nil {\n\t\treturn \"\", false\n\t}\n\n\trt := al.buildCommandsRuntime(agent, opts)\n\texecutor := commands.NewExecutor(al.cmdRegistry, rt)\n\n\tvar commandReply string\n\tresult := executor.Execute(ctx, commands.Request{\n\t\tChannel:  msg.Channel,\n\t\tChatID:   msg.ChatID,\n\t\tSenderID: msg.SenderID,\n\t\tText:     msg.Content,\n\t\tReply: func(text string) error {\n\t\t\tcommandReply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\n\tswitch result.Outcome {\n\tcase commands.OutcomeHandled:\n\t\tif result.Err != nil {\n\t\t\treturn mapCommandError(result), true\n\t\t}\n\t\tif commandReply != \"\" {\n\t\t\treturn commandReply, true\n\t\t}\n\t\treturn \"\", true\n\tdefault: // OutcomePassthrough — let the message fall through to LLM\n\t\treturn \"\", false\n\t}\n}\n\nfunc (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {\n\tregistry := al.GetRegistry()\n\tcfg := al.GetConfig()\n\trt := &commands.Runtime{\n\t\tConfig:          cfg,\n\t\tListAgentIDs:    registry.ListAgentIDs,\n\t\tListDefinitions: al.cmdRegistry.Definitions,\n\t\tGetEnabledChannels: func() []string {\n\t\t\tif al.channelManager == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn al.channelManager.GetEnabledChannels()\n\t\t},\n\t\tSwitchChannel: func(value string) error {\n\t\t\tif al.channelManager == nil {\n\t\t\t\treturn fmt.Errorf(\"channel manager not initialized\")\n\t\t\t}\n\t\t\tif _, exists := al.channelManager.GetChannel(value); !exists && value != \"cli\" {\n\t\t\t\treturn fmt.Errorf(\"channel '%s' not found or not enabled\", value)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\trt.ReloadConfig = func() error {\n\t\tif al.reloadFunc == nil {\n\t\t\treturn fmt.Errorf(\"reload not configured\")\n\t\t}\n\t\treturn al.reloadFunc()\n\t}\n\tif agent != nil {\n\t\trt.GetModelInfo = func() (string, string) {\n\t\t\treturn agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)\n\t\t}\n\t\trt.SwitchModel = func(value string) (string, error) {\n\t\t\tvalue = strings.TrimSpace(value)\n\t\t\tmodelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\n\t\t\tnextProvider, _, err := providers.CreateProviderFromConfig(modelCfg)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"failed to initialize model %q: %w\", value, err)\n\t\t\t}\n\n\t\t\tnextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks)\n\t\t\tif len(nextCandidates) == 0 {\n\t\t\t\treturn \"\", fmt.Errorf(\"model %q did not resolve to any provider candidates\", value)\n\t\t\t}\n\n\t\t\toldModel := agent.Model\n\t\t\toldProvider := agent.Provider\n\t\t\tagent.Model = value\n\t\t\tagent.Provider = nextProvider\n\t\t\tagent.Candidates = nextCandidates\n\t\t\tagent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel)\n\n\t\t\tif oldProvider != nil && oldProvider != nextProvider {\n\t\t\t\tif stateful, ok := oldProvider.(providers.StatefulProvider); ok {\n\t\t\t\t\tstateful.Close()\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn oldModel, nil\n\t\t}\n\n\t\trt.ClearHistory = func() error {\n\t\t\tif opts == nil {\n\t\t\t\treturn fmt.Errorf(\"process options not available\")\n\t\t\t}\n\t\t\tif agent.Sessions == nil {\n\t\t\t\treturn fmt.Errorf(\"sessions not initialized for agent\")\n\t\t\t}\n\n\t\t\tagent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))\n\t\t\tagent.Sessions.SetSummary(opts.SessionKey, \"\")\n\t\t\tagent.Sessions.Save(opts.SessionKey)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn rt\n}\n\nfunc mapCommandError(result commands.ExecuteResult) string {\n\tif result.Command == \"\" {\n\t\treturn fmt.Sprintf(\"Failed to execute command: %v\", result.Err)\n\t}\n\treturn fmt.Sprintf(\"Failed to execute /%s: %v\", result.Command, result.Err)\n}\n\n// extractPeer extracts the routing peer from the inbound message's structured Peer field.\nfunc extractPeer(msg bus.InboundMessage) *routing.RoutePeer {\n\tif msg.Peer.Kind == \"\" {\n\t\treturn nil\n\t}\n\tpeerID := msg.Peer.ID\n\tif peerID == \"\" {\n\t\tif msg.Peer.Kind == \"direct\" {\n\t\t\tpeerID = msg.SenderID\n\t\t} else {\n\t\t\tpeerID = msg.ChatID\n\t\t}\n\t}\n\treturn &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}\n}\n\nfunc inboundMetadata(msg bus.InboundMessage, key string) string {\n\tif msg.Metadata == nil {\n\t\treturn \"\"\n\t}\n\treturn msg.Metadata[key]\n}\n\n// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.\nfunc extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {\n\tparentKind := inboundMetadata(msg, metadataKeyParentPeerKind)\n\tparentID := inboundMetadata(msg, metadataKeyParentPeerID)\n\tif parentKind == \"\" || parentID == \"\" {\n\t\treturn nil\n\t}\n\treturn &routing.RoutePeer{Kind: parentKind, ID: parentID}\n}\n\n// isNativeSearchProvider reports whether the given LLM provider implements\n// NativeSearchCapable and returns true for SupportsNativeSearch.\nfunc isNativeSearchProvider(p providers.LLMProvider) bool {\n\tif ns, ok := p.(providers.NativeSearchCapable); ok {\n\t\treturn ns.SupportsNativeSearch()\n\t}\n\treturn false\n}\n\n// filterClientWebSearch returns a copy of tools with the client-side\n// web_search tool removed. Used when native provider search is preferred.\nfunc filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition {\n\tresult := make([]providers.ToolDefinition, 0, len(tools))\n\tfor _, t := range tools {\n\t\tif strings.EqualFold(t.Function.Name, \"web_search\") {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, t)\n\t}\n\treturn result\n}\n\n// Helper to extract provider from registry for cleanup\nfunc extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {\n\tif registry == nil {\n\t\treturn nil, false\n\t}\n\t// Get any agent to access the provider\n\tdefaultAgent := registry.GetDefaultAgent()\n\tif defaultAgent == nil {\n\t\treturn nil, false\n\t}\n\treturn defaultAgent.Provider, true\n}\n"
  },
  {
    "path": "pkg/agent/loop_mcp.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/mcp\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\ntype mcpRuntime struct {\n\tinitOnce sync.Once\n\tmu       sync.Mutex\n\tmanager  *mcp.Manager\n\tinitErr  error\n}\n\nfunc (r *mcpRuntime) setManager(manager *mcp.Manager) {\n\tr.mu.Lock()\n\tr.manager = manager\n\tr.initErr = nil\n\tr.mu.Unlock()\n}\n\nfunc (r *mcpRuntime) setInitErr(err error) {\n\tr.mu.Lock()\n\tr.initErr = err\n\tr.mu.Unlock()\n}\n\nfunc (r *mcpRuntime) getInitErr() error {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\treturn r.initErr\n}\n\nfunc (r *mcpRuntime) takeManager() *mcp.Manager {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tmanager := r.manager\n\tr.manager = nil\n\treturn manager\n}\n\nfunc (r *mcpRuntime) hasManager() bool {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\treturn r.manager != nil\n}\n\n// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct\n// agent mode share the same initialization path.\nfunc (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {\n\tif !al.cfg.Tools.IsToolEnabled(\"mcp\") {\n\t\treturn nil\n\t}\n\n\tif al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 {\n\t\tlogger.WarnCF(\"agent\", \"MCP is enabled but no servers are configured, skipping MCP initialization\", nil)\n\t\treturn nil\n\t}\n\n\tfindValidServer := false\n\tfor _, serverCfg := range al.cfg.Tools.MCP.Servers {\n\t\tif serverCfg.Enabled {\n\t\t\tfindValidServer = true\n\t\t}\n\t}\n\tif !findValidServer {\n\t\tlogger.WarnCF(\"agent\", \"MCP is enabled but no valid servers are configured, skipping MCP initialization\", nil)\n\t\treturn nil\n\t}\n\n\tal.mcp.initOnce.Do(func() {\n\t\tmcpManager := mcp.NewManager()\n\n\t\tdefaultAgent := al.registry.GetDefaultAgent()\n\t\tworkspacePath := al.cfg.WorkspacePath()\n\t\tif defaultAgent != nil && defaultAgent.Workspace != \"\" {\n\t\t\tworkspacePath = defaultAgent.Workspace\n\t\t}\n\n\t\tif err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {\n\t\t\tlogger.WarnCF(\"agent\", \"Failed to load MCP servers, MCP tools will not be available\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\tif closeErr := mcpManager.Close(); closeErr != nil {\n\t\t\t\tlogger.ErrorCF(\"agent\", \"Failed to close MCP manager\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"error\": closeErr.Error(),\n\t\t\t\t\t})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// Register MCP tools for all agents\n\t\tservers := mcpManager.GetServers()\n\t\tuniqueTools := 0\n\t\ttotalRegistrations := 0\n\t\tagentIDs := al.registry.ListAgentIDs()\n\t\tagentCount := len(agentIDs)\n\n\t\tfor serverName, conn := range servers {\n\t\t\tuniqueTools += len(conn.Tools)\n\n\t\t\t// Determine whether this server's tools should be deferred (hidden).\n\t\t\t// Per-server \"deferred\" field takes precedence over the global Discovery.Enabled.\n\t\t\tserverCfg := al.cfg.Tools.MCP.Servers[serverName]\n\t\t\tregisterAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)\n\n\t\t\tfor _, tool := range conn.Tools {\n\t\t\t\tfor _, agentID := range agentIDs {\n\t\t\t\t\tagent, ok := al.registry.GetAgent(agentID)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tmcpTool := tools.NewMCPTool(mcpManager, serverName, tool)\n\n\t\t\t\t\tif registerAsHidden {\n\t\t\t\t\t\tagent.Tools.RegisterHidden(mcpTool)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tagent.Tools.Register(mcpTool)\n\t\t\t\t\t}\n\n\t\t\t\t\ttotalRegistrations++\n\t\t\t\t\tlogger.DebugCF(\"agent\", \"Registered MCP tool\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"agent_id\": agentID,\n\t\t\t\t\t\t\t\"server\":   serverName,\n\t\t\t\t\t\t\t\"tool\":     tool.Name,\n\t\t\t\t\t\t\t\"name\":     mcpTool.Name(),\n\t\t\t\t\t\t\t\"deferred\": registerAsHidden,\n\t\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tlogger.InfoCF(\"agent\", \"MCP tools registered successfully\",\n\t\t\tmap[string]any{\n\t\t\t\t\"server_count\":        len(servers),\n\t\t\t\t\"unique_tools\":        uniqueTools,\n\t\t\t\t\"total_registrations\": totalRegistrations,\n\t\t\t\t\"agent_count\":         agentCount,\n\t\t\t})\n\n\t\t// Initializes Discovery Tools only if enabled by configuration\n\t\tif al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {\n\t\t\tuseBM25 := al.cfg.Tools.MCP.Discovery.UseBM25\n\t\t\tuseRegex := al.cfg.Tools.MCP.Discovery.UseRegex\n\n\t\t\t// Fail fast: If discovery is enabled but no search method is turned on\n\t\t\tif !useBM25 && !useRegex {\n\t\t\t\tal.mcp.setInitErr(fmt.Errorf(\n\t\t\t\t\t\"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration\",\n\t\t\t\t))\n\t\t\t\tif closeErr := mcpManager.Close(); closeErr != nil {\n\t\t\t\t\tlogger.ErrorCF(\"agent\", \"Failed to close MCP manager\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"error\": closeErr.Error(),\n\t\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tttl := al.cfg.Tools.MCP.Discovery.TTL\n\t\t\tif ttl <= 0 {\n\t\t\t\tttl = 5 // Default value\n\t\t\t}\n\n\t\t\tmaxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults\n\t\t\tif maxSearchResults <= 0 {\n\t\t\t\tmaxSearchResults = 5 // Default value\n\t\t\t}\n\n\t\t\tlogger.InfoCF(\"agent\", \"Initializing tool discovery\", map[string]any{\n\t\t\t\t\"bm25\": useBM25, \"regex\": useRegex, \"ttl\": ttl, \"max_results\": maxSearchResults,\n\t\t\t})\n\n\t\t\tfor _, agentID := range agentIDs {\n\t\t\t\tagent, ok := al.registry.GetAgent(agentID)\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif useRegex {\n\t\t\t\t\tagent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))\n\t\t\t\t}\n\t\t\t\tif useBM25 {\n\t\t\t\t\tagent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tal.mcp.setManager(mcpManager)\n\t})\n\n\treturn al.mcp.getInitErr()\n}\n\n// serverIsDeferred reports whether an MCP server's tools should be registered\n// as hidden (deferred/discovery mode).\n//\n// The per-server Deferred field takes precedence over the global discoveryEnabled\n// default. When Deferred is nil, discoveryEnabled is used as the fallback.\nfunc serverIsDeferred(discoveryEnabled bool, serverCfg config.MCPServerConfig) bool {\n\tif !discoveryEnabled {\n\t\treturn false\n\t}\n\tif serverCfg.Deferred != nil {\n\t\treturn *serverCfg.Deferred\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pkg/agent/loop_mcp_test.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage agent\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc boolPtr(b bool) *bool { return &b }\n\nfunc TestServerIsDeferred(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tdiscoveryEnabled bool\n\t\tserverDeferred   *bool\n\t\twant             bool\n\t}{\n\t\t// --- global false always wins: per-server deferred is ignored ---\n\t\t{\n\t\t\tname:             \"global false: per-server deferred=true is ignored\",\n\t\t\tdiscoveryEnabled: false,\n\t\t\tserverDeferred:   boolPtr(true),\n\t\t\twant:             false,\n\t\t},\n\t\t{\n\t\t\tname:             \"global false: per-server deferred=false stays false\",\n\t\t\tdiscoveryEnabled: false,\n\t\t\tserverDeferred:   boolPtr(false),\n\t\t\twant:             false,\n\t\t},\n\t\t// --- global true: per-server override applies ---\n\t\t{\n\t\t\tname:             \"global true: per-server deferred=false opts out\",\n\t\t\tdiscoveryEnabled: true,\n\t\t\tserverDeferred:   boolPtr(false),\n\t\t\twant:             false,\n\t\t},\n\t\t{\n\t\t\tname:             \"global true: per-server deferred=true stays true\",\n\t\t\tdiscoveryEnabled: true,\n\t\t\tserverDeferred:   boolPtr(true),\n\t\t\twant:             true,\n\t\t},\n\t\t// --- no per-server override: fall back to global ---\n\t\t{\n\t\t\tname:             \"no per-server field, global discovery enabled\",\n\t\t\tdiscoveryEnabled: true,\n\t\t\tserverDeferred:   nil,\n\t\t\twant:             true,\n\t\t},\n\t\t{\n\t\t\tname:             \"no per-server field, global discovery disabled\",\n\t\t\tdiscoveryEnabled: false,\n\t\t\tserverDeferred:   nil,\n\t\t\twant:             false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserverCfg := config.MCPServerConfig{Deferred: tt.serverDeferred}\n\t\t\tgot := serverIsDeferred(tt.discoveryEnabled, serverCfg)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"serverIsDeferred(discoveryEnabled=%v, deferred=%v) = %v, want %v\",\n\t\t\t\t\ttt.discoveryEnabled, tt.serverDeferred, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/loop_media.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage agent\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/h2non/filetype\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// resolveMediaRefs resolves media:// refs in messages.\n// Images are base64-encoded into the Media array for multimodal LLMs.\n// Non-image files (documents, audio, video) have their local path injected\n// into Content so the agent can access them via file tools like read_file.\n// Returns a new slice; original messages are not mutated.\nfunc resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {\n\tif store == nil {\n\t\treturn messages\n\t}\n\n\tresult := make([]providers.Message, len(messages))\n\tcopy(result, messages)\n\n\tfor i, m := range result {\n\t\tif len(m.Media) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tresolved := make([]string, 0, len(m.Media))\n\t\tvar pathTags []string\n\n\t\tfor _, ref := range m.Media {\n\t\t\tif !strings.HasPrefix(ref, \"media://\") {\n\t\t\t\tresolved = append(resolved, ref)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlocalPath, meta, err := store.ResolveWithMeta(ref)\n\t\t\tif err != nil {\n\t\t\t\tlogger.WarnCF(\"agent\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\t\"ref\":   ref,\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tinfo, err := os.Stat(localPath)\n\t\t\tif err != nil {\n\t\t\t\tlogger.WarnCF(\"agent\", \"Failed to stat media file\", map[string]any{\n\t\t\t\t\t\"path\":  localPath,\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tmime := detectMIME(localPath, meta)\n\n\t\t\tif strings.HasPrefix(mime, \"image/\") {\n\t\t\t\tdataURL := encodeImageToDataURL(localPath, mime, info, maxSize)\n\t\t\t\tif dataURL != \"\" {\n\t\t\t\t\tresolved = append(resolved, dataURL)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpathTags = append(pathTags, buildPathTag(mime, localPath))\n\t\t}\n\n\t\tresult[i].Media = resolved\n\t\tif len(pathTags) > 0 {\n\t\t\tresult[i].Content = injectPathTags(result[i].Content, pathTags)\n\t\t}\n\t}\n\n\treturn result\n}\n\n// detectMIME determines the MIME type from metadata or magic-bytes detection.\n// Returns empty string if detection fails.\nfunc detectMIME(localPath string, meta media.MediaMeta) string {\n\tif meta.ContentType != \"\" {\n\t\treturn meta.ContentType\n\t}\n\tkind, err := filetype.MatchFile(localPath)\n\tif err != nil || kind == filetype.Unknown {\n\t\treturn \"\"\n\t}\n\treturn kind.MIME.Value\n}\n\n// encodeImageToDataURL base64-encodes an image file into a data URL.\n// Returns empty string if the file exceeds maxSize or encoding fails.\nfunc encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {\n\tif info.Size() > int64(maxSize) {\n\t\tlogger.WarnCF(\"agent\", \"Media file too large, skipping\", map[string]any{\n\t\t\t\"path\":     localPath,\n\t\t\t\"size\":     info.Size(),\n\t\t\t\"max_size\": maxSize,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tf, err := os.Open(localPath)\n\tif err != nil {\n\t\tlogger.WarnCF(\"agent\", \"Failed to open media file\", map[string]any{\n\t\t\t\"path\":  localPath,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\tdefer f.Close()\n\n\tprefix := \"data:\" + mime + \";base64,\"\n\tencodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))\n\tvar buf bytes.Buffer\n\tbuf.Grow(len(prefix) + encodedLen)\n\tbuf.WriteString(prefix)\n\n\tencoder := base64.NewEncoder(base64.StdEncoding, &buf)\n\tif _, err := io.Copy(encoder, f); err != nil {\n\t\tlogger.WarnCF(\"agent\", \"Failed to encode media file\", map[string]any{\n\t\t\t\"path\":  localPath,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\tencoder.Close()\n\n\treturn buf.String()\n}\n\n// buildPathTag creates a structured tag exposing the local file path.\n// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path].\nfunc buildPathTag(mime, localPath string) string {\n\tswitch {\n\tcase strings.HasPrefix(mime, \"audio/\"):\n\t\treturn \"[audio:\" + localPath + \"]\"\n\tcase strings.HasPrefix(mime, \"video/\"):\n\t\treturn \"[video:\" + localPath + \"]\"\n\tdefault:\n\t\treturn \"[file:\" + localPath + \"]\"\n\t}\n}\n\n// injectPathTags replaces generic media tags in content with path-bearing versions,\n// or appends if no matching generic tag is found.\nfunc injectPathTags(content string, tags []string) string {\n\tfor _, tag := range tags {\n\t\tvar generic string\n\t\tswitch {\n\t\tcase strings.HasPrefix(tag, \"[audio:\"):\n\t\t\tgeneric = \"[audio]\"\n\t\tcase strings.HasPrefix(tag, \"[video:\"):\n\t\t\tgeneric = \"[video]\"\n\t\tcase strings.HasPrefix(tag, \"[file:\"):\n\t\t\tgeneric = \"[file]\"\n\t\t}\n\n\t\tif generic != \"\" && strings.Contains(content, generic) {\n\t\t\tcontent = strings.Replace(content, generic, tag, 1)\n\t\t} else if content == \"\" {\n\t\t\tcontent = tag\n\t\t} else {\n\t\t\tcontent += \" \" + tag\n\t\t}\n\t}\n\treturn content\n}\n"
  },
  {
    "path": "pkg/agent/loop_test.go",
    "content": "package agent\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/routing\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\ntype fakeChannel struct{ id string }\n\nfunc (f *fakeChannel) Name() string                                            { return \"fake\" }\nfunc (f *fakeChannel) Start(ctx context.Context) error                         { return nil }\nfunc (f *fakeChannel) Stop(ctx context.Context) error                          { return nil }\nfunc (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }\nfunc (f *fakeChannel) IsRunning() bool                                         { return true }\nfunc (f *fakeChannel) IsAllowed(string) bool                                   { return true }\nfunc (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool              { return true }\nfunc (f *fakeChannel) ReasoningChannelID() string                              { return f.id }\n\ntype recordingProvider struct {\n\tlastMessages []providers.Message\n}\n\nfunc (r *recordingProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\topts map[string]any,\n) (*providers.LLMResponse, error) {\n\tr.lastMessages = append([]providers.Message(nil), messages...)\n\treturn &providers.LLMResponse{\n\t\tContent:   \"Mock response\",\n\t\tToolCalls: []providers.ToolCall{},\n\t}, nil\n}\n\nfunc (r *recordingProvider) GetDefaultModel() string {\n\treturn \"mock-model\"\n}\n\nfunc newTestAgentLoop(\n\tt *testing.T,\n) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {\n\tt.Helper()\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tcfg = &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\tmsgBus = bus.NewMessageBus()\n\tprovider = &mockProvider{}\n\tal = NewAgentLoop(cfg, msgBus, provider)\n\treturn al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }\n}\n\nfunc TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &recordingProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\tresponse, err := al.processMessage(context.Background(), bus.InboundMessage{\n\t\tChannel:  \"discord\",\n\t\tSenderID: \"discord:123\",\n\t\tSender: bus.SenderInfo{\n\t\t\tDisplayName: \"Alice\",\n\t\t},\n\t\tChatID:  \"group-1\",\n\t\tContent: \"hello\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"processMessage() error = %v\", err)\n\t}\n\tif response != \"Mock response\" {\n\t\tt.Fatalf(\"processMessage() response = %q, want %q\", response, \"Mock response\")\n\t}\n\tif len(provider.lastMessages) == 0 {\n\t\tt.Fatal(\"provider did not receive any messages\")\n\t}\n\n\tsystemPrompt := provider.lastMessages[0].Content\n\twantSender := \"## Current Sender\\nCurrent sender: Alice (ID: discord:123)\"\n\tif !strings.Contains(systemPrompt, wantSender) {\n\t\tt.Fatalf(\"system prompt missing sender context %q:\\n%s\", wantSender, systemPrompt)\n\t}\n\n\tlastMessage := provider.lastMessages[len(provider.lastMessages)-1]\n\tif lastMessage.Role != \"user\" || lastMessage.Content != \"hello\" {\n\t\tt.Fatalf(\"last provider message = %+v, want unchanged user message\", lastMessage)\n\t}\n}\n\nfunc TestRecordLastChannel(t *testing.T) {\n\tal, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)\n\tdefer cleanup()\n\n\ttestChannel := \"test-channel\"\n\tif err := al.RecordLastChannel(testChannel); err != nil {\n\t\tt.Fatalf(\"RecordLastChannel failed: %v\", err)\n\t}\n\tif got := al.state.GetLastChannel(); got != testChannel {\n\t\tt.Errorf(\"Expected channel '%s', got '%s'\", testChannel, got)\n\t}\n\tal2 := NewAgentLoop(cfg, msgBus, provider)\n\tif got := al2.state.GetLastChannel(); got != testChannel {\n\t\tt.Errorf(\"Expected persistent channel '%s', got '%s'\", testChannel, got)\n\t}\n}\n\nfunc TestRecordLastChatID(t *testing.T) {\n\tal, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)\n\tdefer cleanup()\n\n\ttestChatID := \"test-chat-id-123\"\n\tif err := al.RecordLastChatID(testChatID); err != nil {\n\t\tt.Fatalf(\"RecordLastChatID failed: %v\", err)\n\t}\n\tif got := al.state.GetLastChatID(); got != testChatID {\n\t\tt.Errorf(\"Expected chat ID '%s', got '%s'\", testChatID, got)\n\t}\n\tal2 := NewAgentLoop(cfg, msgBus, provider)\n\tif got := al2.state.GetLastChatID(); got != testChatID {\n\t\tt.Errorf(\"Expected persistent chat ID '%s', got '%s'\", testChatID, got)\n\t}\n}\n\nfunc TestNewAgentLoop_StateInitialized(t *testing.T) {\n\t// Create temp workspace\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Create test config\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\t// Create agent loop\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\t// Verify state manager is initialized\n\tif al.state == nil {\n\t\tt.Error(\"Expected state manager to be initialized\")\n\t}\n\n\t// Verify state directory was created\n\tstateDir := filepath.Join(tmpDir, \"state\")\n\tif _, err := os.Stat(stateDir); os.IsNotExist(err) {\n\t\tt.Error(\"Expected state directory to exist\")\n\t}\n}\n\n// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved\nfunc TestToolRegistry_ToolRegistration(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\t// Register a custom tool\n\tcustomTool := &mockCustomTool{}\n\tal.RegisterTool(customTool)\n\n\t// Verify tool is registered by checking it doesn't panic on GetStartupInfo\n\t// (actual tool retrieval is tested in tools package tests)\n\tinfo := al.GetStartupInfo()\n\ttoolsInfo := info[\"tools\"].(map[string]any)\n\ttoolsList := toolsInfo[\"names\"].([]string)\n\n\t// Check that our custom tool name is in the list\n\tfound := slices.Contains(toolsList, \"mock_custom\")\n\tif !found {\n\t\tt.Error(\"Expected custom tool to be registered\")\n\t}\n}\n\n// TestToolContext_Updates verifies tool context helpers work correctly\nfunc TestToolContext_Updates(t *testing.T) {\n\tctx := tools.WithToolContext(context.Background(), \"telegram\", \"chat-42\")\n\n\tif got := tools.ToolChannel(ctx); got != \"telegram\" {\n\t\tt.Errorf(\"expected channel 'telegram', got %q\", got)\n\t}\n\tif got := tools.ToolChatID(ctx); got != \"chat-42\" {\n\t\tt.Errorf(\"expected chatID 'chat-42', got %q\", got)\n\t}\n\n\t// Empty context returns empty strings\n\tif got := tools.ToolChannel(context.Background()); got != \"\" {\n\t\tt.Errorf(\"expected empty channel from bare context, got %q\", got)\n\t}\n}\n\n// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved\nfunc TestToolRegistry_GetDefinitions(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\t// Register a test tool and verify it shows up in startup info\n\ttestTool := &mockCustomTool{}\n\tal.RegisterTool(testTool)\n\n\tinfo := al.GetStartupInfo()\n\ttoolsInfo := info[\"tools\"].(map[string]any)\n\ttoolsList := toolsInfo[\"names\"].([]string)\n\n\t// Check that our custom tool name is in the list\n\tfound := slices.Contains(toolsList, \"mock_custom\")\n\tif !found {\n\t\tt.Error(\"Expected custom tool to be registered\")\n\t}\n}\n\n// TestAgentLoop_GetStartupInfo verifies startup info contains tools\nfunc TestAgentLoop_GetStartupInfo(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Workspace = tmpDir\n\tcfg.Agents.Defaults.Model = \"test-model\"\n\tcfg.Agents.Defaults.MaxTokens = 4096\n\tcfg.Agents.Defaults.MaxToolIterations = 10\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\tinfo := al.GetStartupInfo()\n\n\t// Verify tools info exists\n\ttoolsInfo, ok := info[\"tools\"]\n\tif !ok {\n\t\tt.Fatal(\"Expected 'tools' key in startup info\")\n\t}\n\n\ttoolsMap, ok := toolsInfo.(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Expected 'tools' to be a map\")\n\t}\n\n\tcount, ok := toolsMap[\"count\"]\n\tif !ok {\n\t\tt.Fatal(\"Expected 'count' in tools info\")\n\t}\n\n\t// Should have default tools registered\n\tif count.(int) == 0 {\n\t\tt.Error(\"Expected at least some tools to be registered\")\n\t}\n}\n\n// TestAgentLoop_Stop verifies Stop() sets running to false\nfunc TestAgentLoop_Stop(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\t// Note: running is only set to true when Run() is called\n\t// We can't test that without starting the event loop\n\t// Instead, verify the Stop method can be called safely\n\tal.Stop()\n\n\t// Verify running is false (initial state or after Stop)\n\tif al.running.Load() {\n\t\tt.Error(\"Expected agent to be stopped (or never started)\")\n\t}\n}\n\n// Mock implementations for testing\n\ntype simpleMockProvider struct {\n\tresponse string\n}\n\nfunc (m *simpleMockProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\topts map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn &providers.LLMResponse{\n\t\tContent:   m.response,\n\t\tToolCalls: []providers.ToolCall{},\n\t}, nil\n}\n\nfunc (m *simpleMockProvider) GetDefaultModel() string {\n\treturn \"mock-model\"\n}\n\ntype countingMockProvider struct {\n\tresponse string\n\tcalls    int\n}\n\nfunc (m *countingMockProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\topts map[string]any,\n) (*providers.LLMResponse, error) {\n\tm.calls++\n\treturn &providers.LLMResponse{\n\t\tContent:   m.response,\n\t\tToolCalls: []providers.ToolCall{},\n\t}, nil\n}\n\nfunc (m *countingMockProvider) GetDefaultModel() string {\n\treturn \"counting-mock-model\"\n}\n\n// mockCustomTool is a simple mock tool for registration testing\ntype mockCustomTool struct{}\n\nfunc (m *mockCustomTool) Name() string {\n\treturn \"mock_custom\"\n}\n\nfunc (m *mockCustomTool) Description() string {\n\treturn \"Mock custom tool for testing\"\n}\n\nfunc (m *mockCustomTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\":       \"object\",\n\t\t\"properties\": map[string]any{},\n\t}\n}\n\nfunc (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {\n\treturn tools.SilentResult(\"Custom tool executed\")\n}\n\n// testHelper executes a message and returns the response\ntype testHelper struct {\n\tal *AgentLoop\n}\n\nfunc newChatCompletionTestServer(\n\tt *testing.T,\n\tlabel string,\n\tresponse string,\n\tcalls *int,\n\tmodel *string,\n) *httptest.Server {\n\tt.Helper()\n\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/chat/completions\" {\n\t\t\tt.Fatalf(\"%s server path = %q, want /chat/completions\", label, r.URL.Path)\n\t\t}\n\t\t*calls = *calls + 1\n\t\tdefer r.Body.Close()\n\n\t\tvar req struct {\n\t\t\tModel string `json:\"model\"`\n\t\t}\n\t\tdecodeErr := json.NewDecoder(r.Body).Decode(&req)\n\t\tif decodeErr != nil {\n\t\t\tt.Fatalf(\"decode %s request: %v\", label, decodeErr)\n\t\t}\n\t\t*model = req.Model\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tencodeErr := json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": response},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif encodeErr != nil {\n\t\t\tt.Fatalf(\"encode %s response: %v\", label, encodeErr)\n\t\t}\n\t}))\n}\n\nfunc (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {\n\t// Use a short timeout to avoid hanging\n\ttimeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)\n\tdefer cancel()\n\n\tresponse, err := h.al.processMessage(timeoutCtx, msg)\n\tif err != nil {\n\t\ttb.Fatalf(\"processMessage failed: %v\", err)\n\t}\n\treturn response\n}\n\nconst responseTimeout = 3 * time.Second\n\nfunc TestProcessMessage_UsesRouteSessionKey(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &simpleMockProvider{response: \"ok\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\tmsg := bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"hello\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t}\n\n\troute := al.registry.ResolveRoute(routing.RouteInput{\n\t\tChannel: msg.Channel,\n\t\tPeer:    extractPeer(msg),\n\t})\n\tsessionKey := route.SessionKey\n\n\tdefaultAgent := al.registry.GetDefaultAgent()\n\tif defaultAgent == nil {\n\t\tt.Fatal(\"No default agent found\")\n\t}\n\n\thelper := testHelper{al: al}\n\t_ = helper.executeAndGetResponse(t, context.Background(), msg)\n\n\thistory := defaultAgent.Sessions.GetHistory(sessionKey)\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected session history len=2, got %d\", len(history))\n\t}\n\tif history[0].Role != \"user\" || history[0].Content != \"hello\" {\n\t\tt.Fatalf(\"unexpected first message in session: %+v\", history[0])\n\t}\n}\n\nfunc TestProcessMessage_CommandOutcomes(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t\tSession: config.SessionConfig{\n\t\t\tDMScope: \"per-channel-peer\",\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &countingMockProvider{response: \"LLM reply\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\tbaseMsg := bus.InboundMessage{\n\t\tChannel:  \"whatsapp\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t}\n\n\tshowResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  baseMsg.Channel,\n\t\tSenderID: baseMsg.SenderID,\n\t\tChatID:   baseMsg.ChatID,\n\t\tContent:  \"/show channel\",\n\t\tPeer:     baseMsg.Peer,\n\t})\n\tif showResp != \"Current Channel: whatsapp\" {\n\t\tt.Fatalf(\"unexpected /show reply: %q\", showResp)\n\t}\n\tif provider.calls != 0 {\n\t\tt.Fatalf(\"LLM should not be called for handled command, calls=%d\", provider.calls)\n\t}\n\n\tfooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  baseMsg.Channel,\n\t\tSenderID: baseMsg.SenderID,\n\t\tChatID:   baseMsg.ChatID,\n\t\tContent:  \"/foo\",\n\t\tPeer:     baseMsg.Peer,\n\t})\n\tif fooResp != \"LLM reply\" {\n\t\tt.Fatalf(\"unexpected /foo reply: %q\", fooResp)\n\t}\n\tif provider.calls != 1 {\n\t\tt.Fatalf(\"LLM should be called exactly once after /foo passthrough, calls=%d\", provider.calls)\n\t}\n\n\tnewResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  baseMsg.Channel,\n\t\tSenderID: baseMsg.SenderID,\n\t\tChatID:   baseMsg.ChatID,\n\t\tContent:  \"/new\",\n\t\tPeer:     baseMsg.Peer,\n\t})\n\tif newResp != \"LLM reply\" {\n\t\tt.Fatalf(\"unexpected /new reply: %q\", newResp)\n\t}\n\tif provider.calls != 2 {\n\t\tt.Fatalf(\"LLM should be called for passthrough /new command, calls=%d\", provider.calls)\n\t}\n}\n\nfunc TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tProvider:          \"openai\",\n\t\t\t\tModel:             \"local\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{\n\t\t\t\tModelName: \"local\",\n\t\t\t\tModel:     \"openai/local-model\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPIBase:   \"https://local.example.invalid/v1\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tModelName: \"deepseek\",\n\t\t\t\tModel:     \"openrouter/deepseek/deepseek-v3.2\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPIBase:   \"https://openrouter.ai/api/v1\",\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &countingMockProvider{response: \"LLM reply\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\tswitchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"/switch model to deepseek\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif !strings.Contains(switchResp, \"Switched model from local to deepseek\") {\n\t\tt.Fatalf(\"unexpected /switch reply: %q\", switchResp)\n\t}\n\n\tshowResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"/show model\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif !strings.Contains(showResp, \"Current Model: deepseek (Provider: openrouter)\") {\n\t\tt.Fatalf(\"unexpected /show model reply after switch: %q\", showResp)\n\t}\n\n\tif provider.calls != 0 {\n\t\tt.Fatalf(\"LLM should not be called for /switch and /show, calls=%d\", provider.calls)\n\t}\n}\n\nfunc TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tProvider:          \"openai\",\n\t\t\t\tModel:             \"local\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{\n\t\t\t\tModelName: \"local\",\n\t\t\t\tModel:     \"openai/local-model\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPIBase:   \"https://local.example.invalid/v1\",\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &countingMockProvider{response: \"LLM reply\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\tswitchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"/switch model to missing\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif switchResp != `model \"missing\" not found in model_list or providers` {\n\t\tt.Fatalf(\"unexpected /switch error reply: %q\", switchResp)\n\t}\n\n\tshowResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"/show model\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif !strings.Contains(showResp, \"Current Model: local (Provider: openai)\") {\n\t\tt.Fatalf(\"unexpected /show model reply after rejected switch: %q\", showResp)\n\t}\n\n\tif provider.calls != 0 {\n\t\tt.Fatalf(\"LLM should not be called for rejected /switch and /show, calls=%d\", provider.calls)\n\t}\n}\n\nfunc TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tlocalCalls := 0\n\tlocalModel := \"\"\n\tlocalServer := newChatCompletionTestServer(t, \"local\", \"local reply\", &localCalls, &localModel)\n\tdefer localServer.Close()\n\n\tremoteCalls := 0\n\tremoteModel := \"\"\n\tremoteServer := newChatCompletionTestServer(t, \"remote\", \"remote reply\", &remoteCalls, &remoteModel)\n\tdefer remoteServer.Close()\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tProvider:          \"openai\",\n\t\t\t\tModel:             \"local\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t\tModelList: []config.ModelConfig{\n\t\t\t{\n\t\t\t\tModelName: \"local\",\n\t\t\t\tModel:     \"openai/Qwen3.5-35B-A3B\",\n\t\t\t\tAPIKey:    \"local-key\",\n\t\t\t\tAPIBase:   localServer.URL,\n\t\t\t},\n\t\t\t{\n\t\t\t\tModelName: \"deepseek\",\n\t\t\t\tModel:     \"openrouter/deepseek/deepseek-v3.2\",\n\t\t\t\tAPIKey:    \"remote-key\",\n\t\t\t\tAPIBase:   remoteServer.URL,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider, _, err := providers.CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider() error = %v\", err)\n\t}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\tfirstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"hello before switch\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif firstResp != \"local reply\" {\n\t\tt.Fatalf(\"unexpected response before switch: %q\", firstResp)\n\t}\n\tif localCalls != 1 {\n\t\tt.Fatalf(\"local calls before switch = %d, want 1\", localCalls)\n\t}\n\tif remoteCalls != 0 {\n\t\tt.Fatalf(\"remote calls before switch = %d, want 0\", remoteCalls)\n\t}\n\tif localModel != \"Qwen3.5-35B-A3B\" {\n\t\tt.Fatalf(\"local model before switch = %q, want %q\", localModel, \"Qwen3.5-35B-A3B\")\n\t}\n\n\tswitchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"/switch model to deepseek\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif !strings.Contains(switchResp, \"Switched model from local to deepseek\") {\n\t\tt.Fatalf(\"unexpected /switch reply: %q\", switchResp)\n\t}\n\n\tsecondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{\n\t\tChannel:  \"telegram\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"hello after switch\",\n\t\tPeer: bus.Peer{\n\t\t\tKind: \"direct\",\n\t\t\tID:   \"user1\",\n\t\t},\n\t})\n\tif secondResp != \"remote reply\" {\n\t\tt.Fatalf(\"unexpected response after switch: %q\", secondResp)\n\t}\n\tif localCalls != 1 {\n\t\tt.Fatalf(\"local calls after switch = %d, want 1\", localCalls)\n\t}\n\tif remoteCalls != 1 {\n\t\tt.Fatalf(\"remote calls after switch = %d, want 1\", remoteCalls)\n\t}\n\tif remoteModel != \"deepseek-v3.2\" {\n\t\tt.Fatalf(\n\t\t\t\"remote model after switch = %q, want %q\",\n\t\t\tremoteModel,\n\t\t\t\"deepseek-v3.2\",\n\t\t)\n\t}\n}\n\n// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound\nfunc TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &simpleMockProvider{response: \"File operation complete\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\t// ReadFileTool returns SilentResult, which should not send user message\n\tctx := context.Background()\n\tmsg := bus.InboundMessage{\n\t\tChannel:    \"test\",\n\t\tSenderID:   \"user1\",\n\t\tChatID:     \"chat1\",\n\t\tContent:    \"read test.txt\",\n\t\tSessionKey: \"test-session\",\n\t}\n\n\tresponse := helper.executeAndGetResponse(t, ctx, msg)\n\n\t// Silent tool should return the LLM's response directly\n\tif response != \"File operation complete\" {\n\t\tt.Errorf(\"Expected 'File operation complete', got: %s\", response)\n\t}\n}\n\n// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound\nfunc TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &simpleMockProvider{response: \"Command output: hello world\"}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\thelper := testHelper{al: al}\n\n\t// ExecTool returns UserResult, which should send user message\n\tctx := context.Background()\n\tmsg := bus.InboundMessage{\n\t\tChannel:    \"test\",\n\t\tSenderID:   \"user1\",\n\t\tChatID:     \"chat1\",\n\t\tContent:    \"run hello\",\n\t\tSessionKey: \"test-session\",\n\t}\n\n\tresponse := helper.executeAndGetResponse(t, ctx, msg)\n\n\t// User-facing tool should include the output in final response\n\tif response != \"Command output: hello world\" {\n\t\tt.Errorf(\"Expected 'Command output: hello world', got: %s\", response)\n\t}\n}\n\n// failFirstMockProvider fails on the first N calls with a specific error\ntype failFirstMockProvider struct {\n\tfailures    int\n\tcurrentCall int\n\tfailError   error\n\tsuccessResp string\n}\n\nfunc (m *failFirstMockProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\topts map[string]any,\n) (*providers.LLMResponse, error) {\n\tm.currentCall++\n\tif m.currentCall <= m.failures {\n\t\treturn nil, m.failError\n\t}\n\treturn &providers.LLMResponse{\n\t\tContent:   m.successResp,\n\t\tToolCalls: []providers.ToolCall{},\n\t}, nil\n}\n\nfunc (m *failFirstMockProvider) GetDefaultModel() string {\n\treturn \"mock-fail-model\"\n}\n\n// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors\nfunc TestAgentLoop_ContextExhaustionRetry(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\n\t// Create a provider that fails once with a context error\n\tcontextErr := fmt.Errorf(\"InvalidParameter: Total tokens of image and text exceed max message tokens\")\n\tprovider := &failFirstMockProvider{\n\t\tfailures:    1,\n\t\tfailError:   contextErr,\n\t\tsuccessResp: \"Recovered from context error\",\n\t}\n\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\n\t// Inject some history to simulate a full context\n\tsessionKey := \"test-session-context\"\n\t// Create dummy history\n\thistory := []providers.Message{\n\t\t{Role: \"system\", Content: \"System prompt\"},\n\t\t{Role: \"user\", Content: \"Old message 1\"},\n\t\t{Role: \"assistant\", Content: \"Old response 1\"},\n\t\t{Role: \"user\", Content: \"Old message 2\"},\n\t\t{Role: \"assistant\", Content: \"Old response 2\"},\n\t\t{Role: \"user\", Content: \"Trigger message\"},\n\t}\n\tdefaultAgent := al.registry.GetDefaultAgent()\n\tif defaultAgent == nil {\n\t\tt.Fatal(\"No default agent found\")\n\t}\n\tdefaultAgent.Sessions.SetHistory(sessionKey, history)\n\n\t// Call ProcessDirectWithChannel\n\t// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration\n\tresponse, err := al.ProcessDirectWithChannel(\n\t\tcontext.Background(),\n\t\t\"Trigger message\",\n\t\tsessionKey,\n\t\t\"test\",\n\t\t\"test-chat\",\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected success after retry, got error: %v\", err)\n\t}\n\n\tif response != \"Recovered from context error\" {\n\t\tt.Errorf(\"Expected 'Recovered from context error', got '%s'\", response)\n\t}\n\n\t// We expect 2 calls: 1st failed, 2nd succeeded\n\tif provider.currentCall != 2 {\n\t\tt.Errorf(\"Expected 2 calls (1 fail + 1 success), got %d\", provider.currentCall)\n\t}\n\n\t// Check final history length\n\tfinalHistory := defaultAgent.Sessions.GetHistory(sessionKey)\n\t// We verify that the history has been modified (compressed)\n\t// Original length: 6\n\t// Expected behavior: compression drops ~50% of history (mid slice)\n\t// We can assert that the length is NOT what it would be without compression.\n\t// Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8\n\tif len(finalHistory) >= 8 {\n\t\tt.Errorf(\"Expected history to be compressed (len < 8), got %d\", len(finalHistory))\n\t}\n}\n\n// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that\n// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.\n// Note: Manager is only initialized when at least one MCP server is configured\n// and successfully connected.\nfunc TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Test with MCP enabled but no servers - should not initialize manager\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t\tTools: config.ToolsConfig{\n\t\t\tMCP: config.MCPConfig{\n\t\t\t\tToolConfig: config.ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\t// No servers configured - manager should not be initialized\n\t\t\t},\n\t\t},\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tprovider := &mockProvider{}\n\tal := NewAgentLoop(cfg, msgBus, provider)\n\tdefer al.Close()\n\n\tif al.mcp.hasManager() {\n\t\tt.Fatal(\"expected MCP manager to be nil before first direct processing\")\n\t}\n\n\t_, err = al.ProcessDirectWithChannel(\n\t\tcontext.Background(),\n\t\t\"hello\",\n\t\t\"session-1\",\n\t\t\"cli\",\n\t\t\"direct\",\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"ProcessDirectWithChannel failed: %v\", err)\n\t}\n\n\t// Manager should not be initialized when no servers are configured\n\tif al.mcp.hasManager() {\n\t\tt.Fatal(\"expected MCP manager to be nil when no servers are configured\")\n\t}\n}\n\nfunc TestTargetReasoningChannelID_AllChannels(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcfg := &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\tModel:             \"test-model\",\n\t\t\t\tMaxTokens:         4096,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t},\n\t}\n\n\tal := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})\n\tchManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create channel manager: %v\", err)\n\t}\n\tfor name, id := range map[string]string{\n\t\t\"whatsapp\":  \"rid-whatsapp\",\n\t\t\"telegram\":  \"rid-telegram\",\n\t\t\"feishu\":    \"rid-feishu\",\n\t\t\"discord\":   \"rid-discord\",\n\t\t\"maixcam\":   \"rid-maixcam\",\n\t\t\"qq\":        \"rid-qq\",\n\t\t\"dingtalk\":  \"rid-dingtalk\",\n\t\t\"slack\":     \"rid-slack\",\n\t\t\"line\":      \"rid-line\",\n\t\t\"onebot\":    \"rid-onebot\",\n\t\t\"wecom\":     \"rid-wecom\",\n\t\t\"wecom_app\": \"rid-wecom-app\",\n\t} {\n\t\tchManager.RegisterChannel(name, &fakeChannel{id: id})\n\t}\n\tal.SetChannelManager(chManager)\n\ttests := []struct {\n\t\tchannel string\n\t\twantID  string\n\t}{\n\t\t{channel: \"whatsapp\", wantID: \"rid-whatsapp\"},\n\t\t{channel: \"telegram\", wantID: \"rid-telegram\"},\n\t\t{channel: \"feishu\", wantID: \"rid-feishu\"},\n\t\t{channel: \"discord\", wantID: \"rid-discord\"},\n\t\t{channel: \"maixcam\", wantID: \"rid-maixcam\"},\n\t\t{channel: \"qq\", wantID: \"rid-qq\"},\n\t\t{channel: \"dingtalk\", wantID: \"rid-dingtalk\"},\n\t\t{channel: \"slack\", wantID: \"rid-slack\"},\n\t\t{channel: \"line\", wantID: \"rid-line\"},\n\t\t{channel: \"onebot\", wantID: \"rid-onebot\"},\n\t\t{channel: \"wecom\", wantID: \"rid-wecom\"},\n\t\t{channel: \"wecom_app\", wantID: \"rid-wecom-app\"},\n\t\t{channel: \"unknown\", wantID: \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.channel, func(t *testing.T) {\n\t\t\tgot := al.targetReasoningChannelID(tt.channel)\n\t\t\tif got != tt.wantID {\n\t\t\t\tt.Fatalf(\"targetReasoningChannelID(%q) = %q, want %q\", tt.channel, got, tt.wantID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHandleReasoning(t *testing.T) {\n\tnewLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {\n\t\tt.Helper()\n\t\ttmpDir, err := os.MkdirTemp(\"\", \"agent-test-*\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t\t}\n\t\tt.Cleanup(func() { _ = os.RemoveAll(tmpDir) })\n\t\tcfg := &config.Config{\n\t\t\tAgents: config.AgentsConfig{\n\t\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\t\tWorkspace:         tmpDir,\n\t\t\t\t\tModel:             \"test-model\",\n\t\t\t\t\tMaxTokens:         4096,\n\t\t\t\t\tMaxToolIterations: 10,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tmsgBus := bus.NewMessageBus()\n\t\treturn NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus\n\t}\n\n\tt.Run(\"skips when any required field is empty\", func(t *testing.T) {\n\t\tal, msgBus := newLoop(t)\n\t\tal.handleReasoning(context.Background(), \"reasoning\", \"telegram\", \"\")\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase msg, ok := <-msgBus.OutboundChan():\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"expected no outbound message, got %+v\", msg)\n\t\t\t\t}\n\t\t\t\tif msg.Content == \"reasoning\" {\n\t\t\t\t\tt.Fatalf(\"expected no message for empty chatID, got %+v\", msg)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\tcase <-ctx.Done():\n\t\t\t\tt.Log(\"expected an outbound message, got none within timeout\")\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\t// Continue to check for message\n\t\t\t\ttime.Sleep(5 * time.Millisecond) // Avoid busy loop\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"publishes one message for non telegram\", func(t *testing.T) {\n\t\tal, msgBus := newLoop(t)\n\t\tal.handleReasoning(context.Background(), \"hello reasoning\", \"slack\", \"channel-1\")\n\n\t\tmsg, ok := <-msgBus.OutboundChan()\n\t\tif !ok {\n\t\t\tt.Fatal(\"expected an outbound message\")\n\t\t}\n\t\tif msg.Channel != \"slack\" || msg.ChatID != \"channel-1\" || msg.Content != \"hello reasoning\" {\n\t\t\tt.Fatalf(\"unexpected outbound message: %+v\", msg)\n\t\t}\n\t})\n\n\tt.Run(\"publishes one message for telegram\", func(t *testing.T) {\n\t\tal, msgBus := newLoop(t)\n\t\treasoning := \"hello telegram reasoning\"\n\t\tal.handleReasoning(context.Background(), reasoning, \"telegram\", \"tg-chat\")\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tt.Fatal(\"expected an outbound message, got none within timeout\")\n\t\t\t\treturn\n\t\t\tcase msg, ok := <-msgBus.OutboundChan():\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatal(\"expected outbound message\")\n\t\t\t\t}\n\n\t\t\t\tif msg.Channel != \"telegram\" {\n\t\t\t\t\tt.Fatalf(\"expected telegram channel message, got %+v\", msg)\n\t\t\t\t}\n\t\t\t\tif msg.ChatID != \"tg-chat\" {\n\t\t\t\t\tt.Fatalf(\"expected chatID tg-chat, got %+v\", msg)\n\t\t\t\t}\n\t\t\t\tif msg.Content != reasoning {\n\t\t\t\t\tt.Fatalf(\"content mismatch: got %q want %q\", msg.Content, reasoning)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n\tt.Run(\"expired ctx\", func(t *testing.T) {\n\t\tal, msgBus := newLoop(t)\n\t\treasoning := \"hello telegram reasoning\"\n\n\t\tal.handleReasoning(context.Background(), reasoning, \"telegram\", \"tg-chat\")\n\n\t\tconsumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer consumeCancel()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase msg, ok := <-msgBus.OutboundChan():\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"expected no outbound message, but received: %+v\", msg)\n\t\t\t\t}\n\t\t\t\tt.Logf(\"Received unexpected outbound message: %+v\", msg)\n\t\t\t\treturn\n\t\t\tcase <-consumeCtx.Done():\n\t\t\t\tt.Fatalf(\"failed: no message received within timeout\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"returns promptly when bus is full\", func(t *testing.T) {\n\t\tal, msgBus := newLoop(t)\n\n\t\t// Fill the outbound bus buffer until a publish would block.\n\t\t// Use a short timeout to detect when the buffer is full,\n\t\t// rather than hardcoding the buffer size.\n\t\tfor i := 0; ; i++ {\n\t\t\tfillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)\n\t\t\terr := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{\n\t\t\t\tChannel: \"filler\",\n\t\t\t\tChatID:  \"filler\",\n\t\t\t\tContent: fmt.Sprintf(\"filler-%d\", i),\n\t\t\t})\n\t\t\tfillCancel()\n\t\t\tif err != nil {\n\t\t\t\t// Buffer is full (timed out trying to send).\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Use a short-deadline parent context to bound the test.\n\t\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\t\tdefer cancel()\n\n\t\tstart := time.Now()\n\t\tal.handleReasoning(ctx, \"should timeout\", \"slack\", \"channel-full\")\n\t\telapsed := time.Since(start)\n\n\t\t// handleReasoning uses a 5s internal timeout, but the parent ctx\n\t\t// expires in 500ms. It should return within ~500ms, not 5s.\n\t\tif elapsed > 2*time.Second {\n\t\t\tt.Fatalf(\"handleReasoning blocked too long (%v); expected prompt return\", elapsed)\n\t\t}\n\n\t\t// Drain the bus and verify the reasoning message was NOT published\n\t\t// (it should have been dropped due to timeout).\n\t\ttimeer := time.After(1 * time.Second)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-timeer:\n\t\t\t\tt.Logf(\n\t\t\t\t\t\"no reasoning message received after draining bus for 1s, as expected,length=%d\",\n\t\t\t\t\tlen(msgBus.OutboundChan()),\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\tcase msg, ok := <-msgBus.OutboundChan():\n\t\t\t\tif !ok {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif msg.Content == \"should timeout\" {\n\t\t\t\t\tt.Fatal(\"expected reasoning message to be dropped when bus is full, but it was published\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\t// Create a minimal valid PNG (8-byte header is enough for filetype detection)\n\tpngPath := filepath.Join(dir, \"test.png\")\n\t// PNG magic: 0x89 P N G \\r \\n 0x1A \\n + minimal IHDR\n\tpngHeader := []byte{\n\t\t0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature\n\t\t0x00, 0x00, 0x00, 0x0D, // IHDR length\n\t\t0x49, 0x48, 0x44, 0x52, // \"IHDR\"\n\t\t0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB\n\t\t0x00, 0x00, 0x00, // no interlace\n\t\t0x90, 0x77, 0x53, 0xDE, // CRC\n\t}\n\tif err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tref, err := store.Store(pngPath, media.MediaMeta{}, \"test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"describe this\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 1 {\n\t\tt.Fatalf(\"expected 1 resolved media, got %d\", len(result[0].Media))\n\t}\n\tif !strings.HasPrefix(result[0].Media[0], \"data:image/png;base64,\") {\n\t\tt.Fatalf(\"expected data:image/png;base64, prefix, got %q\", result[0].Media[0][:40])\n\t}\n}\n\nfunc TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tbigPath := filepath.Join(dir, \"big.png\")\n\t// Write PNG header + padding to exceed limit\n\tdata := make([]byte, 1024+1) // 1KB + 1 byte\n\tcopy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})\n\tif err := os.WriteFile(bigPath, data, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tref, _ := store.Store(bigPath, media.MediaMeta{}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"hi\", Media: []string{ref}},\n\t}\n\t// Use a tiny limit (1KB) so the file is oversized\n\tresult := resolveMediaRefs(messages, store, 1024)\n\n\tif len(result[0].Media) != 0 {\n\t\tt.Fatalf(\"expected 0 media (oversized), got %d\", len(result[0].Media))\n\t}\n}\n\nfunc TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\ttxtPath := filepath.Join(dir, \"readme.txt\")\n\tif err := os.WriteFile(txtPath, []byte(\"hello world\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tref, _ := store.Store(txtPath, media.MediaMeta{}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"hi\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 0 {\n\t\tt.Fatalf(\"expected 0 media entries, got %d\", len(result[0].Media))\n\t}\n\texpected := \"hi [file:\" + txtPath + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"hi\", Media: []string{\"https://example.com/img.png\"}},\n\t}\n\tresult := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 1 || result[0].Media[0] != \"https://example.com/img.png\" {\n\t\tt.Fatalf(\"expected passthrough of non-media:// URL, got %v\", result[0].Media)\n\t}\n}\n\nfunc TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\tpngPath := filepath.Join(dir, \"test.png\")\n\tpngHeader := []byte{\n\t\t0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,\n\t\t0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,\n\t\t0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,\n\t\t0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,\n\t}\n\tos.WriteFile(pngPath, pngHeader, 0o644)\n\tref, _ := store.Store(pngPath, media.MediaMeta{}, \"test\")\n\n\toriginal := []providers.Message{\n\t\t{Role: \"user\", Content: \"hi\", Media: []string{ref}},\n\t}\n\toriginalRef := original[0].Media[0]\n\n\tresolveMediaRefs(original, store, config.DefaultMaxMediaSize)\n\n\tif original[0].Media[0] != originalRef {\n\t\tt.Fatal(\"resolveMediaRefs mutated original message slice\")\n\t}\n}\n\nfunc TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\t// File with JPEG content but stored with explicit content type\n\tjpegPath := filepath.Join(dir, \"photo\")\n\tjpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes\n\tos.WriteFile(jpegPath, jpegHeader, 0o644)\n\tref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: \"image/jpeg\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"hi\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 1 {\n\t\tt.Fatalf(\"expected 1 media, got %d\", len(result[0].Media))\n\t}\n\tif !strings.HasPrefix(result[0].Media[0], \"data:image/jpeg;base64,\") {\n\t\tt.Fatalf(\"expected jpeg prefix, got %q\", result[0].Media[0][:30])\n\t}\n}\n\nfunc TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tpdfPath := filepath.Join(dir, \"report.pdf\")\n\t// PDF magic bytes\n\tos.WriteFile(pdfPath, []byte(\"%PDF-1.4 test content\"), 0o644)\n\tref, _ := store.Store(pdfPath, media.MediaMeta{ContentType: \"application/pdf\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"report.pdf [file]\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 0 {\n\t\tt.Fatalf(\"expected 0 media (non-image), got %d\", len(result[0].Media))\n\t}\n\texpected := \"report.pdf [file:\" + pdfPath + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\toggPath := filepath.Join(dir, \"voice.ogg\")\n\tos.WriteFile(oggPath, []byte(\"fake audio\"), 0o644)\n\tref, _ := store.Store(oggPath, media.MediaMeta{ContentType: \"audio/ogg\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"voice.ogg [audio]\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 0 {\n\t\tt.Fatalf(\"expected 0 media, got %d\", len(result[0].Media))\n\t}\n\texpected := \"voice.ogg [audio:\" + oggPath + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tmp4Path := filepath.Join(dir, \"clip.mp4\")\n\tos.WriteFile(mp4Path, []byte(\"fake video\"), 0o644)\n\tref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: \"video/mp4\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"clip.mp4 [video]\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 0 {\n\t\tt.Fatalf(\"expected 0 media, got %d\", len(result[0].Media))\n\t}\n\texpected := \"clip.mp4 [video:\" + mp4Path + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tcsvPath := filepath.Join(dir, \"data.csv\")\n\tos.WriteFile(csvPath, []byte(\"a,b,c\"), 0o644)\n\tref, _ := store.Store(csvPath, media.MediaMeta{ContentType: \"text/csv\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"here is my data\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\texpected := \"here is my data [file:\" + csvPath + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tdocPath := filepath.Join(dir, \"doc.docx\")\n\tos.WriteFile(docPath, []byte(\"fake docx\"), 0o644)\n\tdocxMIME := \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n\tref, _ := store.Store(docPath, media.MediaMeta{ContentType: docxMIME}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"\", Media: []string{ref}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\texpected := \"[file:\" + docPath + \"]\"\n\tif result[0].Content != expected {\n\t\tt.Fatalf(\"expected content %q, got %q\", expected, result[0].Content)\n\t}\n}\n\nfunc TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\tdir := t.TempDir()\n\n\tpngPath := filepath.Join(dir, \"photo.png\")\n\tpngHeader := []byte{\n\t\t0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,\n\t\t0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,\n\t\t0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,\n\t\t0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,\n\t}\n\tos.WriteFile(pngPath, pngHeader, 0o644)\n\timgRef, _ := store.Store(pngPath, media.MediaMeta{}, \"test\")\n\n\tpdfPath := filepath.Join(dir, \"report.pdf\")\n\tos.WriteFile(pdfPath, []byte(\"%PDF-1.4 test\"), 0o644)\n\tfileRef, _ := store.Store(pdfPath, media.MediaMeta{ContentType: \"application/pdf\"}, \"test\")\n\n\tmessages := []providers.Message{\n\t\t{Role: \"user\", Content: \"check these [file]\", Media: []string{imgRef, fileRef}},\n\t}\n\tresult := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)\n\n\tif len(result[0].Media) != 1 {\n\t\tt.Fatalf(\"expected 1 media (image only), got %d\", len(result[0].Media))\n\t}\n\tif !strings.HasPrefix(result[0].Media[0], \"data:image/png;base64,\") {\n\t\tt.Fatal(\"expected image to be base64 encoded\")\n\t}\n\texpectedContent := \"check these [file:\" + pdfPath + \"]\"\n\tif result[0].Content != expectedContent {\n\t\tt.Fatalf(\"expected content %q, got %q\", expectedContent, result[0].Content)\n\t}\n}\n\n// --- Native search helper tests ---\n\ntype nativeSearchProvider struct {\n\tsupported bool\n}\n\nfunc (p *nativeSearchProvider) Chat(\n\tctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition,\n\tmodel string, opts map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn &providers.LLMResponse{Content: \"ok\"}, nil\n}\n\nfunc (p *nativeSearchProvider) GetDefaultModel() string { return \"test-model\" }\n\nfunc (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported }\n\ntype plainProvider struct{}\n\nfunc (p *plainProvider) Chat(\n\tctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition,\n\tmodel string, opts map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn &providers.LLMResponse{Content: \"ok\"}, nil\n}\n\nfunc (p *plainProvider) GetDefaultModel() string { return \"test-model\" }\n\nfunc TestIsNativeSearchProvider_Supported(t *testing.T) {\n\tif !isNativeSearchProvider(&nativeSearchProvider{supported: true}) {\n\t\tt.Fatal(\"expected true for provider that supports native search\")\n\t}\n}\n\nfunc TestIsNativeSearchProvider_NotSupported(t *testing.T) {\n\tif isNativeSearchProvider(&nativeSearchProvider{supported: false}) {\n\t\tt.Fatal(\"expected false for provider that does not support native search\")\n\t}\n}\n\nfunc TestIsNativeSearchProvider_NoInterface(t *testing.T) {\n\tif isNativeSearchProvider(&plainProvider{}) {\n\t\tt.Fatal(\"expected false for provider that does not implement NativeSearchCapable\")\n\t}\n}\n\nfunc TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) {\n\tdefs := []providers.ToolDefinition{\n\t\t{Type: \"function\", Function: providers.ToolFunctionDefinition{Name: \"web_search\"}},\n\t\t{Type: \"function\", Function: providers.ToolFunctionDefinition{Name: \"read_file\"}},\n\t\t{Type: \"function\", Function: providers.ToolFunctionDefinition{Name: \"exec\"}},\n\t}\n\tresult := filterClientWebSearch(defs)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n\tfor _, td := range result {\n\t\tif td.Function.Name == \"web_search\" {\n\t\t\tt.Fatal(\"web_search should be filtered out\")\n\t\t}\n\t}\n}\n\nfunc TestFilterClientWebSearch_NoWebSearch(t *testing.T) {\n\tdefs := []providers.ToolDefinition{\n\t\t{Type: \"function\", Function: providers.ToolFunctionDefinition{Name: \"read_file\"}},\n\t\t{Type: \"function\", Function: providers.ToolFunctionDefinition{Name: \"exec\"}},\n\t}\n\tresult := filterClientWebSearch(defs)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n}\n\nfunc TestFilterClientWebSearch_EmptyInput(t *testing.T) {\n\tresult := filterClientWebSearch(nil)\n\tif len(result) != 0 {\n\t\tt.Fatalf(\"len(result) = %d, want 0\", len(result))\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/memory.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage agent\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n)\n\n// MemoryStore manages persistent memory for the agent.\n// - Long-term memory: memory/MEMORY.md\n// - Daily notes: memory/YYYYMM/YYYYMMDD.md\ntype MemoryStore struct {\n\tworkspace  string\n\tmemoryDir  string\n\tmemoryFile string\n}\n\n// NewMemoryStore creates a new MemoryStore with the given workspace path.\n// It ensures the memory directory exists.\nfunc NewMemoryStore(workspace string) *MemoryStore {\n\tmemoryDir := filepath.Join(workspace, \"memory\")\n\tmemoryFile := filepath.Join(memoryDir, \"MEMORY.md\")\n\n\t// Ensure memory directory exists\n\tos.MkdirAll(memoryDir, 0o755)\n\n\treturn &MemoryStore{\n\t\tworkspace:  workspace,\n\t\tmemoryDir:  memoryDir,\n\t\tmemoryFile: memoryFile,\n\t}\n}\n\n// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).\nfunc (ms *MemoryStore) getTodayFile() string {\n\ttoday := time.Now().Format(\"20060102\") // YYYYMMDD\n\tmonthDir := today[:6]                  // YYYYMM\n\tfilePath := filepath.Join(ms.memoryDir, monthDir, today+\".md\")\n\treturn filePath\n}\n\n// ReadLongTerm reads the long-term memory (MEMORY.md).\n// Returns empty string if the file doesn't exist.\nfunc (ms *MemoryStore) ReadLongTerm() string {\n\tif data, err := os.ReadFile(ms.memoryFile); err == nil {\n\t\treturn string(data)\n\t}\n\treturn \"\"\n}\n\n// WriteLongTerm writes content to the long-term memory file (MEMORY.md).\nfunc (ms *MemoryStore) WriteLongTerm(content string) error {\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\t// Using 0o600 (owner read/write only) for secure default permissions.\n\treturn fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)\n}\n\n// ReadToday reads today's daily note.\n// Returns empty string if the file doesn't exist.\nfunc (ms *MemoryStore) ReadToday() string {\n\ttodayFile := ms.getTodayFile()\n\tif data, err := os.ReadFile(todayFile); err == nil {\n\t\treturn string(data)\n\t}\n\treturn \"\"\n}\n\n// AppendToday appends content to today's daily note.\n// If the file doesn't exist, it creates a new file with a date header.\nfunc (ms *MemoryStore) AppendToday(content string) error {\n\ttodayFile := ms.getTodayFile()\n\n\t// Ensure month directory exists\n\tmonthDir := filepath.Dir(todayFile)\n\tif err := os.MkdirAll(monthDir, 0o755); err != nil {\n\t\treturn err\n\t}\n\n\tvar existingContent string\n\tif data, err := os.ReadFile(todayFile); err == nil {\n\t\texistingContent = string(data)\n\t}\n\n\tvar newContent string\n\tif existingContent == \"\" {\n\t\t// Add header for new day\n\t\theader := fmt.Sprintf(\"# %s\\n\\n\", time.Now().Format(\"2006-01-02\"))\n\t\tnewContent = header + content\n\t} else {\n\t\t// Append to existing content\n\t\tnewContent = existingContent + \"\\n\" + content\n\t}\n\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\treturn fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)\n}\n\n// GetRecentDailyNotes returns daily notes from the last N days.\n// Contents are joined with \"---\" separator.\nfunc (ms *MemoryStore) GetRecentDailyNotes(days int) string {\n\tvar sb strings.Builder\n\tfirst := true\n\n\tfor i := range days {\n\t\tdate := time.Now().AddDate(0, 0, -i)\n\t\tdateStr := date.Format(\"20060102\") // YYYYMMDD\n\t\tmonthDir := dateStr[:6]            // YYYYMM\n\t\tfilePath := filepath.Join(ms.memoryDir, monthDir, dateStr+\".md\")\n\n\t\tif data, err := os.ReadFile(filePath); err == nil {\n\t\t\tif !first {\n\t\t\t\tsb.WriteString(\"\\n\\n---\\n\\n\")\n\t\t\t}\n\t\t\tsb.Write(data)\n\t\t\tfirst = false\n\t\t}\n\t}\n\n\treturn sb.String()\n}\n\n// GetMemoryContext returns formatted memory context for the agent prompt.\n// Includes long-term memory and recent daily notes.\nfunc (ms *MemoryStore) GetMemoryContext() string {\n\tlongTerm := ms.ReadLongTerm()\n\trecentNotes := ms.GetRecentDailyNotes(3)\n\n\tif longTerm == \"\" && recentNotes == \"\" {\n\t\treturn \"\"\n\t}\n\n\tvar sb strings.Builder\n\n\tif longTerm != \"\" {\n\t\tsb.WriteString(\"## Long-term Memory\\n\\n\")\n\t\tsb.WriteString(longTerm)\n\t}\n\n\tif recentNotes != \"\" {\n\t\tif longTerm != \"\" {\n\t\t\tsb.WriteString(\"\\n\\n---\\n\\n\")\n\t\t}\n\t\tsb.WriteString(\"## Recent Daily Notes\\n\\n\")\n\t\tsb.WriteString(recentNotes)\n\t}\n\n\treturn sb.String()\n}\n"
  },
  {
    "path": "pkg/agent/mock_provider_test.go",
    "content": "package agent\n\nimport (\n\t\"context\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\ntype mockProvider struct{}\n\nfunc (m *mockProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\topts map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn &providers.LLMResponse{\n\t\tContent:   \"Mock response\",\n\t\tToolCalls: []providers.ToolCall{},\n\t}, nil\n}\n\nfunc (m *mockProvider) GetDefaultModel() string {\n\treturn \"mock-model\"\n}\n"
  },
  {
    "path": "pkg/agent/model_resolution.go",
    "content": "package agent\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nfunc buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) {\n\tensureProtocol := func(model string) string {\n\t\tmodel = strings.TrimSpace(model)\n\t\tif model == \"\" {\n\t\t\treturn \"\"\n\t\t}\n\t\tif strings.Contains(model, \"/\") {\n\t\t\treturn model\n\t\t}\n\t\treturn \"openai/\" + model\n\t}\n\n\treturn func(raw string) (string, bool) {\n\t\traw = strings.TrimSpace(raw)\n\t\tif raw == \"\" || cfg == nil {\n\t\t\treturn \"\", false\n\t\t}\n\n\t\tif mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != \"\" {\n\t\t\treturn ensureProtocol(mc.Model), true\n\t\t}\n\n\t\tfor i := range cfg.ModelList {\n\t\t\tfullModel := strings.TrimSpace(cfg.ModelList[i].Model)\n\t\t\tif fullModel == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif fullModel == raw {\n\t\t\t\treturn ensureProtocol(fullModel), true\n\t\t\t}\n\t\t\t_, modelID := providers.ExtractProtocol(fullModel)\n\t\t\tif modelID == raw {\n\t\t\t\treturn ensureProtocol(fullModel), true\n\t\t\t}\n\t\t}\n\n\t\treturn \"\", false\n\t}\n}\n\nfunc resolveModelCandidates(\n\tcfg *config.Config,\n\tdefaultProvider string,\n\tprimary string,\n\tfallbacks []string,\n) []providers.FallbackCandidate {\n\treturn providers.ResolveCandidatesWithLookup(\n\t\tproviders.ModelConfig{\n\t\t\tPrimary:   primary,\n\t\t\tFallbacks: fallbacks,\n\t\t},\n\t\tdefaultProvider,\n\t\tbuildModelListResolver(cfg),\n\t)\n}\n\nfunc resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {\n\tif len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != \"\" {\n\t\treturn candidates[0].Model\n\t}\n\treturn fallback\n}\n\nfunc resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string {\n\tif len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != \"\" {\n\t\treturn candidates[0].Provider\n\t}\n\treturn fallback\n}\n\nfunc resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"config is nil\")\n\t}\n\n\tmodelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclone := *modelCfg\n\tif clone.Workspace == \"\" {\n\t\tclone.Workspace = workspace\n\t}\n\n\treturn &clone, nil\n}\n"
  },
  {
    "path": "pkg/agent/registry.go",
    "content": "package agent\n\nimport (\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/routing\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\n// AgentRegistry manages multiple agent instances and routes messages to them.\ntype AgentRegistry struct {\n\tagents   map[string]*AgentInstance\n\tresolver *routing.RouteResolver\n\tmu       sync.RWMutex\n}\n\n// NewAgentRegistry creates a registry from config, instantiating all agents.\nfunc NewAgentRegistry(\n\tcfg *config.Config,\n\tprovider providers.LLMProvider,\n) *AgentRegistry {\n\tregistry := &AgentRegistry{\n\t\tagents:   make(map[string]*AgentInstance),\n\t\tresolver: routing.NewRouteResolver(cfg),\n\t}\n\n\tagentConfigs := cfg.Agents.List\n\tif len(agentConfigs) == 0 {\n\t\timplicitAgent := &config.AgentConfig{\n\t\t\tID:      \"main\",\n\t\t\tDefault: true,\n\t\t}\n\t\tinstance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)\n\t\tregistry.agents[\"main\"] = instance\n\t\tlogger.InfoCF(\"agent\", \"Created implicit main agent (no agents.list configured)\", nil)\n\t} else {\n\t\tfor i := range agentConfigs {\n\t\t\tac := &agentConfigs[i]\n\t\t\tid := routing.NormalizeAgentID(ac.ID)\n\t\t\tinstance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)\n\t\t\tregistry.agents[id] = instance\n\t\t\tlogger.InfoCF(\"agent\", \"Registered agent\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"agent_id\":  id,\n\t\t\t\t\t\"name\":      ac.Name,\n\t\t\t\t\t\"workspace\": instance.Workspace,\n\t\t\t\t\t\"model\":     instance.Model,\n\t\t\t\t})\n\t\t}\n\t}\n\n\treturn registry\n}\n\n// GetAgent returns the agent instance for a given ID.\nfunc (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tid := routing.NormalizeAgentID(agentID)\n\tagent, ok := r.agents[id]\n\treturn agent, ok\n}\n\n// ResolveRoute determines which agent handles the message.\nfunc (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {\n\treturn r.resolver.ResolveRoute(input)\n}\n\n// ListAgentIDs returns all registered agent IDs.\nfunc (r *AgentRegistry) ListAgentIDs() []string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tids := make([]string, 0, len(r.agents))\n\tfor id := range r.agents {\n\t\tids = append(ids, id)\n\t}\n\treturn ids\n}\n\n// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.\nfunc (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {\n\tparent, ok := r.GetAgent(parentAgentID)\n\tif !ok {\n\t\treturn false\n\t}\n\tif parent.Subagents == nil || parent.Subagents.AllowAgents == nil {\n\t\treturn false\n\t}\n\ttargetNorm := routing.NormalizeAgentID(targetAgentID)\n\tfor _, allowed := range parent.Subagents.AllowAgents {\n\t\tif allowed == \"*\" {\n\t\t\treturn true\n\t\t}\n\t\tif routing.NormalizeAgentID(allowed) == targetNorm {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ForEachTool calls fn for every tool registered under the given name\n// across all agents. This is useful for propagating dependencies (e.g.\n// MediaStore) to tools after registry construction.\nfunc (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tfor _, agent := range r.agents {\n\t\tif t, ok := agent.Tools.Get(name); ok {\n\t\t\tfn(t)\n\t\t}\n\t}\n}\n\n// Close releases resources held by all registered agents.\nfunc (r *AgentRegistry) Close() {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tfor _, agent := range r.agents {\n\t\tif err := agent.Close(); err != nil {\n\t\t\tlogger.WarnCF(\"agent\", \"Failed to close agent\",\n\t\t\t\tmap[string]any{\"agent_id\": agent.ID, \"error\": err.Error()})\n\t\t}\n\t}\n}\n\n// GetDefaultAgent returns the default agent instance.\nfunc (r *AgentRegistry) GetDefaultAgent() *AgentInstance {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tif agent, ok := r.agents[\"main\"]; ok {\n\t\treturn agent\n\t}\n\tfor _, agent := range r.agents {\n\t\treturn agent\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/agent/registry_test.go",
    "content": "package agent\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\ntype mockRegistryProvider struct{}\n\nfunc (m *mockRegistryProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn &providers.LLMResponse{Content: \"mock\", FinishReason: \"stop\"}, nil\n}\n\nfunc (m *mockRegistryProvider) GetDefaultModel() string {\n\treturn \"mock-model\"\n}\n\nfunc testCfg(agents []config.AgentConfig) *config.Config {\n\treturn &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace:         \"/tmp/picoclaw-test-registry\",\n\t\t\t\tModel:             \"gpt-4\",\n\t\t\t\tMaxTokens:         8192,\n\t\t\t\tMaxToolIterations: 10,\n\t\t\t},\n\t\t\tList: agents,\n\t\t},\n\t}\n}\n\nfunc TestNewAgentRegistry_ImplicitMain(t *testing.T) {\n\tcfg := testCfg(nil)\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tids := registry.ListAgentIDs()\n\tif len(ids) != 1 || ids[0] != \"main\" {\n\t\tt.Errorf(\"expected implicit main agent, got %v\", ids)\n\t}\n\n\tagent, ok := registry.GetAgent(\"main\")\n\tif !ok || agent == nil {\n\t\tt.Fatal(\"expected to find 'main' agent\")\n\t}\n\tif agent.ID != \"main\" {\n\t\tt.Errorf(\"agent.ID = %q, want 'main'\", agent.ID)\n\t}\n}\n\nfunc TestNewAgentRegistry_ExplicitAgents(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"sales\", Default: true, Name: \"Sales Bot\"},\n\t\t{ID: \"support\", Name: \"Support Bot\"},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tids := registry.ListAgentIDs()\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"expected 2 agents, got %d: %v\", len(ids), ids)\n\t}\n\n\tsales, ok := registry.GetAgent(\"sales\")\n\tif !ok || sales == nil {\n\t\tt.Fatal(\"expected to find 'sales' agent\")\n\t}\n\tif sales.Name != \"Sales Bot\" {\n\t\tt.Errorf(\"sales.Name = %q, want 'Sales Bot'\", sales.Name)\n\t}\n\n\tsupport, ok := registry.GetAgent(\"support\")\n\tif !ok || support == nil {\n\t\tt.Fatal(\"expected to find 'support' agent\")\n\t}\n}\n\nfunc TestAgentRegistry_GetAgent_Normalize(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"my-agent\", Default: true},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tagent, ok := registry.GetAgent(\"My-Agent\")\n\tif !ok || agent == nil {\n\t\tt.Fatal(\"expected to find agent with normalized ID\")\n\t}\n\tif agent.ID != \"my-agent\" {\n\t\tt.Errorf(\"agent.ID = %q, want 'my-agent'\", agent.ID)\n\t}\n}\n\nfunc TestAgentRegistry_GetDefaultAgent(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"alpha\"},\n\t\t{ID: \"beta\", Default: true},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\t// GetDefaultAgent first checks for \"main\", then returns any\n\tagent := registry.GetDefaultAgent()\n\tif agent == nil {\n\t\tt.Fatal(\"expected a default agent\")\n\t}\n}\n\nfunc TestAgentRegistry_CanSpawnSubagent(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{\n\t\t\tID:      \"parent\",\n\t\t\tDefault: true,\n\t\t\tSubagents: &config.SubagentsConfig{\n\t\t\t\tAllowAgents: []string{\"child1\", \"child2\"},\n\t\t\t},\n\t\t},\n\t\t{ID: \"child1\"},\n\t\t{ID: \"child2\"},\n\t\t{ID: \"restricted\"},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tif !registry.CanSpawnSubagent(\"parent\", \"child1\") {\n\t\tt.Error(\"expected parent to be allowed to spawn child1\")\n\t}\n\tif !registry.CanSpawnSubagent(\"parent\", \"child2\") {\n\t\tt.Error(\"expected parent to be allowed to spawn child2\")\n\t}\n\tif registry.CanSpawnSubagent(\"parent\", \"restricted\") {\n\t\tt.Error(\"expected parent to NOT be allowed to spawn restricted\")\n\t}\n\tif registry.CanSpawnSubagent(\"child1\", \"child2\") {\n\t\tt.Error(\"expected child1 to NOT be allowed to spawn (no subagents config)\")\n\t}\n}\n\nfunc TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{\n\t\t\tID:      \"admin\",\n\t\t\tDefault: true,\n\t\t\tSubagents: &config.SubagentsConfig{\n\t\t\t\tAllowAgents: []string{\"*\"},\n\t\t\t},\n\t\t},\n\t\t{ID: \"any-agent\"},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tif !registry.CanSpawnSubagent(\"admin\", \"any-agent\") {\n\t\tt.Error(\"expected wildcard to allow spawning any agent\")\n\t}\n\tif !registry.CanSpawnSubagent(\"admin\", \"nonexistent\") {\n\t\tt.Error(\"expected wildcard to allow spawning even nonexistent agents\")\n\t}\n}\n\nfunc TestAgentInstance_Model(t *testing.T) {\n\tmodel := &config.AgentModelConfig{Primary: \"claude-opus\"}\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"custom\", Default: true, Model: model},\n\t})\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tagent, _ := registry.GetAgent(\"custom\")\n\tif agent.Model != \"claude-opus\" {\n\t\tt.Errorf(\"agent.Model = %q, want 'claude-opus'\", agent.Model)\n\t}\n}\n\nfunc TestAgentInstance_FallbackInheritance(t *testing.T) {\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"inherit\", Default: true},\n\t})\n\tcfg.Agents.Defaults.ModelFallbacks = []string{\"openai/gpt-4o-mini\", \"anthropic/haiku\"}\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tagent, _ := registry.GetAgent(\"inherit\")\n\tif len(agent.Fallbacks) != 2 {\n\t\tt.Errorf(\"expected 2 fallbacks inherited from defaults, got %d\", len(agent.Fallbacks))\n\t}\n}\n\nfunc TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {\n\tmodel := &config.AgentModelConfig{\n\t\tPrimary:   \"gpt-4\",\n\t\tFallbacks: []string{}, // explicitly empty = disable\n\t}\n\tcfg := testCfg([]config.AgentConfig{\n\t\t{ID: \"no-fallback\", Default: true, Model: model},\n\t})\n\tcfg.Agents.Defaults.ModelFallbacks = []string{\"should-not-inherit\"}\n\tregistry := NewAgentRegistry(cfg, &mockRegistryProvider{})\n\n\tagent, _ := registry.GetAgent(\"no-fallback\")\n\tif len(agent.Fallbacks) != 0 {\n\t\tt.Errorf(\"expected 0 fallbacks (explicit empty), got %d: %v\", len(agent.Fallbacks), agent.Fallbacks)\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/thinking.go",
    "content": "package agent\n\nimport \"strings\"\n\n// ThinkingLevel controls how the provider sends thinking parameters.\n//\n//   - \"adaptive\": sends {thinking: {type: \"adaptive\"}} + output_config.effort (Claude 4.6+)\n//   - \"low\"/\"medium\"/\"high\"/\"xhigh\": sends {thinking: {type: \"enabled\", budget_tokens: N}} (all models)\n//   - \"off\": disables thinking\ntype ThinkingLevel string\n\nconst (\n\tThinkingOff      ThinkingLevel = \"off\"\n\tThinkingLow      ThinkingLevel = \"low\"\n\tThinkingMedium   ThinkingLevel = \"medium\"\n\tThinkingHigh     ThinkingLevel = \"high\"\n\tThinkingXHigh    ThinkingLevel = \"xhigh\"\n\tThinkingAdaptive ThinkingLevel = \"adaptive\"\n)\n\n// parseThinkingLevel normalizes a config string to a ThinkingLevel.\n// Case-insensitive and whitespace-tolerant for user-facing config values.\n// Returns ThinkingOff for unknown or empty values.\nfunc parseThinkingLevel(level string) ThinkingLevel {\n\tswitch strings.ToLower(strings.TrimSpace(level)) {\n\tcase \"adaptive\":\n\t\treturn ThinkingAdaptive\n\tcase \"low\":\n\t\treturn ThinkingLow\n\tcase \"medium\":\n\t\treturn ThinkingMedium\n\tcase \"high\":\n\t\treturn ThinkingHigh\n\tcase \"xhigh\":\n\t\treturn ThinkingXHigh\n\tdefault:\n\t\treturn ThinkingOff\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/thinking_test.go",
    "content": "package agent\n\nimport \"testing\"\n\nfunc TestParseThinkingLevel(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t\twant  ThinkingLevel\n\t}{\n\t\t{\"off\", \"off\", ThinkingOff},\n\t\t{\"empty\", \"\", ThinkingOff},\n\t\t{\"low\", \"low\", ThinkingLow},\n\t\t{\"medium\", \"medium\", ThinkingMedium},\n\t\t{\"high\", \"high\", ThinkingHigh},\n\t\t{\"xhigh\", \"xhigh\", ThinkingXHigh},\n\t\t{\"adaptive\", \"adaptive\", ThinkingAdaptive},\n\t\t{\"unknown\", \"unknown\", ThinkingOff},\n\t\t// Case-insensitive and whitespace-tolerant\n\t\t{\"upper_Medium\", \"Medium\", ThinkingMedium},\n\t\t{\"upper_HIGH\", \"HIGH\", ThinkingHigh},\n\t\t{\"mixed_Adaptive\", \"Adaptive\", ThinkingAdaptive},\n\t\t{\"leading_space\", \" high\", ThinkingHigh},\n\t\t{\"trailing_space\", \"low \", ThinkingLow},\n\t\t{\"both_spaces\", \" medium \", ThinkingMedium},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := parseThinkingLevel(tt.input); got != tt.want {\n\t\t\t\tt.Errorf(\"parseThinkingLevel(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/anthropic_usage.go",
    "content": "package auth\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\nconst (\n\tanthropicBetaHeader = \"oauth-2025-04-20\"\n\tanthropicAPIVersion = \"2023-06-01\"\n)\n\n// anthropicUsageURL is the endpoint for fetching OAuth usage stats.\n// It is a var (not const) to allow overriding in tests.\nvar anthropicUsageURL = \"https://api.anthropic.com/api/oauth/usage\"\n\nfunc setAnthropicUsageURL(url string) { anthropicUsageURL = url }\n\ntype AnthropicUsage struct {\n\tFiveHourUtilization float64\n\tSevenDayUtilization float64\n}\n\nfunc FetchAnthropicUsage(token string) (*AnthropicUsage, error) {\n\treq, err := http.NewRequest(\"GET\", anthropicUsageURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\treq.Header.Set(\"Anthropic-Version\", anthropicAPIVersion)\n\treq.Header.Set(\"Anthropic-Beta\", anthropicBetaHeader)\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading usage response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tif resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, fmt.Errorf(\"insufficient scope: usage endpoint requires oauth scope\")\n\t\t}\n\t\treturn nil, fmt.Errorf(\"usage request failed (%d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar result struct {\n\t\tFiveHour struct {\n\t\t\tUtilization float64 `json:\"utilization\"`\n\t\t} `json:\"five_hour\"`\n\t\tSevenDay struct {\n\t\t\tUtilization float64 `json:\"utilization\"`\n\t\t} `json:\"seven_day\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing usage response: %w\", err)\n\t}\n\n\treturn &AnthropicUsage{\n\t\tFiveHourUtilization: result.FiveHour.Utilization,\n\t\tSevenDayUtilization: result.SevenDay.Utilization,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/auth/anthropic_usage_test.go",
    "content": "package auth\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestFetchAnthropicUsage_Success(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer test-token\" {\n\t\t\tt.Errorf(\"Authorization = %q, want %q\", got, \"Bearer test-token\")\n\t\t}\n\t\tif got := r.Header.Get(\"Anthropic-Beta\"); got != anthropicBetaHeader {\n\t\t\tt.Errorf(\"Anthropic-Beta = %q, want %q\", got, anthropicBetaHeader)\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(`{\"five_hour\":{\"utilization\":0.42},\"seven_day\":{\"utilization\":0.85}}`))\n\t}))\n\tdefer srv.Close()\n\n\t// Temporarily override the URL by using the test server\n\torigURL := anthropicUsageURL\n\tdefer func() { setAnthropicUsageURL(origURL) }()\n\tsetAnthropicUsageURL(srv.URL)\n\n\tusage, err := FetchAnthropicUsage(\"test-token\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif usage.FiveHourUtilization != 0.42 {\n\t\tt.Errorf(\"FiveHourUtilization = %v, want 0.42\", usage.FiveHourUtilization)\n\t}\n\tif usage.SevenDayUtilization != 0.85 {\n\t\tt.Errorf(\"SevenDayUtilization = %v, want 0.85\", usage.SevenDayUtilization)\n\t}\n}\n\nfunc TestFetchAnthropicUsage_Forbidden(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusForbidden)\n\t\tw.Write([]byte(`{\"error\":\"forbidden\"}`))\n\t}))\n\tdefer srv.Close()\n\n\torigURL := anthropicUsageURL\n\tdefer func() { setAnthropicUsageURL(origURL) }()\n\tsetAnthropicUsageURL(srv.URL)\n\n\t_, err := FetchAnthropicUsage(\"test-token\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for 403, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"insufficient scope\") {\n\t\tt.Errorf(\"expected 'insufficient scope' error, got %q\", err.Error())\n\t}\n}\n\nfunc TestFetchAnthropicUsage_ServerError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\tw.Write([]byte(`internal error`))\n\t}))\n\tdefer srv.Close()\n\n\torigURL := anthropicUsageURL\n\tdefer func() { setAnthropicUsageURL(origURL) }()\n\tsetAnthropicUsageURL(srv.URL)\n\n\t_, err := FetchAnthropicUsage(\"test-token\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for 500, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"500\") {\n\t\tt.Errorf(\"expected error containing '500', got %q\", err.Error())\n\t}\n}\n\nfunc TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(`not json`))\n\t}))\n\tdefer srv.Close()\n\n\torigURL := anthropicUsageURL\n\tdefer func() { setAnthropicUsageURL(origURL) }()\n\tsetAnthropicUsageURL(srv.URL)\n\n\t_, err := FetchAnthropicUsage(\"test-token\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for malformed JSON, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"parsing usage response\") {\n\t\tt.Errorf(\"expected 'parsing usage response' error, got %q\", err.Error())\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/oauth.go",
    "content": "package auth\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype OAuthProviderConfig struct {\n\tIssuer       string\n\tClientID     string\n\tClientSecret string // Required for Google OAuth (confidential client)\n\tTokenURL     string // Override token endpoint (Google uses a different URL than issuer)\n\tScopes       string\n\tOriginator   string\n\tPort         int\n}\n\nfunc OpenAIOAuthConfig() OAuthProviderConfig {\n\treturn OAuthProviderConfig{\n\t\tIssuer:     \"https://auth.openai.com\",\n\t\tClientID:   \"app_EMoamEEZ73f0CkXaXp7hrann\",\n\t\tScopes:     \"openid profile email offline_access\",\n\t\tOriginator: \"codex_cli_rs\",\n\t\tPort:       1455,\n\t}\n}\n\n// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).\n// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.\nfunc GoogleAntigravityOAuthConfig() OAuthProviderConfig {\n\t// These are the same client credentials used by the OpenCode antigravity plugin.\n\tclientID := decodeBase64(\n\t\t\"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==\",\n\t)\n\tclientSecret := decodeBase64(\"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=\")\n\treturn OAuthProviderConfig{\n\t\tIssuer:       \"https://accounts.google.com/o/oauth2/v2\",\n\t\tTokenURL:     \"https://oauth2.googleapis.com/token\",\n\t\tClientID:     clientID,\n\t\tClientSecret: clientSecret,\n\t\tScopes:       \"https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs\",\n\t\tPort:         51121,\n\t}\n}\n\nfunc decodeBase64(s string) string {\n\tdata, err := base64.StdEncoding.DecodeString(s)\n\tif err != nil {\n\t\treturn s\n\t}\n\treturn string(data)\n}\n\n// GenerateState generates a random state string for OAuth CSRF protection.\nfunc GenerateState() (string, error) {\n\tbuf := make([]byte, 32)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn hex.EncodeToString(buf), nil\n}\n\nfunc LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {\n\tpkce, err := GeneratePKCE()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"generating PKCE: %w\", err)\n\t}\n\n\tstate, err := GenerateState()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"generating state: %w\", err)\n\t}\n\n\tredirectURI := fmt.Sprintf(\"http://localhost:%d/auth/callback\", cfg.Port)\n\n\tauthURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)\n\n\tresultCh := make(chan callbackResult, 1)\n\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/auth/callback\", func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"state\") != state {\n\t\t\tresultCh <- callbackResult{err: fmt.Errorf(\"state mismatch\")}\n\t\t\thttp.Error(w, \"State mismatch\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tcode := r.URL.Query().Get(\"code\")\n\t\tif code == \"\" {\n\t\t\terrMsg := r.URL.Query().Get(\"error\")\n\t\t\tresultCh <- callbackResult{err: fmt.Errorf(\"no code received: %s\", errMsg)}\n\t\t\thttp.Error(w, \"No authorization code received\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tfmt.Fprint(w, \"<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>\")\n\t\tresultCh <- callbackResult{code: code}\n\t})\n\n\tlistener, err := net.Listen(\"tcp\", fmt.Sprintf(\"127.0.0.1:%d\", cfg.Port))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"starting callback server on port %d: %w\", cfg.Port, err)\n\t}\n\n\tserver := &http.Server{Handler: mux}\n\tgo server.Serve(listener)\n\tdefer func() {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\tdefer cancel()\n\t\tserver.Shutdown(ctx)\n\t}()\n\n\tfmt.Printf(\"Open this URL to authenticate:\\n\\n%s\\n\\n\", authURL)\n\n\tif err := OpenBrowser(authURL); err != nil {\n\t\tfmt.Printf(\"Could not open browser automatically.\\nPlease open this URL manually:\\n\\n%s\\n\\n\", authURL)\n\t}\n\n\tfmt.Printf(\n\t\t\"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\\n\",\n\t\tcfg.Port,\n\t)\n\tfmt.Println(\n\t\t\"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.\",\n\t)\n\tfmt.Println(\"Waiting for authentication (browser or manual paste)...\")\n\n\t// Start manual input in a goroutine\n\tmanualCh := make(chan string)\n\tgo func() {\n\t\treader := bufio.NewReader(os.Stdin)\n\t\tinput, _ := reader.ReadString('\\n')\n\t\tmanualCh <- strings.TrimSpace(input)\n\t}()\n\n\tselect {\n\tcase result := <-resultCh:\n\t\tif result.err != nil {\n\t\t\treturn nil, result.err\n\t\t}\n\t\treturn ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)\n\tcase manualInput := <-manualCh:\n\t\tif manualInput == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"manual input canceled\")\n\t\t}\n\t\t// Extract code from URL if it's a full URL\n\t\tcode := manualInput\n\t\tif strings.Contains(manualInput, \"?\") {\n\t\t\tu, err := url.Parse(manualInput)\n\t\t\tif err == nil {\n\t\t\t\tcode = u.Query().Get(\"code\")\n\t\t\t}\n\t\t}\n\t\tif code == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"could not find authorization code in input\")\n\t\t}\n\t\treturn ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)\n\tcase <-time.After(5 * time.Minute):\n\t\treturn nil, fmt.Errorf(\"authentication timed out after 5 minutes\")\n\t}\n}\n\ntype callbackResult struct {\n\tcode string\n\terr  error\n}\n\ntype deviceCodeResponse struct {\n\tDeviceAuthID string\n\tUserCode     string\n\tInterval     int\n}\n\n// DeviceCodeInfo holds the device code information returned by the OAuth provider.\ntype DeviceCodeInfo struct {\n\tDeviceAuthID string `json:\"device_auth_id\"`\n\tUserCode     string `json:\"user_code\"`\n\tVerifyURL    string `json:\"verify_url\"`\n\tInterval     int    `json:\"interval\"`\n}\n\n// RequestDeviceCode requests a device code from the OAuth provider.\n// Returns the info needed for the user to authenticate in a browser.\nfunc RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {\n\treqBody, _ := json.Marshal(map[string]string{\n\t\t\"client_id\": cfg.ClientID,\n\t})\n\n\tresp, err := http.Post(\n\t\tcfg.Issuer+\"/api/accounts/deviceauth/usercode\",\n\t\t\"application/json\",\n\t\tstrings.NewReader(string(reqBody)),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"requesting device code: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading device code response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"device code request failed: %s\", string(body))\n\t}\n\n\tdeviceResp, err := parseDeviceCodeResponse(body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing device code response: %w\", err)\n\t}\n\n\tif deviceResp.Interval < 1 {\n\t\tdeviceResp.Interval = 5\n\t}\n\n\treturn &DeviceCodeInfo{\n\t\tDeviceAuthID: deviceResp.DeviceAuthID,\n\t\tUserCode:     deviceResp.UserCode,\n\t\tVerifyURL:    cfg.Issuer + \"/codex/device\",\n\t\tInterval:     deviceResp.Interval,\n\t}, nil\n}\n\n// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated.\n// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure.\nfunc PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {\n\treturn pollDeviceCode(cfg, deviceAuthID, userCode)\n}\n\nfunc parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {\n\tvar raw struct {\n\t\tDeviceAuthID string          `json:\"device_auth_id\"`\n\t\tUserCode     string          `json:\"user_code\"`\n\t\tInterval     json.RawMessage `json:\"interval\"`\n\t}\n\n\tif err := json.Unmarshal(body, &raw); err != nil {\n\t\treturn deviceCodeResponse{}, err\n\t}\n\n\tinterval, err := parseFlexibleInt(raw.Interval)\n\tif err != nil {\n\t\treturn deviceCodeResponse{}, err\n\t}\n\n\treturn deviceCodeResponse{\n\t\tDeviceAuthID: raw.DeviceAuthID,\n\t\tUserCode:     raw.UserCode,\n\t\tInterval:     interval,\n\t}, nil\n}\n\nfunc parseFlexibleInt(raw json.RawMessage) (int, error) {\n\tif len(raw) == 0 || string(raw) == \"null\" {\n\t\treturn 0, nil\n\t}\n\n\tvar interval int\n\tif err := json.Unmarshal(raw, &interval); err == nil {\n\t\treturn interval, nil\n\t}\n\n\tvar intervalStr string\n\tif err := json.Unmarshal(raw, &intervalStr); err == nil {\n\t\tintervalStr = strings.TrimSpace(intervalStr)\n\t\tif intervalStr == \"\" {\n\t\t\treturn 0, nil\n\t\t}\n\t\treturn strconv.Atoi(intervalStr)\n\t}\n\n\treturn 0, fmt.Errorf(\"invalid integer value: %s\", string(raw))\n}\n\nfunc LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {\n\treqBody, _ := json.Marshal(map[string]string{\n\t\t\"client_id\": cfg.ClientID,\n\t})\n\n\tresp, err := http.Post(\n\t\tcfg.Issuer+\"/api/accounts/deviceauth/usercode\",\n\t\t\"application/json\",\n\t\tstrings.NewReader(string(reqBody)),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"requesting device code: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading device code response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"device code request failed: %s\", string(body))\n\t}\n\n\tdeviceResp, err := parseDeviceCodeResponse(body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing device code response: %w\", err)\n\t}\n\n\tif deviceResp.Interval < 1 {\n\t\tdeviceResp.Interval = 5\n\t}\n\n\tfmt.Printf(\n\t\t\"\\nTo authenticate, open this URL in your browser:\\n\\n  %s/codex/device\\n\\nThen enter this code: %s\\n\\nWaiting for authentication...\\n\",\n\t\tcfg.Issuer,\n\t\tdeviceResp.UserCode,\n\t)\n\n\tdeadline := time.After(15 * time.Minute)\n\tticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-deadline:\n\t\t\treturn nil, fmt.Errorf(\"device code authentication timed out after 15 minutes\")\n\t\tcase <-ticker.C:\n\t\t\tcred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif cred != nil {\n\t\t\t\treturn cred, nil\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {\n\treqBody, _ := json.Marshal(map[string]string{\n\t\t\"device_auth_id\": deviceAuthID,\n\t\t\"user_code\":      userCode,\n\t})\n\n\tresp, err := http.Post(\n\t\tcfg.Issuer+\"/api/accounts/deviceauth/token\",\n\t\t\"application/json\",\n\t\tstrings.NewReader(string(reqBody)),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"pending\")\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading device token response: %w\", err)\n\t}\n\n\tvar tokenResp struct {\n\t\tAuthorizationCode string `json:\"authorization_code\"`\n\t\tCodeChallenge     string `json:\"code_challenge\"`\n\t\tCodeVerifier      string `json:\"code_verifier\"`\n\t}\n\tif err := json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, err\n\t}\n\n\tredirectURI := cfg.Issuer + \"/deviceauth/callback\"\n\treturn ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)\n}\n\nfunc RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {\n\tif cred.RefreshToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"no refresh token available\")\n\t}\n\n\tdata := url.Values{\n\t\t\"client_id\":     {cfg.ClientID},\n\t\t\"grant_type\":    {\"refresh_token\"},\n\t\t\"refresh_token\": {cred.RefreshToken},\n\t\t\"scope\":         {\"openid profile email\"},\n\t}\n\tif cfg.ClientSecret != \"\" {\n\t\tdata.Set(\"client_secret\", cfg.ClientSecret)\n\t}\n\n\ttokenURL := cfg.Issuer + \"/oauth/token\"\n\tif cfg.TokenURL != \"\" {\n\t\ttokenURL = cfg.TokenURL\n\t}\n\n\tresp, err := http.PostForm(tokenURL, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"refreshing token: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading token refresh response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token refresh failed: %s\", string(body))\n\t}\n\n\trefreshed, err := parseTokenResponse(body, cred.Provider)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif refreshed.RefreshToken == \"\" {\n\t\trefreshed.RefreshToken = cred.RefreshToken\n\t}\n\tif refreshed.AccountID == \"\" {\n\t\trefreshed.AccountID = cred.AccountID\n\t}\n\tif cred.Email != \"\" && refreshed.Email == \"\" {\n\t\trefreshed.Email = cred.Email\n\t}\n\tif cred.ProjectID != \"\" && refreshed.ProjectID == \"\" {\n\t\trefreshed.ProjectID = cred.ProjectID\n\t}\n\treturn refreshed, nil\n}\n\nfunc BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {\n\treturn buildAuthorizeURL(cfg, pkce, state, redirectURI)\n}\n\nfunc buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {\n\tparams := url.Values{\n\t\t\"response_type\":         {\"code\"},\n\t\t\"client_id\":             {cfg.ClientID},\n\t\t\"redirect_uri\":          {redirectURI},\n\t\t\"scope\":                 {cfg.Scopes},\n\t\t\"code_challenge\":        {pkce.CodeChallenge},\n\t\t\"code_challenge_method\": {\"S256\"},\n\t\t\"state\":                 {state},\n\t}\n\n\tisGoogle := strings.Contains(strings.ToLower(cfg.Issuer), \"accounts.google.com\")\n\tif isGoogle {\n\t\t// Google OAuth requires these for refresh token support\n\t\tparams.Set(\"access_type\", \"offline\")\n\t\tparams.Set(\"prompt\", \"consent\")\n\t} else {\n\t\t// OpenAI-specific parameters\n\t\tparams.Set(\"id_token_add_organizations\", \"true\")\n\t\tparams.Set(\"codex_cli_simplified_flow\", \"true\")\n\t\tif strings.Contains(strings.ToLower(cfg.Issuer), \"auth.openai.com\") {\n\t\t\tparams.Set(\"originator\", \"picoclaw\")\n\t\t}\n\t\tif cfg.Originator != \"\" {\n\t\t\tparams.Set(\"originator\", cfg.Originator)\n\t\t}\n\t}\n\n\t// Google uses /auth path, OpenAI uses /oauth/authorize\n\tif isGoogle {\n\t\treturn cfg.Issuer + \"/auth?\" + params.Encode()\n\t}\n\treturn cfg.Issuer + \"/oauth/authorize?\" + params.Encode()\n}\n\n// ExchangeCodeForTokens exchanges an authorization code for tokens.\nfunc ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {\n\tdata := url.Values{\n\t\t\"grant_type\":    {\"authorization_code\"},\n\t\t\"code\":          {code},\n\t\t\"redirect_uri\":  {redirectURI},\n\t\t\"client_id\":     {cfg.ClientID},\n\t\t\"code_verifier\": {codeVerifier},\n\t}\n\tif cfg.ClientSecret != \"\" {\n\t\tdata.Set(\"client_secret\", cfg.ClientSecret)\n\t}\n\n\ttokenURL := cfg.Issuer + \"/oauth/token\"\n\tif cfg.TokenURL != \"\" {\n\t\ttokenURL = cfg.TokenURL\n\t}\n\n\t// Determine provider name from config\n\tprovider := \"openai\"\n\tif cfg.TokenURL != \"\" && strings.Contains(cfg.TokenURL, \"googleapis.com\") {\n\t\tprovider = \"google-antigravity\"\n\t}\n\n\tresp, err := http.PostForm(tokenURL, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"exchanging code for tokens: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading token exchange response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token exchange failed: %s\", string(body))\n\t}\n\n\treturn parseTokenResponse(body, provider)\n}\n\nfunc parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn    int    `json:\"expires_in\"`\n\t\tIDToken      string `json:\"id_token\"`\n\t}\n\tif err := json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing token response: %w\", err)\n\t}\n\n\tif tokenResp.AccessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"no access token in response\")\n\t}\n\n\tvar expiresAt time.Time\n\tif tokenResp.ExpiresIn > 0 {\n\t\texpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)\n\t}\n\n\tcred := &AuthCredential{\n\t\tAccessToken:  tokenResp.AccessToken,\n\t\tRefreshToken: tokenResp.RefreshToken,\n\t\tExpiresAt:    expiresAt,\n\t\tProvider:     provider,\n\t\tAuthMethod:   \"oauth\",\n\t}\n\n\tif accountID := extractAccountID(tokenResp.IDToken); accountID != \"\" {\n\t\tcred.AccountID = accountID\n\t} else if accountID := extractAccountID(tokenResp.AccessToken); accountID != \"\" {\n\t\tcred.AccountID = accountID\n\t} else if accountID := extractAccountID(tokenResp.IDToken); accountID != \"\" {\n\t\t// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.\n\t\tcred.AccountID = accountID\n\t}\n\n\treturn cred, nil\n}\n\nfunc extractAccountID(token string) string {\n\tclaims, err := parseJWTClaims(token)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tif accountID, ok := claims[\"chatgpt_account_id\"].(string); ok && accountID != \"\" {\n\t\treturn accountID\n\t}\n\n\tif accountID, ok := claims[\"https://api.openai.com/auth.chatgpt_account_id\"].(string); ok && accountID != \"\" {\n\t\treturn accountID\n\t}\n\n\tif authClaim, ok := claims[\"https://api.openai.com/auth\"].(map[string]any); ok {\n\t\tif accountID, ok := authClaim[\"chatgpt_account_id\"].(string); ok && accountID != \"\" {\n\t\t\treturn accountID\n\t\t}\n\t}\n\n\tif orgs, ok := claims[\"organizations\"].([]any); ok {\n\t\tfor _, org := range orgs {\n\t\t\tif orgMap, ok := org.(map[string]any); ok {\n\t\t\t\tif accountID, ok := orgMap[\"id\"].(string); ok && accountID != \"\" {\n\t\t\t\t\treturn accountID\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc parseJWTClaims(token string) (map[string]any, error) {\n\tparts := strings.Split(token, \".\")\n\tif len(parts) < 2 {\n\t\treturn nil, fmt.Errorf(\"token is not a JWT\")\n\t}\n\n\tpayload := parts[1]\n\tswitch len(payload) % 4 {\n\tcase 2:\n\t\tpayload += \"==\"\n\tcase 3:\n\t\tpayload += \"=\"\n\t}\n\n\tdecoded, err := base64URLDecode(payload)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar claims map[string]any\n\tif err := json.Unmarshal(decoded, &claims); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn claims, nil\n}\n\nfunc base64URLDecode(s string) ([]byte, error) {\n\ts = strings.NewReplacer(\"-\", \"+\", \"_\", \"/\").Replace(s)\n\treturn base64.StdEncoding.DecodeString(s)\n}\n\n// OpenBrowser opens the given URL in the user's default browser.\nfunc OpenBrowser(url string) error {\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", url).Start()\n\tcase \"linux\":\n\t\treturn exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\treturn exec.Command(\"cmd\", \"/c\", \"start\", url).Start()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported platform: %s\", runtime.GOOS)\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/oauth_test.go",
    "content": "package auth\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc makeJWTForClaims(t *testing.T, claims map[string]any) string {\n\tt.Helper()\n\n\theader := base64.RawURLEncoding.EncodeToString([]byte(`{\"alg\":\"none\",\"typ\":\"JWT\"}`))\n\tpayloadJSON, err := json.Marshal(claims)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal claims: %v\", err)\n\t}\n\tpayload := base64.RawURLEncoding.EncodeToString(payloadJSON)\n\treturn header + \".\" + payload + \".sig\"\n}\n\nfunc TestBuildAuthorizeURL(t *testing.T) {\n\tcfg := OAuthProviderConfig{\n\t\tIssuer:     \"https://auth.example.com\",\n\t\tClientID:   \"test-client-id\",\n\t\tScopes:     \"openid profile\",\n\t\tOriginator: \"codex_cli_rs\",\n\t\tPort:       1455,\n\t}\n\tpkce := PKCECodes{\n\t\tCodeVerifier:  \"test-verifier\",\n\t\tCodeChallenge: \"test-challenge\",\n\t}\n\n\tu := BuildAuthorizeURL(cfg, pkce, \"test-state\", \"http://localhost:1455/auth/callback\")\n\n\tif !strings.HasPrefix(u, \"https://auth.example.com/oauth/authorize?\") {\n\t\tt.Errorf(\"URL does not start with expected prefix: %s\", u)\n\t}\n\tif !strings.Contains(u, \"client_id=test-client-id\") {\n\t\tt.Error(\"URL missing client_id\")\n\t}\n\tif !strings.Contains(u, \"code_challenge=test-challenge\") {\n\t\tt.Error(\"URL missing code_challenge\")\n\t}\n\tif !strings.Contains(u, \"code_challenge_method=S256\") {\n\t\tt.Error(\"URL missing code_challenge_method\")\n\t}\n\tif !strings.Contains(u, \"state=test-state\") {\n\t\tt.Error(\"URL missing state\")\n\t}\n\tif !strings.Contains(u, \"response_type=code\") {\n\t\tt.Error(\"URL missing response_type\")\n\t}\n\tif !strings.Contains(u, \"id_token_add_organizations=true\") {\n\t\tt.Error(\"URL missing id_token_add_organizations\")\n\t}\n\tif !strings.Contains(u, \"codex_cli_simplified_flow=true\") {\n\t\tt.Error(\"URL missing codex_cli_simplified_flow\")\n\t}\n\tif !strings.Contains(u, \"originator=codex_cli_rs\") {\n\t\tt.Error(\"URL missing originator\")\n\t}\n}\n\nfunc TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {\n\tcfg := OpenAIOAuthConfig()\n\tpkce := PKCECodes{CodeVerifier: \"test-verifier\", CodeChallenge: \"test-challenge\"}\n\n\tu := BuildAuthorizeURL(cfg, pkce, \"test-state\", \"http://localhost:1455/auth/callback\")\n\tparsed, err := url.Parse(u)\n\tif err != nil {\n\t\tt.Fatalf(\"url.Parse() error: %v\", err)\n\t}\n\tq := parsed.Query()\n\n\tif q.Get(\"id_token_add_organizations\") != \"true\" {\n\t\tt.Errorf(\"id_token_add_organizations = %q, want true\", q.Get(\"id_token_add_organizations\"))\n\t}\n\tif q.Get(\"codex_cli_simplified_flow\") != \"true\" {\n\t\tt.Errorf(\"codex_cli_simplified_flow = %q, want true\", q.Get(\"codex_cli_simplified_flow\"))\n\t}\n\tif q.Get(\"originator\") != \"codex_cli_rs\" {\n\t\tt.Errorf(\"originator = %q, want codex_cli_rs\", q.Get(\"originator\"))\n\t}\n}\n\nfunc TestParseTokenResponse(t *testing.T) {\n\tresp := map[string]any{\n\t\t\"access_token\":  \"test-access-token\",\n\t\t\"refresh_token\": \"test-refresh-token\",\n\t\t\"expires_in\":    3600,\n\t\t\"id_token\":      \"test-id-token\",\n\t}\n\tbody, _ := json.Marshal(resp)\n\n\tcred, err := parseTokenResponse(body, \"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"parseTokenResponse() error: %v\", err)\n\t}\n\n\tif cred.AccessToken != \"test-access-token\" {\n\t\tt.Errorf(\"AccessToken = %q, want %q\", cred.AccessToken, \"test-access-token\")\n\t}\n\tif cred.RefreshToken != \"test-refresh-token\" {\n\t\tt.Errorf(\"RefreshToken = %q, want %q\", cred.RefreshToken, \"test-refresh-token\")\n\t}\n\tif cred.Provider != \"openai\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", cred.Provider, \"openai\")\n\t}\n\tif cred.AuthMethod != \"oauth\" {\n\t\tt.Errorf(\"AuthMethod = %q, want %q\", cred.AuthMethod, \"oauth\")\n\t}\n\tif cred.ExpiresAt.IsZero() {\n\t\tt.Error(\"ExpiresAt should not be zero\")\n\t}\n}\n\nfunc TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {\n\tidToken := makeJWTForClaims(t, map[string]any{\"chatgpt_account_id\": \"acc-id-from-id-token\"})\n\tresp := map[string]any{\n\t\t\"access_token\":  \"opaque-access-token\",\n\t\t\"refresh_token\": \"test-refresh-token\",\n\t\t\"expires_in\":    3600,\n\t\t\"id_token\":      idToken,\n\t}\n\tbody, _ := json.Marshal(resp)\n\n\tcred, err := parseTokenResponse(body, \"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"parseTokenResponse() error: %v\", err)\n\t}\n\tif cred.AccountID != \"acc-id-from-id-token\" {\n\t\tt.Errorf(\"AccountID = %q, want %q\", cred.AccountID, \"acc-id-from-id-token\")\n\t}\n}\n\nfunc TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {\n\ttoken := makeJWTForClaims(t, map[string]any{\n\t\t\"organizations\": []any{\n\t\t\tmap[string]any{\"id\": \"org_from_orgs\"},\n\t\t},\n\t})\n\n\tif got := extractAccountID(token); got != \"org_from_orgs\" {\n\t\tt.Errorf(\"extractAccountID() = %q, want %q\", got, \"org_from_orgs\")\n\t}\n}\n\nfunc TestParseTokenResponseNoAccessToken(t *testing.T) {\n\tbody := []byte(`{\"refresh_token\": \"test\"}`)\n\t_, err := parseTokenResponse(body, \"openai\")\n\tif err == nil {\n\t\tt.Error(\"expected error for missing access_token\")\n\t}\n}\n\nfunc TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {\n\tidToken := makeJWTWithAccountID(\"acc-from-id\")\n\tresp := map[string]any{\n\t\t\"access_token\":  \"not-a-jwt\",\n\t\t\"refresh_token\": \"test-refresh-token\",\n\t\t\"expires_in\":    3600,\n\t\t\"id_token\":      idToken,\n\t}\n\tbody, _ := json.Marshal(resp)\n\n\tcred, err := parseTokenResponse(body, \"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"parseTokenResponse() error: %v\", err)\n\t}\n\n\tif cred.AccountID != \"acc-from-id\" {\n\t\tt.Errorf(\"AccountID = %q, want %q\", cred.AccountID, \"acc-from-id\")\n\t}\n}\n\nfunc makeJWTWithAccountID(accountID string) string {\n\theader := base64.RawURLEncoding.EncodeToString([]byte(`{\"alg\":\"none\",\"typ\":\"JWT\"}`))\n\tpayload := base64.RawURLEncoding.EncodeToString(\n\t\t[]byte(`{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"` + accountID + `\"}}`),\n\t)\n\treturn header + \".\" + payload + \".sig\"\n}\n\nfunc TestExchangeCodeForTokens(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/oauth/token\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif r.Method != http.MethodPost {\n\t\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t\t\treturn\n\t\t}\n\n\t\tr.ParseForm()\n\t\tif r.FormValue(\"grant_type\") != \"authorization_code\" {\n\t\t\thttp.Error(w, \"invalid grant_type\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"access_token\":  \"mock-access-token\",\n\t\t\t\"refresh_token\": \"mock-refresh-token\",\n\t\t\t\"expires_in\":    3600,\n\t\t}\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tcfg := OAuthProviderConfig{\n\t\tIssuer:   server.URL,\n\t\tClientID: \"test-client\",\n\t\tScopes:   \"openid\",\n\t\tPort:     1455,\n\t}\n\n\tcred, err := ExchangeCodeForTokens(cfg, \"test-code\", \"test-verifier\", \"http://localhost:1455/auth/callback\")\n\tif err != nil {\n\t\tt.Fatalf(\"ExchangeCodeForTokens() error: %v\", err)\n\t}\n\n\tif cred.AccessToken != \"mock-access-token\" {\n\t\tt.Errorf(\"AccessToken = %q, want %q\", cred.AccessToken, \"mock-access-token\")\n\t}\n}\n\nfunc TestRefreshAccessToken(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/oauth/token\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tr.ParseForm()\n\t\tif r.FormValue(\"grant_type\") != \"refresh_token\" {\n\t\t\thttp.Error(w, \"invalid grant_type\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"access_token\":  \"refreshed-access-token\",\n\t\t\t\"refresh_token\": \"refreshed-refresh-token\",\n\t\t\t\"expires_in\":    3600,\n\t\t}\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tcfg := OAuthProviderConfig{\n\t\tIssuer:   server.URL,\n\t\tClientID: \"test-client\",\n\t}\n\n\tcred := &AuthCredential{\n\t\tAccessToken:  \"old-token\",\n\t\tRefreshToken: \"old-refresh-token\",\n\t\tProvider:     \"openai\",\n\t\tAuthMethod:   \"oauth\",\n\t}\n\n\trefreshed, err := RefreshAccessToken(cred, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"RefreshAccessToken() error: %v\", err)\n\t}\n\n\tif refreshed.AccessToken != \"refreshed-access-token\" {\n\t\tt.Errorf(\"AccessToken = %q, want %q\", refreshed.AccessToken, \"refreshed-access-token\")\n\t}\n\tif refreshed.RefreshToken != \"refreshed-refresh-token\" {\n\t\tt.Errorf(\"RefreshToken = %q, want %q\", refreshed.RefreshToken, \"refreshed-refresh-token\")\n\t}\n}\n\nfunc TestRefreshAccessTokenNoRefreshToken(t *testing.T) {\n\tcfg := OpenAIOAuthConfig()\n\tcred := &AuthCredential{\n\t\tAccessToken: \"old-token\",\n\t\tProvider:    \"openai\",\n\t\tAuthMethod:  \"oauth\",\n\t}\n\n\t_, err := RefreshAccessToken(cred, cfg)\n\tif err == nil {\n\t\tt.Error(\"expected error for missing refresh token\")\n\t}\n}\n\nfunc TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tresp := map[string]any{\n\t\t\t\"access_token\": \"new-access-token-only\",\n\t\t\t\"expires_in\":   3600,\n\t\t}\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tcfg := OAuthProviderConfig{Issuer: server.URL, ClientID: \"test-client\"}\n\tcred := &AuthCredential{\n\t\tAccessToken:  \"old-access\",\n\t\tRefreshToken: \"existing-refresh\",\n\t\tAccountID:    \"acc_existing\",\n\t\tProvider:     \"openai\",\n\t\tAuthMethod:   \"oauth\",\n\t}\n\n\trefreshed, err := RefreshAccessToken(cred, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"RefreshAccessToken() error: %v\", err)\n\t}\n\tif refreshed.RefreshToken != \"existing-refresh\" {\n\t\tt.Errorf(\"RefreshToken = %q, want %q\", refreshed.RefreshToken, \"existing-refresh\")\n\t}\n\tif refreshed.AccountID != \"acc_existing\" {\n\t\tt.Errorf(\"AccountID = %q, want %q\", refreshed.AccountID, \"acc_existing\")\n\t}\n}\n\nfunc TestOpenAIOAuthConfig(t *testing.T) {\n\tcfg := OpenAIOAuthConfig()\n\tif cfg.Issuer != \"https://auth.openai.com\" {\n\t\tt.Errorf(\"Issuer = %q, want %q\", cfg.Issuer, \"https://auth.openai.com\")\n\t}\n\tif cfg.ClientID == \"\" {\n\t\tt.Error(\"ClientID is empty\")\n\t}\n\tif cfg.Port != 1455 {\n\t\tt.Errorf(\"Port = %d, want 1455\", cfg.Port)\n\t}\n}\n\nfunc TestParseDeviceCodeResponseIntervalAsNumber(t *testing.T) {\n\tbody := []byte(`{\"device_auth_id\":\"abc\",\"user_code\":\"DEF-1234\",\"interval\":5}`)\n\n\tresp, err := parseDeviceCodeResponse(body)\n\tif err != nil {\n\t\tt.Fatalf(\"parseDeviceCodeResponse() error: %v\", err)\n\t}\n\n\tif resp.DeviceAuthID != \"abc\" {\n\t\tt.Errorf(\"DeviceAuthID = %q, want %q\", resp.DeviceAuthID, \"abc\")\n\t}\n\tif resp.UserCode != \"DEF-1234\" {\n\t\tt.Errorf(\"UserCode = %q, want %q\", resp.UserCode, \"DEF-1234\")\n\t}\n\tif resp.Interval != 5 {\n\t\tt.Errorf(\"Interval = %d, want %d\", resp.Interval, 5)\n\t}\n}\n\nfunc TestParseDeviceCodeResponseIntervalAsString(t *testing.T) {\n\tbody := []byte(`{\"device_auth_id\":\"abc\",\"user_code\":\"DEF-1234\",\"interval\":\"5\"}`)\n\n\tresp, err := parseDeviceCodeResponse(body)\n\tif err != nil {\n\t\tt.Fatalf(\"parseDeviceCodeResponse() error: %v\", err)\n\t}\n\n\tif resp.Interval != 5 {\n\t\tt.Errorf(\"Interval = %d, want %d\", resp.Interval, 5)\n\t}\n}\n\nfunc TestParseDeviceCodeResponseInvalidInterval(t *testing.T) {\n\tbody := []byte(`{\"device_auth_id\":\"abc\",\"user_code\":\"DEF-1234\",\"interval\":\"abc\"}`)\n\n\tif _, err := parseDeviceCodeResponse(body); err == nil {\n\t\tt.Fatal(\"expected error for invalid interval\")\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/pkce.go",
    "content": "package auth\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n)\n\ntype PKCECodes struct {\n\tCodeVerifier  string\n\tCodeChallenge string\n}\n\nfunc GeneratePKCE() (PKCECodes, error) {\n\tbuf := make([]byte, 64)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn PKCECodes{}, err\n\t}\n\n\tverifier := base64.RawURLEncoding.EncodeToString(buf)\n\n\thash := sha256.Sum256([]byte(verifier))\n\tchallenge := base64.RawURLEncoding.EncodeToString(hash[:])\n\n\treturn PKCECodes{\n\t\tCodeVerifier:  verifier,\n\t\tCodeChallenge: challenge,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/auth/pkce_test.go",
    "content": "package auth\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"testing\"\n)\n\nfunc TestGeneratePKCE(t *testing.T) {\n\tcodes, err := GeneratePKCE()\n\tif err != nil {\n\t\tt.Fatalf(\"GeneratePKCE() error: %v\", err)\n\t}\n\n\tif codes.CodeVerifier == \"\" {\n\t\tt.Fatal(\"CodeVerifier is empty\")\n\t}\n\tif codes.CodeChallenge == \"\" {\n\t\tt.Fatal(\"CodeChallenge is empty\")\n\t}\n\n\tverifierBytes, err := base64.RawURLEncoding.DecodeString(codes.CodeVerifier)\n\tif err != nil {\n\t\tt.Fatalf(\"CodeVerifier is not valid base64url: %v\", err)\n\t}\n\tif len(verifierBytes) != 64 {\n\t\tt.Errorf(\"CodeVerifier decoded length = %d, want 64\", len(verifierBytes))\n\t}\n\n\thash := sha256.Sum256([]byte(codes.CodeVerifier))\n\texpectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])\n\tif codes.CodeChallenge != expectedChallenge {\n\t\tt.Errorf(\"CodeChallenge = %q, want SHA256 of verifier = %q\", codes.CodeChallenge, expectedChallenge)\n\t}\n}\n\nfunc TestGeneratePKCEUniqueness(t *testing.T) {\n\tcodes1, err := GeneratePKCE()\n\tif err != nil {\n\t\tt.Fatalf(\"GeneratePKCE() error: %v\", err)\n\t}\n\n\tcodes2, err := GeneratePKCE()\n\tif err != nil {\n\t\tt.Fatalf(\"GeneratePKCE() error: %v\", err)\n\t}\n\n\tif codes1.CodeVerifier == codes2.CodeVerifier {\n\t\tt.Error(\"two GeneratePKCE() calls produced identical verifiers\")\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/store.go",
    "content": "package auth\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n)\n\ntype AuthCredential struct {\n\tAccessToken  string    `json:\"access_token\"`\n\tRefreshToken string    `json:\"refresh_token,omitempty\"`\n\tAccountID    string    `json:\"account_id,omitempty\"`\n\tExpiresAt    time.Time `json:\"expires_at,omitempty\"`\n\tProvider     string    `json:\"provider\"`\n\tAuthMethod   string    `json:\"auth_method\"`\n\tEmail        string    `json:\"email,omitempty\"`\n\tProjectID    string    `json:\"project_id,omitempty\"`\n}\n\ntype AuthStore struct {\n\tCredentials map[string]*AuthCredential `json:\"credentials\"`\n}\n\nfunc (c *AuthCredential) IsExpired() bool {\n\tif c.ExpiresAt.IsZero() {\n\t\treturn false\n\t}\n\treturn time.Now().After(c.ExpiresAt)\n}\n\nfunc (c *AuthCredential) NeedsRefresh() bool {\n\tif c.ExpiresAt.IsZero() {\n\t\treturn false\n\t}\n\treturn time.Now().Add(5 * time.Minute).After(c.ExpiresAt)\n}\n\nfunc authFilePath() string {\n\tif home := os.Getenv(config.EnvHome); home != \"\" {\n\t\treturn filepath.Join(home, \"auth.json\")\n\t}\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".picoclaw\", \"auth.json\")\n}\n\nfunc LoadStore() (*AuthStore, error) {\n\tpath := authFilePath()\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn &AuthStore{Credentials: make(map[string]*AuthCredential)}, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tvar store AuthStore\n\tif err := json.Unmarshal(data, &store); err != nil {\n\t\treturn nil, err\n\t}\n\tif store.Credentials == nil {\n\t\tstore.Credentials = make(map[string]*AuthCredential)\n\t}\n\treturn &store, nil\n}\n\nfunc SaveStore(store *AuthStore) error {\n\tpath := authFilePath()\n\tdata, err := json.MarshalIndent(store, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\treturn fileutil.WriteFileAtomic(path, data, 0o600)\n}\n\nfunc GetCredential(provider string) (*AuthCredential, error) {\n\tstore, err := LoadStore()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcred, ok := store.Credentials[provider]\n\tif !ok {\n\t\treturn nil, nil\n\t}\n\treturn cred, nil\n}\n\nfunc SetCredential(provider string, cred *AuthCredential) error {\n\tstore, err := LoadStore()\n\tif err != nil {\n\t\treturn err\n\t}\n\tstore.Credentials[provider] = cred\n\treturn SaveStore(store)\n}\n\nfunc DeleteCredential(provider string) error {\n\tstore, err := LoadStore()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdelete(store.Credentials, provider)\n\treturn SaveStore(store)\n}\n\nfunc DeleteAllCredentials() error {\n\tpath := authFilePath()\n\tif err := os.Remove(path); err != nil && !os.IsNotExist(err) {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/auth/store_test.go",
    "content": "package auth\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAuthCredentialIsExpired(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\texpiresAt time.Time\n\t\twant      bool\n\t}{\n\t\t{\"zero time\", time.Time{}, false},\n\t\t{\"future\", time.Now().Add(time.Hour), false},\n\t\t{\"past\", time.Now().Add(-time.Hour), true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tc := &AuthCredential{ExpiresAt: tt.expiresAt}\n\t\t\tif got := c.IsExpired(); got != tt.want {\n\t\t\t\tt.Errorf(\"IsExpired() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAuthCredentialNeedsRefresh(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\texpiresAt time.Time\n\t\twant      bool\n\t}{\n\t\t{\"zero time\", time.Time{}, false},\n\t\t{\"far future\", time.Now().Add(time.Hour), false},\n\t\t{\"within 5 min\", time.Now().Add(3 * time.Minute), true},\n\t\t{\"already expired\", time.Now().Add(-time.Minute), true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tc := &AuthCredential{ExpiresAt: tt.expiresAt}\n\t\t\tif got := c.NeedsRefresh(); got != tt.want {\n\t\t\t\tt.Errorf(\"NeedsRefresh() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStoreRoundtrip(t *testing.T) {\n\ttmpDir := t.TempDir()\n\torigHome := os.Getenv(\"HOME\")\n\tt.Setenv(\"HOME\", tmpDir)\n\tdefer os.Setenv(\"HOME\", origHome)\n\n\tcred := &AuthCredential{\n\t\tAccessToken:  \"test-access-token\",\n\t\tRefreshToken: \"test-refresh-token\",\n\t\tAccountID:    \"acct-123\",\n\t\tExpiresAt:    time.Now().Add(time.Hour).Truncate(time.Second),\n\t\tProvider:     \"openai\",\n\t\tAuthMethod:   \"oauth\",\n\t}\n\n\tif err := SetCredential(\"openai\", cred); err != nil {\n\t\tt.Fatalf(\"SetCredential() error: %v\", err)\n\t}\n\n\tloaded, err := GetCredential(\"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetCredential() error: %v\", err)\n\t}\n\tif loaded == nil {\n\t\tt.Fatal(\"GetCredential() returned nil\")\n\t}\n\tif loaded.AccessToken != cred.AccessToken {\n\t\tt.Errorf(\"AccessToken = %q, want %q\", loaded.AccessToken, cred.AccessToken)\n\t}\n\tif loaded.RefreshToken != cred.RefreshToken {\n\t\tt.Errorf(\"RefreshToken = %q, want %q\", loaded.RefreshToken, cred.RefreshToken)\n\t}\n\tif loaded.Provider != cred.Provider {\n\t\tt.Errorf(\"Provider = %q, want %q\", loaded.Provider, cred.Provider)\n\t}\n}\n\nfunc TestStoreFilePermissions(t *testing.T) {\n\ttmpDir := t.TempDir()\n\torigHome := os.Getenv(\"HOME\")\n\tt.Setenv(\"HOME\", tmpDir)\n\tdefer os.Setenv(\"HOME\", origHome)\n\n\tcred := &AuthCredential{\n\t\tAccessToken: \"secret-token\",\n\t\tProvider:    \"openai\",\n\t\tAuthMethod:  \"oauth\",\n\t}\n\tif err := SetCredential(\"openai\", cred); err != nil {\n\t\tt.Fatalf(\"SetCredential() error: %v\", err)\n\t}\n\n\tpath := filepath.Join(tmpDir, \".picoclaw\", \"auth.json\")\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatalf(\"Stat() error: %v\", err)\n\t}\n\tperm := info.Mode().Perm()\n\tif perm != 0o600 {\n\t\tt.Errorf(\"file permissions = %o, want 0600\", perm)\n\t}\n}\n\nfunc TestStoreMultiProvider(t *testing.T) {\n\ttmpDir := t.TempDir()\n\torigHome := os.Getenv(\"HOME\")\n\tt.Setenv(\"HOME\", tmpDir)\n\tdefer os.Setenv(\"HOME\", origHome)\n\n\topenaiCred := &AuthCredential{AccessToken: \"openai-token\", Provider: \"openai\", AuthMethod: \"oauth\"}\n\tanthropicCred := &AuthCredential{AccessToken: \"anthropic-token\", Provider: \"anthropic\", AuthMethod: \"token\"}\n\n\tif err := SetCredential(\"openai\", openaiCred); err != nil {\n\t\tt.Fatalf(\"SetCredential(openai) error: %v\", err)\n\t}\n\tif err := SetCredential(\"anthropic\", anthropicCred); err != nil {\n\t\tt.Fatalf(\"SetCredential(anthropic) error: %v\", err)\n\t}\n\n\tloaded, err := GetCredential(\"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetCredential(openai) error: %v\", err)\n\t}\n\tif loaded.AccessToken != \"openai-token\" {\n\t\tt.Errorf(\"openai token = %q, want %q\", loaded.AccessToken, \"openai-token\")\n\t}\n\n\tloaded, err = GetCredential(\"anthropic\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetCredential(anthropic) error: %v\", err)\n\t}\n\tif loaded.AccessToken != \"anthropic-token\" {\n\t\tt.Errorf(\"anthropic token = %q, want %q\", loaded.AccessToken, \"anthropic-token\")\n\t}\n}\n\nfunc TestDeleteCredential(t *testing.T) {\n\ttmpDir := t.TempDir()\n\torigHome := os.Getenv(\"HOME\")\n\tt.Setenv(\"HOME\", tmpDir)\n\tdefer os.Setenv(\"HOME\", origHome)\n\n\tcred := &AuthCredential{AccessToken: \"to-delete\", Provider: \"openai\", AuthMethod: \"oauth\"}\n\tif err := SetCredential(\"openai\", cred); err != nil {\n\t\tt.Fatalf(\"SetCredential() error: %v\", err)\n\t}\n\n\tif err := DeleteCredential(\"openai\"); err != nil {\n\t\tt.Fatalf(\"DeleteCredential() error: %v\", err)\n\t}\n\n\tloaded, err := GetCredential(\"openai\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetCredential() error: %v\", err)\n\t}\n\tif loaded != nil {\n\t\tt.Error(\"expected nil after delete\")\n\t}\n}\n\nfunc TestLoadStoreEmpty(t *testing.T) {\n\ttmpDir := t.TempDir()\n\torigHome := os.Getenv(\"HOME\")\n\tt.Setenv(\"HOME\", tmpDir)\n\tdefer os.Setenv(\"HOME\", origHome)\n\n\tstore, err := LoadStore()\n\tif err != nil {\n\t\tt.Fatalf(\"LoadStore() error: %v\", err)\n\t}\n\tif store == nil {\n\t\tt.Fatal(\"LoadStore() returned nil\")\n\t}\n\tif len(store.Credentials) != 0 {\n\t\tt.Errorf(\"expected empty credentials, got %d\", len(store.Credentials))\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/token.go",
    "content": "package auth\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n)\n\nfunc LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {\n\tfmt.Printf(\"Paste your API key or session token from %s:\\n\", providerDisplayName(provider))\n\tfmt.Print(\"> \")\n\n\tscanner := bufio.NewScanner(r)\n\tif !scanner.Scan() {\n\t\tif err := scanner.Err(); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"reading token: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"no input received\")\n\t}\n\n\ttoken := strings.TrimSpace(scanner.Text())\n\tif token == \"\" {\n\t\treturn nil, fmt.Errorf(\"token cannot be empty\")\n\t}\n\n\treturn &AuthCredential{\n\t\tAccessToken: token,\n\t\tProvider:    provider,\n\t\tAuthMethod:  \"token\",\n\t}, nil\n}\n\nfunc LoginSetupToken(r io.Reader) (*AuthCredential, error) {\n\tfmt.Println(\"Paste your setup token from `claude setup-token`:\")\n\tfmt.Print(\"> \")\n\n\tscanner := bufio.NewScanner(r)\n\tif !scanner.Scan() {\n\t\tif err := scanner.Err(); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"reading token: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"no input received\")\n\t}\n\n\ttoken := strings.TrimSpace(scanner.Text())\n\n\tif !strings.HasPrefix(token, \"sk-ant-oat01-\") {\n\t\treturn nil, fmt.Errorf(\"invalid setup token: expected prefix sk-ant-oat01-\")\n\t}\n\n\tif len(token) < 80 {\n\t\treturn nil, fmt.Errorf(\"invalid setup token: too short (expected at least 80 characters)\")\n\t}\n\n\treturn &AuthCredential{\n\t\tAccessToken: token,\n\t\tProvider:    \"anthropic\",\n\t\tAuthMethod:  \"oauth\",\n\t}, nil\n}\n\nfunc providerDisplayName(provider string) string {\n\tswitch provider {\n\tcase \"anthropic\":\n\t\treturn \"console.anthropic.com\"\n\tcase \"openai\":\n\t\treturn \"platform.openai.com\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n"
  },
  {
    "path": "pkg/auth/token_test.go",
    "content": "package auth\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestLoginSetupToken(t *testing.T) {\n\t// A valid token: correct prefix + at least 80 chars\n\tvalidToken := \"sk-ant-oat01-\" + strings.Repeat(\"a\", 80)\n\n\ttests := []struct {\n\t\tname    string\n\t\tinput   string\n\t\twantErr string\n\t}{\n\t\t{\"valid token\", validToken, \"\"},\n\t\t{\"empty input\", \"\", \"expected prefix sk-ant-oat01-\"},\n\t\t{\"wrong prefix\", \"sk-ant-api-\" + strings.Repeat(\"a\", 80), \"expected prefix sk-ant-oat01-\"},\n\t\t{\"too short\", \"sk-ant-oat01-short\", \"too short\"},\n\t\t{\"whitespace only\", \"   \", \"expected prefix sk-ant-oat01-\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := strings.NewReader(tt.input + \"\\n\")\n\t\t\tcred, err := LoginSetupToken(r)\n\n\t\t\tif tt.wantErr != \"\" {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"expected error containing %q, got nil\", tt.wantErr)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(err.Error(), tt.wantErr) {\n\t\t\t\t\tt.Fatalf(\"expected error containing %q, got %q\", tt.wantErr, err.Error())\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif cred.AccessToken != validToken {\n\t\t\t\tt.Errorf(\"AccessToken = %q, want %q\", cred.AccessToken, validToken)\n\t\t\t}\n\t\t\tif cred.Provider != \"anthropic\" {\n\t\t\t\tt.Errorf(\"Provider = %q, want %q\", cred.Provider, \"anthropic\")\n\t\t\t}\n\t\t\tif cred.AuthMethod != \"oauth\" {\n\t\t\t\tt.Errorf(\"AuthMethod = %q, want %q\", cred.AuthMethod, \"oauth\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLoginSetupToken_EmptyReader(t *testing.T) {\n\tr := strings.NewReader(\"\")\n\t_, err := LoginSetupToken(r)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty reader, got nil\")\n\t}\n}\n"
  },
  {
    "path": "pkg/bus/bus.go",
    "content": "package bus\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// ErrBusClosed is returned when publishing to a closed MessageBus.\nvar ErrBusClosed = errors.New(\"message bus closed\")\n\nconst defaultBusBufferSize = 64\n\ntype MessageBus struct {\n\tinbound       chan InboundMessage\n\toutbound      chan OutboundMessage\n\toutboundMedia chan OutboundMediaMessage\n\n\tcloseOnce sync.Once\n\tdone      chan struct{}\n\tclosed    atomic.Bool\n\twg        sync.WaitGroup\n}\n\nfunc NewMessageBus() *MessageBus {\n\treturn &MessageBus{\n\t\tinbound:       make(chan InboundMessage, defaultBusBufferSize),\n\t\toutbound:      make(chan OutboundMessage, defaultBusBufferSize),\n\t\toutboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),\n\t\tdone:          make(chan struct{}),\n\t}\n}\n\nfunc publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {\n\t// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock\n\tif mb.closed.Load() {\n\t\treturn ErrBusClosed\n\t}\n\n\t// check again,before sending message, to avoid sending to closed channel\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-mb.done:\n\t\treturn ErrBusClosed\n\tdefault:\n\t}\n\n\tmb.wg.Add(1)\n\tdefer mb.wg.Done()\n\n\tselect {\n\tcase ch <- msg:\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-mb.done:\n\t\treturn ErrBusClosed\n\t}\n}\n\nfunc (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {\n\treturn publish(ctx, mb, mb.inbound, msg)\n}\n\nfunc (mb *MessageBus) InboundChan() <-chan InboundMessage {\n\treturn mb.inbound\n}\n\nfunc (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {\n\treturn publish(ctx, mb, mb.outbound, msg)\n}\n\nfunc (mb *MessageBus) OutboundChan() <-chan OutboundMessage {\n\treturn mb.outbound\n}\n\nfunc (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {\n\treturn publish(ctx, mb, mb.outboundMedia, msg)\n}\n\nfunc (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {\n\treturn mb.outboundMedia\n}\n\nfunc (mb *MessageBus) Close() {\n\tmb.closeOnce.Do(func() {\n\t\t// notify all blocked publishers to exit\n\t\tclose(mb.done)\n\n\t\t// because every publisher will check mb.closed before acquiring wg\n\t\t// so we can be sure that new publishers will not be added new messages after this point\n\t\tmb.closed.Store(true)\n\n\t\t// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited\n\t\tmb.wg.Wait()\n\n\t\t// close channels safely\n\t\tclose(mb.inbound)\n\t\tclose(mb.outbound)\n\t\tclose(mb.outboundMedia)\n\n\t\t// clean up any remaining messages in channels\n\t\tdrained := 0\n\t\tfor range mb.inbound {\n\t\t\tdrained++\n\t\t}\n\t\tfor range mb.outbound {\n\t\t\tdrained++\n\t\t}\n\t\tfor range mb.outboundMedia {\n\t\t\tdrained++\n\t\t}\n\n\t\tif drained > 0 {\n\t\t\tlogger.DebugCF(\"bus\", \"Drained buffered messages during close\", map[string]any{\n\t\t\t\t\"count\": drained,\n\t\t\t})\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/bus/bus_test.go",
    "content": "package bus\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestPublishConsume(t *testing.T) {\n\tmb := NewMessageBus()\n\tdefer mb.Close()\n\n\tctx := context.Background()\n\n\tmsg := InboundMessage{\n\t\tChannel:  \"test\",\n\t\tSenderID: \"user1\",\n\t\tChatID:   \"chat1\",\n\t\tContent:  \"hello\",\n\t}\n\n\tif err := mb.PublishInbound(ctx, msg); err != nil {\n\t\tt.Fatalf(\"PublishInbound failed: %v\", err)\n\t}\n\n\tgot, ok := <-mb.InboundChan()\n\tif !ok {\n\t\tt.Fatal(\"ConsumeInbound returned ok=false\")\n\t}\n\tif got.Content != \"hello\" {\n\t\tt.Fatalf(\"expected content 'hello', got %q\", got.Content)\n\t}\n\tif got.Channel != \"test\" {\n\t\tt.Fatalf(\"expected channel 'test', got %q\", got.Channel)\n\t}\n}\n\nfunc TestPublishOutboundSubscribe(t *testing.T) {\n\tmb := NewMessageBus()\n\tdefer mb.Close()\n\n\tctx := context.Background()\n\n\tmsg := OutboundMessage{\n\t\tChannel: \"telegram\",\n\t\tChatID:  \"123\",\n\t\tContent: \"world\",\n\t}\n\n\tif err := mb.PublishOutbound(ctx, msg); err != nil {\n\t\tt.Fatalf(\"PublishOutbound failed: %v\", err)\n\t}\n\n\tgot, ok := <-mb.OutboundChan()\n\tif !ok {\n\t\tt.Fatal(\"SubscribeOutbound returned ok=false\")\n\t}\n\tif got.Content != \"world\" {\n\t\tt.Fatalf(\"expected content 'world', got %q\", got.Content)\n\t}\n}\n\nfunc TestPublishInbound_ContextCancel(t *testing.T) {\n\tmb := NewMessageBus()\n\tdefer mb.Close()\n\n\t// Fill the buffer\n\tctx := context.Background()\n\tfor i := range defaultBusBufferSize {\n\t\tif err := mb.PublishInbound(ctx, InboundMessage{Content: \"fill\"}); err != nil {\n\t\t\tt.Fatalf(\"fill failed at %d: %v\", i, err)\n\t\t}\n\t}\n\n\t// Now buffer is full; publish with a canceled context\n\tcancelCtx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\terr := mb.PublishInbound(cancelCtx, InboundMessage{Content: \"overflow\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected error from canceled context, got nil\")\n\t}\n\tif err != context.Canceled {\n\t\tt.Fatalf(\"expected context.Canceled, got %v\", err)\n\t}\n}\n\nfunc TestPublishInbound_BusClosed(t *testing.T) {\n\tmb := NewMessageBus()\n\tmb.Close()\n\n\terr := mb.PublishInbound(context.Background(), InboundMessage{Content: \"test\"})\n\tif err != ErrBusClosed {\n\t\tt.Fatalf(\"expected ErrBusClosed, got %v\", err)\n\t}\n}\n\nfunc TestPublishOutbound_BusClosed(t *testing.T) {\n\tmb := NewMessageBus()\n\tmb.Close()\n\n\terr := mb.PublishOutbound(context.Background(), OutboundMessage{Content: \"test\"})\n\tif err != ErrBusClosed {\n\t\tt.Fatalf(\"expected ErrBusClosed, got %v\", err)\n\t}\n}\n\nfunc TestConsumeInbound_ContextCancel(t *testing.T) {\n\tmb := NewMessageBus()\n\n\tdefer mb.Close()\n\n\tfor i := range defaultBusBufferSize {\n\t\tif err := mb.PublishInbound(context.Background(), InboundMessage{Content: \"fill\"}); err != nil {\n\t\t\tt.Fatalf(\"fill failed at %d: %v\", i, err)\n\t\t}\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)\n\tdefer cancel()\n\tmb.PublishInbound(ctx, InboundMessage{Content: \"ContextCancel\"})\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tt.Log(\"context canceled, as expected\")\n\n\tcase msg, ok := <-mb.InboundChan():\n\t\tif !ok {\n\t\t\tt.Fatal(\"expected ok=false when context is canceled\")\n\t\t}\n\t\tif msg.Content == \"ContextCancel\" {\n\t\t\tt.Fatalf(\"expected content 'ContextCancel', got %q\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestConsumeInbound_BusClosed(t *testing.T) {\n\tmb := NewMessageBus()\n\n\ttimer := time.AfterFunc(100*time.Millisecond, func() {\n\t\tmb.Close()\n\t})\n\n\tselect {\n\tcase <-timer.C:\n\t\tt.Log(\"context canceled, as expected\")\n\n\tcase _, ok := <-mb.InboundChan():\n\t\tif ok {\n\t\t\tt.Fatal(\"expected ok=false when context is canceled\")\n\t\t}\n\t}\n}\n\nfunc TestSubscribeOutbound_BusClosed(t *testing.T) {\n\tmb := NewMessageBus()\n\tmb.Close()\n\n\t_, ok := <-mb.OutboundChan()\n\tif ok {\n\t\tt.Fatal(\"expected ok=false when bus is closed\")\n\t}\n}\n\nfunc TestConcurrentPublishClose(t *testing.T) {\n\tmb := NewMessageBus()\n\tctx := context.Background()\n\n\tconst numGoroutines = 100\n\tvar wg sync.WaitGroup\n\twg.Add(numGoroutines + 1)\n\n\t// Spawn many goroutines trying to publish\n\tfor range numGoroutines {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\t// Use a short timeout context so we don't block forever after close\n\t\t\tpublishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)\n\t\t\tdefer cancel()\n\t\t\t// Errors are expected; we just must not panic or deadlock\n\t\t\t_ = mb.PublishInbound(publishCtx, InboundMessage{Content: \"concurrent\"})\n\t\t}()\n\t}\n\n\t// Close from another goroutine\n\tgo func() {\n\t\tdefer wg.Done()\n\t\ttime.Sleep(5 * time.Millisecond)\n\t\tmb.Close()\n\t}()\n\n\t// Must complete without deadlock\n\tdone := make(chan struct{})\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// success\n\tcase <-time.After(5 * time.Second):\n\t\tt.Fatal(\"test timed out - possible deadlock\")\n\t}\n}\n\nfunc TestPublishInbound_FullBuffer(t *testing.T) {\n\tmb := NewMessageBus()\n\tdefer mb.Close()\n\n\tctx := context.Background()\n\n\t// Fill the buffer\n\tfor i := range defaultBusBufferSize {\n\t\tif err := mb.PublishInbound(ctx, InboundMessage{Content: \"fill\"}); err != nil {\n\t\t\tt.Fatalf(\"fill failed at %d: %v\", i, err)\n\t\t}\n\t}\n\n\t// Buffer is full; publish with short timeout\n\ttimeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)\n\tdefer cancel()\n\n\terr := mb.PublishInbound(timeoutCtx, InboundMessage{Content: \"overflow\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected error when buffer is full and context times out\")\n\t}\n\tif err != context.DeadlineExceeded {\n\t\tt.Fatalf(\"expected context.DeadlineExceeded, got %v\", err)\n\t}\n}\n\nfunc TestCloseIdempotent(t *testing.T) {\n\tmb := NewMessageBus()\n\n\t// Multiple Close calls must not panic\n\tmb.Close()\n\tmb.Close()\n\tmb.Close()\n\n\t// After close, publish should return ErrBusClosed\n\terr := mb.PublishInbound(context.Background(), InboundMessage{Content: \"test\"})\n\tif err != ErrBusClosed {\n\t\tt.Fatalf(\"expected ErrBusClosed after multiple closes, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/bus/types.go",
    "content": "package bus\n\n// Peer identifies the routing peer for a message (direct, group, channel, etc.)\ntype Peer struct {\n\tKind string `json:\"kind\"` // \"direct\" | \"group\" | \"channel\" | \"\"\n\tID   string `json:\"id\"`\n}\n\n// SenderInfo provides structured sender identity information.\ntype SenderInfo struct {\n\tPlatform    string `json:\"platform,omitempty\"`     // \"telegram\", \"discord\", \"slack\", ...\n\tPlatformID  string `json:\"platform_id,omitempty\"`  // raw platform ID, e.g. \"123456\"\n\tCanonicalID string `json:\"canonical_id,omitempty\"` // \"platform:id\" format\n\tUsername    string `json:\"username,omitempty\"`     // username (e.g. @alice)\n\tDisplayName string `json:\"display_name,omitempty\"` // display name\n}\n\ntype InboundMessage struct {\n\tChannel    string            `json:\"channel\"`\n\tSenderID   string            `json:\"sender_id\"`\n\tSender     SenderInfo        `json:\"sender\"`\n\tChatID     string            `json:\"chat_id\"`\n\tContent    string            `json:\"content\"`\n\tMedia      []string          `json:\"media,omitempty\"`\n\tPeer       Peer              `json:\"peer\"`                  // routing peer\n\tMessageID  string            `json:\"message_id,omitempty\"`  // platform message ID\n\tMediaScope string            `json:\"media_scope,omitempty\"` // media lifecycle scope\n\tSessionKey string            `json:\"session_key\"`\n\tMetadata   map[string]string `json:\"metadata,omitempty\"`\n}\n\ntype OutboundMessage struct {\n\tChannel          string `json:\"channel\"`\n\tChatID           string `json:\"chat_id\"`\n\tContent          string `json:\"content\"`\n\tReplyToMessageID string `json:\"reply_to_message_id,omitempty\"`\n}\n\n// MediaPart describes a single media attachment to send.\ntype MediaPart struct {\n\tType        string `json:\"type\"`                   // \"image\" | \"audio\" | \"video\" | \"file\"\n\tRef         string `json:\"ref\"`                    // media store ref, e.g. \"media://abc123\"\n\tCaption     string `json:\"caption,omitempty\"`      // optional caption text\n\tFilename    string `json:\"filename,omitempty\"`     // original filename hint\n\tContentType string `json:\"content_type,omitempty\"` // MIME type hint\n}\n\n// OutboundMediaMessage carries media attachments from Agent to channels via the bus.\ntype OutboundMediaMessage struct {\n\tChannel string      `json:\"channel\"`\n\tChatID  string      `json:\"chat_id\"`\n\tParts   []MediaPart `json:\"parts\"`\n}\n"
  },
  {
    "path": "pkg/channels/README.md",
    "content": "# PicoClaw Channel System: Complete Development Guide\n\n> **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`\n\n---\n\n## Table of Contents\n\n- [Part 1: Architecture Overview](#part-1-architecture-overview)\n- [Part 2: Migration Guide — From main Branch to Refactored Branch](#part-2-migration-guide--from-main-branch-to-refactored-branch)\n- [Part 3: New Channel Development Guide — Implementing a Channel from Scratch](#part-3-new-channel-development-guide--implementing-a-channel-from-scratch)\n- [Part 4: Core Subsystem Details](#part-4-core-subsystem-details)\n- [Part 5: Key Design Decisions and Conventions](#part-5-key-design-decisions-and-conventions)\n- [Appendix: Complete File Listing and Interface Quick Reference](#appendix-complete-file-listing-and-interface-quick-reference)\n\n---\n\n## Part 1: Architecture Overview\n\n### 1.1 Before and After Comparison\n\n**Before Refactor (main branch)**:\n\n```\npkg/channels/\n├── telegram.go          # Each channel directly in the channels package\n├── discord.go\n├── slack.go\n├── manager.go           # Manager directly references each channel type\n├── ...\n```\n\n- All channel implementations lived at the top level of `pkg/channels/`\n- Manager constructed each channel via `switch` or `if-else` chains\n- Routing info like Peer and MessageID was buried in `Metadata map[string]string`\n- No rate limiting or retry on message sending\n- No unified media file lifecycle management\n- Each channel ran its own HTTP server\n- Group chat trigger filtering logic was scattered across channels\n\n**After Refactor (refactor/channel-system branch)**:\n\n```\npkg/channels/\n├── base.go              # BaseChannel shared abstraction layer\n├── interfaces.go        # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder)\n├── README.md            # English documentation\n├── README.zh.md         # Chinese documentation\n├── media.go             # MediaSender optional interface\n├── webhook.go           # WebhookHandler, HealthChecker optional interfaces\n├── errors.go            # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed)\n├── errutil.go           # Error classification helpers\n├── registry.go          # Factory registry (RegisterFactory / getFactory)\n├── manager.go           # Unified orchestration: Worker queues, rate limiting, retries, Typing/Placeholder, shared HTTP\n├── split.go             # Smart long-message splitting (preserves code block integrity)\n├── telegram/            # Each channel in its own sub-package\n│   ├── init.go          # Factory registration\n│   ├── telegram.go      # Implementation\n│   └── telegram_commands.go\n├── discord/\n│   ├── init.go\n│   └── discord.go\n├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/\n│   └── ...\n\npkg/bus/\n├── bus.go               # MessageBus (buffer 64, safe close + drain)\n├── types.go             # Structured message types (Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage)\n\npkg/media/\n├── store.go             # MediaStore interface + FileMediaStore implementation (two-phase release, TTL cleanup)\n\npkg/identity/\n├── identity.go          # Unified user identity: canonical \"platform:id\" format + backward-compatible matching\n```\n\n### 1.2 Message Flow Overview\n\n```\n┌────────────┐      InboundMessage       ┌───────────┐      LLM + Tools      ┌────────────┐\n│  Telegram   │──┐                        │           │                        │            │\n│  Discord    │──┤   PublishInbound()     │           │   PublishOutbound()   │            │\n│  Slack      │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop  │\n│  LINE       │──┤   (buffered chan, 64)  │           │   (buffered chan, 64) │            │\n│  ...        │──┘                        │           │                        │            │\n└────────────┘                            └─────┬─────┘                        └────────────┘\n                                                │\n                            SubscribeOutbound() │  SubscribeOutboundMedia()\n                                                ▼\n                                    ┌───────────────────┐\n                                    │   Manager          │\n                                    │   ├── dispatchOutbound()    Route to Worker queues\n                                    │   ├── dispatchOutboundMedia()\n                                    │   ├── runWorker()           Message split + sendWithRetry()\n                                    │   ├── runMediaWorker()      sendMediaWithRetry()\n                                    │   ├── preSend()             Stop Typing + Undo Reaction + Edit Placeholder\n                                    │   └── runTTLJanitor()       Clean up expired Typing/Placeholder\n                                    └────────┬──────────┘\n                                             │\n                                   channel.Send() / SendMedia()\n                                             │\n                                             ▼\n                                    ┌────────────────┐\n                                    │ Platform APIs   │\n                                    └────────────────┘\n```\n\n### 1.3 Key Design Principles\n\n| Principle | Description |\n|-----------|-------------|\n| **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package |\n| **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling |\n| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`), discovered by Manager via runtime type assertions |\n| **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage |\n| **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy |\n| **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send |\n\n---\n\n## Part 2: Migration Guide — From main Branch to Refactored Branch\n\n### 2.1 If You Have Unmerged Channel Changes\n\n#### Step 1: Identify which files you modified\n\nOn the main branch, channel files were directly in `pkg/channels/` top level, e.g.:\n- `pkg/channels/telegram.go`\n- `pkg/channels/discord.go`\n\nAfter refactoring, these files have been removed and code moved to corresponding sub-packages:\n- `pkg/channels/telegram/telegram.go`\n- `pkg/channels/discord/discord.go`\n\n#### Step 2: Understand the structural change mapping\n\n| main branch file | Refactored branch location | Changes |\n|---|---|---|\n| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | Package name changed from `channels` to `telegram` |\n| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | Same as above |\n| `pkg/channels/manager.go` | `pkg/channels/manager.go` | Extensively rewritten |\n| _(did not exist)_ | `pkg/channels/base.go` | New shared abstraction layer |\n| _(did not exist)_ | `pkg/channels/registry.go` | New factory registry |\n| _(did not exist)_ | `pkg/channels/errors.go` + `errutil.go` | New error classification system |\n| _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces |\n| _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface |\n| _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker |\n| _(did not exist)_ | `pkg/channels/whatsapp_native/` | New WhatsApp native mode (whatsmeow) |\n| _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) |\n| _(did not exist)_ | `pkg/bus/types.go` | New structured message types |\n| _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management |\n| _(did not exist)_ | `pkg/identity/identity.go` | New unified user identity |\n\n#### Step 3: Migrate your channel code\n\nUsing Telegram as an example, the main changes are:\n\n**3a. Package declaration and imports**\n\n```go\n// Old code (main branch)\npackage channels\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// New code (refactored branch)\npackage telegram\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"     // Reference parent package\n    \"github.com/sipeed/picoclaw/pkg/config\"\n    \"github.com/sipeed/picoclaw/pkg/identity\"      // New\n    \"github.com/sipeed/picoclaw/pkg/media\"          // New (if media support needed)\n)\n```\n\n**3b. Struct embeds BaseChannel**\n\n```go\n// Old code: directly held bus, config, etc. fields\ntype TelegramChannel struct {\n    bus       *bus.MessageBus\n    config    *config.Config\n    running   bool\n    allowList []string\n    // ...\n}\n\n// New code: embed BaseChannel, which provides bus, running, allowList, etc.\ntype TelegramChannel struct {\n    *channels.BaseChannel          // Embed shared abstraction\n    bot    *telego.Bot\n    config *config.Config\n    // ... only channel-specific fields\n}\n```\n\n**3c. Constructor**\n\n```go\n// Old code: direct assignment\nfunc NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {\n    return &TelegramChannel{\n        bus:       bus,\n        config:    cfg,\n        allowList: cfg.Channels.Telegram.AllowFrom,\n        // ...\n    }, nil\n}\n\n// New code: use NewBaseChannel + functional options\nfunc NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {\n    base := channels.NewBaseChannel(\n        \"telegram\",                    // Name\n        cfg.Channels.Telegram,         // Raw config (any type)\n        bus,                           // Message bus\n        cfg.Channels.Telegram.AllowFrom, // Allow list\n        channels.WithMaxMessageLength(4096),                     // Platform message length limit\n        channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config\n        channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // Reasoning chain routing\n    )\n    return &TelegramChannel{\n        BaseChannel: base,\n        bot:         bot,\n        config:      cfg,\n    }, nil\n}\n```\n\n**3d. Start/Stop lifecycle**\n\n```go\n// New code: use SetRunning atomic operation\nfunc (c *TelegramChannel) Start(ctx context.Context) error {\n    // ... initialize bot, webhook, etc.\n    c.SetRunning(true)    // Must be called after ready\n    go bh.Start()\n    return nil\n}\n\nfunc (c *TelegramChannel) Stop(ctx context.Context) error {\n    c.SetRunning(false)   // Must be called before cleanup\n    // ... stop bot handler, cancel context\n    return nil\n}\n```\n\n**3e. Send method error returns**\n\n```go\n// Old code: returns plain error\nfunc (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    if !c.running { return fmt.Errorf(\"not running\") }\n    // ...\n    if err != nil { return err }\n}\n\n// New code: must return sentinel errors for Manager to determine retry strategy\nfunc (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    if !c.IsRunning() {\n        return channels.ErrNotRunning    // ← Manager will not retry\n    }\n    // ...\n    if err != nil {\n        // Use ClassifySendError to wrap error based on HTTP status code\n        return channels.ClassifySendError(statusCode, err)\n        // Or manually wrap:\n        // return fmt.Errorf(\"%w: %v\", channels.ErrTemporary, err)\n        // return fmt.Errorf(\"%w: %v\", channels.ErrRateLimit, err)\n        // return fmt.Errorf(\"%w: %v\", channels.ErrSendFailed, err)\n    }\n    return nil\n}\n```\n\n**3f. Message reception (Inbound)**\n\n```go\n// Old code: directly construct InboundMessage and publish\nmsg := bus.InboundMessage{\n    Channel:  \"telegram\",\n    SenderID: senderID,\n    ChatID:   chatID,\n    Content:  content,\n    Metadata: map[string]string{\n        \"peer_kind\": \"group\",     // Routing info buried in metadata\n        \"peer_id\":   chatID,\n        \"message_id\": msgID,\n    },\n}\nc.bus.PublishInbound(ctx, msg)\n\n// New code: use BaseChannel.HandleMessage with structured fields\nsender := bus.SenderInfo{\n    Platform:    \"telegram\",\n    PlatformID:  strconv.FormatInt(from.ID, 10),\n    CanonicalID: identity.BuildCanonicalID(\"telegram\", strconv.FormatInt(from.ID, 10)),\n    Username:    from.Username,\n    DisplayName: from.FirstName,\n}\n\npeer := bus.Peer{\n    Kind: \"group\",    // or \"direct\"\n    ID:   chatID,\n}\n\n// HandleMessage internally calls IsAllowedSender for permission checks, builds MediaScope, and publishes to bus\nc.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender)\n```\n\n**3g. Add factory registration (required)**\n\nCreate `init.go` for your channel:\n\n```go\n// pkg/channels/telegram/init.go\npackage telegram\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n    channels.RegisterFactory(\"telegram\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n        return NewTelegramChannel(cfg, b)\n    })\n}\n```\n\n**3h. Import sub-package in Gateway**\n\n```go\n// cmd/picoclaw/internal/gateway/helpers.go\nimport (\n    _ \"github.com/sipeed/picoclaw/pkg/channels/telegram\"   // Triggers init() registration\n    _ \"github.com/sipeed/picoclaw/pkg/channels/discord\"\n    _ \"github.com/sipeed/picoclaw/pkg/channels/your_new_channel\"  // New addition\n)\n```\n\n#### Step 4: Migrate bus message usage\n\nIf your code directly reads routing fields from `InboundMessage.Metadata`:\n\n```go\n// Old code\npeerKind := msg.Metadata[\"peer_kind\"]\npeerID   := msg.Metadata[\"peer_id\"]\nmsgID    := msg.Metadata[\"message_id\"]\n\n// New code\npeerKind := msg.Peer.Kind      // First-class field\npeerID   := msg.Peer.ID        // First-class field\nmsgID    := msg.MessageID       // First-class field\nsender   := msg.Sender          // bus.SenderInfo struct\nscope    := msg.MediaScope       // Media lifecycle scope\n```\n\n#### Step 5: Migrate allow-list checks\n\n```go\n// Old code\nif !c.isAllowed(senderID) { return }\n\n// New code: prefer structured check\nif !c.IsAllowedSender(sender) { return }\n// Or fall back to string check:\nif !c.IsAllowed(senderID) { return }\n```\n\n`BaseChannel.HandleMessage` already handles this logic internally — no need to duplicate the check in your channel.\n\n### 2.2 If You Have Manager Modifications\n\nThe Manager has been completely rewritten. Your modifications will need to account for the new architecture:\n\n| Old Manager Responsibility | New Manager Responsibility |\n|---|---|\n| Directly construct channels (switch/if-else) | Look up and construct via factory registry |\n| Directly call channel.Send | Per-channel Worker queues + rate limiting + retries |\n| No message splitting | Automatic splitting based on MaxMessageLength |\n| Each channel runs its own HTTP server | Unified shared HTTP server |\n| No Typing/Placeholder management | Unified preSend handles Typing stop + Reaction undo + Placeholder edit; inbound-side BaseChannel.HandleMessage auto-orchestrates Typing/Reaction/Placeholder |\n| No TTL cleanup | runTTLJanitor periodically cleans up expired Typing/Reaction/Placeholder entries |\n\n### 2.3 If You Have Agent Loop Modifications\n\nMain changes to the Agent Loop:\n\n1. **MediaStore injection**: `agentLoop.SetMediaStore(mediaStore)` — Agent resolves media references produced by tools via MediaStore\n2. **ChannelManager injection**: `agentLoop.SetChannelManager(channelManager)` — Agent can query channel state\n3. **OutboundMediaMessage**: Agent now sends media messages via `bus.PublishOutboundMedia()` instead of embedding them in text replies\n4. **extractPeer**: Routing uses `msg.Peer` structured fields instead of Metadata lookups\n\n---\n\n## Part 3: New Channel Development Guide — Implementing a Channel from Scratch\n\n### 3.1 Minimum Implementation Checklist\n\nTo add a new chat platform (e.g., `matrix`), you need to:\n\n1. ✅ Create sub-package directory `pkg/channels/matrix/`\n2. ✅ Create `init.go` — factory registration\n3. ✅ Create `matrix.go` — channel implementation\n4. ✅ Add blank import in Gateway helpers\n5. ✅ Add config check in Manager.initChannels()\n6. ✅ Add config struct in `pkg/config/`\n\n### 3.2 Complete Template\n\n#### `pkg/channels/matrix/init.go`\n\n```go\npackage matrix\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n    channels.RegisterFactory(\"matrix\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n        return NewMatrixChannel(cfg, b)\n    })\n}\n```\n\n#### `pkg/channels/matrix/matrix.go`\n\n```go\npackage matrix\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n    \"github.com/sipeed/picoclaw/pkg/identity\"\n    \"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// MatrixChannel implements channels.Channel for the Matrix protocol.\ntype MatrixChannel struct {\n    *channels.BaseChannel            // Must embed\n    config *config.Config\n    ctx    context.Context\n    cancel context.CancelFunc\n    // ... Matrix SDK client, etc.\n}\n\nfunc NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) {\n    matrixCfg := cfg.Channels.Matrix // Assumes this field exists in config\n\n    base := channels.NewBaseChannel(\n        \"matrix\",                           // Channel name (globally unique)\n        matrixCfg,                          // Raw config\n        msgBus,                             // Message bus\n        matrixCfg.AllowFrom,                // Allow list\n        channels.WithMaxMessageLength(65536), // Matrix message length limit\n        channels.WithGroupTrigger(matrixCfg.GroupTrigger),\n        channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // Reasoning chain routing (optional)\n    )\n\n    return &MatrixChannel{\n        BaseChannel: base,\n        config:      cfg,\n    }, nil\n}\n\n// ========== Required Channel Interface Methods ==========\n\nfunc (c *MatrixChannel) Start(ctx context.Context) error {\n    c.ctx, c.cancel = context.WithCancel(ctx)\n\n    // 1. Initialize Matrix client\n    // 2. Start listening for messages\n    // 3. Mark as running\n    c.SetRunning(true)\n\n    logger.InfoC(\"matrix\", \"Matrix channel started\")\n    return nil\n}\n\nfunc (c *MatrixChannel) Stop(ctx context.Context) error {\n    c.SetRunning(false)\n\n    if c.cancel != nil {\n        c.cancel()\n    }\n\n    logger.InfoC(\"matrix\", \"Matrix channel stopped\")\n    return nil\n}\n\nfunc (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    // 1. Check running state\n    if !c.IsRunning() {\n        return channels.ErrNotRunning\n    }\n\n    // 2. Send message to Matrix\n    err := c.sendToMatrix(ctx, msg.ChatID, msg.Content)\n    if err != nil {\n        // 3. Must use error classification wrapping\n        //    If you have an HTTP status code:\n        //    return channels.ClassifySendError(statusCode, err)\n        //    If it's a network error:\n        //    return channels.ClassifyNetError(err)\n        //    If manual classification is needed:\n        return fmt.Errorf(\"%w: %v\", channels.ErrTemporary, err)\n    }\n\n    return nil\n}\n\n// ========== Incoming Message Handling ==========\n\nfunc (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) {\n    // 1. Construct structured sender identity\n    sender := bus.SenderInfo{\n        Platform:    \"matrix\",\n        PlatformID:  senderID,\n        CanonicalID: identity.BuildCanonicalID(\"matrix\", senderID),\n        Username:    senderID,\n        DisplayName: displayName,\n    }\n\n    // 2. Determine Peer type (direct vs group)\n    peer := bus.Peer{\n        Kind: \"group\",    // or \"direct\"\n        ID:   roomID,\n    }\n\n    // 3. Group chat filtering (if applicable)\n    isGroup := peer.Kind == \"group\"\n    if isGroup {\n        isMentioned := false // Detect @mentions based on platform specifics\n        shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content)\n        if !shouldRespond {\n            return\n        }\n        content = cleanContent\n    }\n\n    // 4. Handle media attachments (if any)\n    var mediaRefs []string\n    store := c.GetMediaStore()\n    if store != nil {\n        // Download attachment locally → store.Store() → get ref\n        // mediaRefs = append(mediaRefs, ref)\n    }\n\n    // 5. Call HandleMessage to publish to bus\n    //    HandleMessage internally will:\n    //    - Check IsAllowedSender/IsAllowed\n    //    - Build MediaScope\n    //    - Publish InboundMessage\n    c.HandleMessage(\n        c.ctx,\n        peer,\n        msgID,                   // Platform message ID\n        senderID,                // Raw sender ID\n        roomID,                  // Chat/room ID\n        content,                 // Message content\n        mediaRefs,               // Media reference list\n        nil,                     // Extra metadata (usually nil)\n        sender,                  // SenderInfo (variadic parameter)\n    )\n}\n\n// ========== Internal Methods ==========\n\nfunc (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error {\n    // Actual Matrix SDK call\n    return nil\n}\n```\n\n### 3.3 Optional Capability Interfaces\n\nDepending on platform capabilities, your channel can optionally implement the following interfaces:\n\n#### MediaSender — Send Media Attachments\n\n```go\n// If the platform supports sending images/files/audio/video\nfunc (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n    if !c.IsRunning() {\n        return channels.ErrNotRunning\n    }\n\n    store := c.GetMediaStore()\n    if store == nil {\n        return fmt.Errorf(\"no media store: %w\", channels.ErrSendFailed)\n    }\n\n    for _, part := range msg.Parts {\n        localPath, err := store.Resolve(part.Ref)\n        if err != nil {\n            logger.ErrorCF(\"matrix\", \"Failed to resolve media\", map[string]any{\n                \"ref\": part.Ref, \"error\": err.Error(),\n            })\n            continue\n        }\n\n        // Call the appropriate API based on part.Type (\"image\"|\"audio\"|\"video\"|\"file\")\n        switch part.Type {\n        case \"image\":\n            // Upload image to Matrix\n        default:\n            // Upload file to Matrix\n        }\n    }\n    return nil\n}\n```\n\n#### TypingCapable — Typing Indicator\n\n```go\n// If the platform supports \"typing...\" indicators\nfunc (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) {\n    // Call Matrix API to send typing indicator\n    // The returned stop function must be idempotent\n    stopped := false\n    return func() {\n        if !stopped {\n            stopped = true\n            // Call Matrix API to stop typing\n        }\n    }, nil\n}\n```\n\n#### ReactionCapable — Message Reaction Indicator\n\n```go\n// If the platform supports adding emoji reactions to inbound messages (e.g., Slack's 👀, OneBot's emoji 289)\nfunc (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) {\n    // Call Matrix API to add reaction to message\n    // The returned undo function removes the reaction, must be idempotent\n    err = c.addReaction(chatID, messageID, \"eyes\")\n    if err != nil {\n        return func() {}, err\n    }\n    return func() {\n        c.removeReaction(chatID, messageID, \"eyes\")\n    }, nil\n}\n```\n\n#### MessageEditor — Message Editing\n\n```go\n// If the platform supports editing sent messages (used for Placeholder replacement)\nfunc (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n    // Call Matrix API to edit message\n    return nil\n}\n```\n\n#### PlaceholderCapable — Placeholder Messages\n\n```go\n// If the platform supports sending placeholder messages (e.g. \"Thinking... 💭\"),\n// and the channel also implements MessageEditor, then Manager's preSend will\n// automatically edit the placeholder into the final response on outbound.\n// SendPlaceholder checks PlaceholderConfig.Enabled internally;\n// returning (\"\", nil) means skip.\nfunc (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n    cfg := c.config.Channels.Matrix.Placeholder\n    if !cfg.Enabled {\n        return \"\", nil\n    }\n    text := cfg.Text\n    if text == \"\" {\n        text = \"Thinking... 💭\"\n    }\n    // Call Matrix API to send placeholder message\n    msg, err := c.sendText(ctx, chatID, text)\n    if err != nil {\n        return \"\", err\n    }\n    return msg.ID, nil\n}\n```\n\n#### WebhookHandler — HTTP Webhook Reception\n\n```go\n// If the channel receives messages via webhook (rather than long-polling/WebSocket)\nfunc (c *MatrixChannel) WebhookPath() string {\n    return \"/webhook/matrix\"   // Path will be registered on the shared HTTP server\n}\n\nfunc (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n    // Handle webhook request\n}\n```\n\n#### HealthChecker — Health Check Endpoint\n\n```go\nfunc (c *MatrixChannel) HealthPath() string {\n    return \"/health/matrix\"\n}\n\nfunc (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {\n    if c.IsRunning() {\n        w.WriteHeader(http.StatusOK)\n        w.Write([]byte(\"OK\"))\n    } else {\n        w.WriteHeader(http.StatusServiceUnavailable)\n    }\n}\n```\n\n### 3.4 Inbound-side Typing/Reaction/Placeholder Auto-orchestration\n\n`BaseChannel.HandleMessage` automatically detects whether the channel implements `TypingCapable`, `ReactionCapable`, and/or `PlaceholderCapable` **before** publishing the inbound message, and triggers the corresponding indicators. The three pipelines are completely independent and do not interfere with each other:\n\n```go\n// Automatically executed inside BaseChannel.HandleMessage (no manual calls needed):\nif c.owner != nil && c.placeholderRecorder != nil {\n    // Typing — independent pipeline\n    if tc, ok := c.owner.(TypingCapable); ok {\n        if stop, err := tc.StartTyping(ctx, chatID); err == nil {\n            c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)\n        }\n    }\n    // Reaction — independent pipeline\n    if rc, ok := c.owner.(ReactionCapable); ok && messageID != \"\" {\n        if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {\n            c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)\n        }\n    }\n    // Placeholder — independent pipeline\n    if pc, ok := c.owner.(PlaceholderCapable); ok {\n        if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != \"\" {\n            c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)\n        }\n    }\n}\n```\n\n**This means**:\n- Channels implementing `TypingCapable` (Telegram, Discord, LINE, Pico) do not need to manually call `StartTyping` + `RecordTypingStop` in `handleMessage`\n- Channels implementing `ReactionCapable` (Slack, OneBot) do not need to manually call `AddReaction` + `RecordTypingStop` in `handleMessage`\n- Channels implementing `PlaceholderCapable` (Telegram, Discord, Pico) do not need to manually send placeholder messages and call `RecordPlaceholder` in `handleMessage`\n- Channels only need to implement the corresponding interface; `HandleMessage` handles orchestration automatically\n- Channels that don't implement these interfaces are unaffected (type assertions will fail and be skipped)\n- `PlaceholderCapable`'s `SendPlaceholder` method internally decides whether to send based on the configured `PlaceholderConfig.Enabled`; returning `(\"\", nil)` skips registration\n\n**Owner Injection**: Manager automatically calls `SetOwner(ch)` in `initChannel` to inject the concrete channel into BaseChannel — no manual setup required from developers.\n\nWhen the Agent finishes processing a message, Manager's `preSend` automatically:\n1. Calls the recorded `stop()` to stop Typing\n2. Calls the recorded `undo()` to undo Reaction\n3. If there is a Placeholder and the channel implements `MessageEditor`, attempts to edit the Placeholder with the final reply (skipping Send)\n\n### 3.5 Register Configuration and Gateway Integration\n\n#### Add configuration in `pkg/config/config.go`\n\n```go\ntype ChannelsConfig struct {\n    // ... existing channels\n    Matrix  MatrixChannelConfig  `json:\"matrix\"`\n}\n\ntype MatrixChannelConfig struct {\n    Enabled    bool     `json:\"enabled\"`\n    HomeServer string   `json:\"home_server\"`\n    Token      string   `json:\"token\"`\n    AllowFrom  []string `json:\"allow_from\"`\n    GroupTrigger GroupTriggerConfig `json:\"group_trigger\"`\n    Placeholder  PlaceholderConfig  `json:\"placeholder\"`\n    ReasoningChannelID string `json:\"reasoning_channel_id\"`\n}\n```\n\n#### Add entry in Manager.initChannels()\n\n```go\n// In the initChannels() method of pkg/channels/manager.go\nif m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != \"\" {\n    m.initChannel(\"matrix\", \"Matrix\")\n}\n```\n\n> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config:\n> ```go\n> if cfg.UseNative {\n>     m.initChannel(\"whatsapp_native\", \"WhatsApp Native\")\n> } else {\n>     m.initChannel(\"whatsapp\", \"WhatsApp\")\n> }\n> ```\n\n#### Add blank import in Gateway\n\n```go\n// cmd/picoclaw/internal/gateway/helpers.go\nimport (\n    _ \"github.com/sipeed/picoclaw/pkg/channels/matrix\"\n)\n```\n\n---\n\n## Part 4: Core Subsystem Details\n\n### 4.1 MessageBus\n\n**Files**: `pkg/bus/bus.go`, `pkg/bus/types.go`\n\n```go\ntype MessageBus struct {\n    inbound       chan InboundMessage       // buffer = 64\n    outbound      chan OutboundMessage      // buffer = 64\n    outboundMedia chan OutboundMediaMessage  // buffer = 64\n    done          chan struct{}             // Close signal\n    closed        atomic.Bool              // Prevents double-close\n}\n```\n\n**Key Behaviors**:\n\n| Method | Behavior |\n|--------|----------|\n| `PublishInbound(ctx, msg)` | Check closed → send to inbound channel → block/timeout/close |\n| `ConsumeInbound(ctx)` | Read from inbound → block/close/cancel |\n| `PublishOutbound(ctx, msg)` | Send to outbound channel |\n| `SubscribeOutbound(ctx)` | Read from outbound (called by Manager dispatcher) |\n| `PublishOutboundMedia(ctx, msg)` | Send to outboundMedia channel |\n| `SubscribeOutboundMedia(ctx)` | Read from outboundMedia (called by Manager media dispatcher) |\n| `Close()` | CAS close → close(done) → drain all channels (**does not close the channels themselves** to avoid concurrent send-on-closed panic) |\n\n**Design Notes**:\n- Buffer size increased from 16 to 64 to reduce blocking under burst load\n- `Close()` does not close the underlying channels (only closes the `done` signal channel), because there may be concurrent `Publish` goroutines\n- Drain loop ensures buffered messages are not silently dropped\n\n### 4.2 Structured Message Types\n\n**File**: `pkg/bus/types.go`\n\n```go\n// Routing peer\ntype Peer struct {\n    Kind string `json:\"kind\"`  // \"direct\" | \"group\" | \"channel\" | \"\"\n    ID   string `json:\"id\"`\n}\n\n// Sender identity information\ntype SenderInfo struct {\n    Platform    string `json:\"platform,omitempty\"`     // \"telegram\", \"discord\", ...\n    PlatformID  string `json:\"platform_id,omitempty\"`  // Platform-native ID\n    CanonicalID string `json:\"canonical_id,omitempty\"` // \"platform:id\" canonical format\n    Username    string `json:\"username,omitempty\"`\n    DisplayName string `json:\"display_name,omitempty\"`\n}\n\n// Inbound message\ntype InboundMessage struct {\n    Channel    string            // Source channel name\n    SenderID   string            // Sender ID (prefer CanonicalID)\n    Sender     SenderInfo        // Structured sender info\n    ChatID     string            // Chat/room ID\n    Content    string            // Message text\n    Media      []string          // Media reference list (media://...)\n    Peer       Peer              // Routing peer (first-class field)\n    MessageID  string            // Platform message ID (first-class field)\n    MediaScope string            // Media lifecycle scope\n    SessionKey string            // Session key\n    Metadata   map[string]string // Only for channel-specific extensions\n}\n\n// Outbound text message\ntype OutboundMessage struct {\n    Channel string\n    ChatID  string\n    Content string\n}\n\n// Outbound media message\ntype OutboundMediaMessage struct {\n    Channel string\n    ChatID  string\n    Parts   []MediaPart\n}\n\n// Media part\ntype MediaPart struct {\n    Type        string // \"image\" | \"audio\" | \"video\" | \"file\"\n    Ref         string // \"media://uuid\"\n    Caption     string\n    Filename    string\n    ContentType string\n}\n```\n\n### 4.3 BaseChannel\n\n**File**: `pkg/channels/base.go`\n\nBaseChannel is the shared abstraction layer for all channels, providing the following capabilities:\n\n| Method/Feature | Description |\n|---|---|\n| `Name() string` | Channel name |\n| `IsRunning() bool` | Atomically read running state |\n| `SetRunning(bool)` | Atomically set running state |\n| `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited |\n| `ReasoningChannelID() string` | Reasoning chain routing target channel ID (empty = no routing) |\n| `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `\"id\\|username\"` and `\"@username\"` formats) |\n| `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) |\n| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic |\n| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus |\n| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager |\n| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager |\n| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction/Placeholder type assertions in HandleMessage) |\n\n**Functional Options**:\n\n```go\nchannels.WithMaxMessageLength(4096)        // Set platform message length limit\nchannels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration\nchannels.WithReasoningChannelID(id)        // Set reasoning chain routing target channel\n```\n\n### 4.4 Factory Registry\n\n**File**: `pkg/channels/registry.go`\n\n```go\ntype ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)\n\nfunc RegisterFactory(name string, f ChannelFactory)   // Called in sub-package init()\nfunc getFactory(name string) (ChannelFactory, bool)    // Called internally by Manager\n```\n\nThe factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them.\n\n### 4.5 Error Classification and Retries\n\n**Files**: `pkg/channels/errors.go`, `pkg/channels/errutil.go`\n\n#### Sentinel Errors\n\n```go\nvar (\n    ErrNotRunning = errors.New(\"channel not running\")   // Permanent: do not retry\n    ErrRateLimit  = errors.New(\"rate limited\")           // Fixed delay: retry after 1s\n    ErrTemporary  = errors.New(\"temporary failure\")      // Exponential backoff: 500ms * 2^attempt, max 8s\n    ErrSendFailed = errors.New(\"send failed\")            // Permanent: do not retry\n)\n```\n\n#### Error Classification Helpers\n\n```go\n// Automatically classify based on HTTP status code\nfunc ClassifySendError(statusCode int, rawErr error) error {\n    // 429 → ErrRateLimit\n    // 5xx → ErrTemporary\n    // 4xx → ErrSendFailed\n}\n\n// Wrap network errors as temporary\nfunc ClassifyNetError(err error) error {\n    // → ErrTemporary\n}\n```\n\n#### Manager Retry Strategy (`sendWithRetry`)\n\n```\nMax retries:      3\nRate limit delay:  1 second\nBase backoff:      500 milliseconds\nMax backoff:       8 seconds\n\nRetry logic:\n  ErrNotRunning → Fail immediately, no retry\n  ErrSendFailed → Fail immediately, no retry\n  ErrRateLimit  → Wait 1s → retry\n  ErrTemporary  → Wait 500ms * 2^attempt (max 8s) → retry\n  Other unknown → Wait 500ms * 2^attempt (max 8s) → retry\n```\n\n### 4.6 Manager Orchestration\n\n**File**: `pkg/channels/manager.go`\n\n#### Per-channel Worker Architecture\n\n```go\ntype channelWorker struct {\n    ch         Channel                      // Channel instance\n    queue      chan bus.OutboundMessage      // Outbound text queue (buffered 16)\n    mediaQueue chan bus.OutboundMediaMessage // Outbound media queue (buffered 16)\n    done       chan struct{}                // Text worker completion signal\n    mediaDone  chan struct{}                // Media worker completion signal\n    limiter    *rate.Limiter                // Per-channel rate limiter\n}\n```\n\n#### Per-channel Rate Limit Configuration\n\n```go\nvar channelRateConfig = map[string]float64{\n    \"telegram\": 20,   // 20 msg/s\n    \"discord\":  1,    // 1 msg/s\n    \"slack\":    1,    // 1 msg/s\n    \"line\":     10,   // 10 msg/s\n}\n// Default: 10 msg/s\n// burst = max(1, ceil(rate/2))\n```\n\n#### Lifecycle Management\n\n```\nStartAll:\n  1. Iterate registered channels → channel.Start(ctx)\n  2. Create channelWorker for each successfully started channel\n  3. Start goroutines:\n     - runWorker (per-channel outbound text)\n     - runMediaWorker (per-channel outbound media)\n     - dispatchOutbound (route from bus to worker queues)\n     - dispatchOutboundMedia (route from bus to media worker queues)\n     - runTTLJanitor (every 10s clean up expired typing/reaction/placeholder)\n  4. Start shared HTTP server (if configured)\n\nStopAll:\n  1. Shut down shared HTTP server (5s timeout)\n  2. Cancel dispatcher context\n  3. Close text worker queues → wait for drain to complete\n  4. Close media worker queues → wait for drain to complete\n  5. Stop each channel (channel.Stop)\n```\n\n#### Typing/Reaction/Placeholder Management\n\n```go\n// Manager implements PlaceholderRecorder interface\nfunc (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string)\nfunc (m *Manager) RecordTypingStop(channel, chatID string, stop func())\nfunc (m *Manager) RecordReactionUndo(channel, chatID string, undo func())\n\n// Inbound side: BaseChannel.HandleMessage auto-orchestrates\n// BaseChannel.HandleMessage, before PublishInbound, auto-triggers via owner type assertions:\n//   - TypingCapable.StartTyping       → RecordTypingStop\n//   - ReactionCapable.ReactToMessage  → RecordReactionUndo\n//   - PlaceholderCapable.SendPlaceholder → RecordPlaceholder\n// All three are independent and do not interfere with each other. Channels don't need to call these manually.\n\n// Outbound side: pre-send processing\nfunc (m *Manager) preSend(ctx, name, msg, ch) bool {\n    key := name + \":\" + msg.ChatID\n    // 1. Stop Typing (call stored stop function)\n    // 2. Undo Reaction (call stored undo function)\n    // 3. Attempt to edit Placeholder (if channel implements MessageEditor)\n    //    Success → return true (skip Send)\n    //    Failure → return false (proceed with Send)\n}\n```\n\nManager storage is fully separated; three pipelines do not interfere:\n\n```go\nManager {\n    typingStops   sync.Map  // \"channel:chatID\" → typingEntry    ← manages TypingCapable\n    reactionUndos sync.Map  // \"channel:chatID\" → reactionEntry  ← manages ReactionCapable\n    placeholders  sync.Map  // \"channel:chatID\" → placeholderEntry\n}\n```\n\nTTL Cleanup:\n- Typing stop functions: 5-minute TTL (auto-calls stop and deletes on expiry)\n- Reaction undo functions: 5-minute TTL (auto-calls undo and deletes on expiry)\n- Placeholder IDs: 10-minute TTL (deletes on expiry)\n- Cleanup interval: 10 seconds\n\n### 4.7 Message Splitting\n\n**File**: `pkg/channels/split.go`\n\n`SplitMessage(content string, maxLen int) []string`\n\nSmart splitting strategy:\n1. Calculate effective split point = maxLen - 10% buffer (to reserve space for code block closure)\n2. Prefer splitting at newlines\n3. Otherwise split at spaces/tabs\n4. Detect unclosed code blocks (` ``` `)\n5. If a code block is unclosed:\n   - Attempt to extend to maxLen to include the closing fence\n   - If the code block is too long, inject close/reopen fences (`\\n```\\n` + header)\n   - Last resort: split before the code block starts\n\n### 4.8 MediaStore\n\n**File**: `pkg/media/store.go`\n\n```go\ntype MediaStore interface {\n    Store(localPath string, meta MediaMeta, scope string) (ref string, err error)\n    Resolve(ref string) (localPath string, err error)\n    ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)\n    ReleaseAll(scope string) error\n}\n```\n\n**FileMediaStore Implementation**:\n- Pure in-memory mapping, no file copy/move\n- Reference format: `media://<uuid>`\n- Scope format: `channel:chatID:messageID` (generated by `BuildMediaScope`)\n- **Two-phase operation**:\n  - Phase 1 (holding lock): collect and delete entries from map\n  - Phase 2 (no lock): delete files from disk\n  - Purpose: minimize lock contention\n- **TTL Cleanup**: `NewFileMediaStoreWithCleanup` → `Start()` launches background cleanup goroutine\n- Cleanup interval and max TTL are controlled by configuration\n\n### 4.9 Identity\n\n**File**: `pkg/identity/identity.go`\n\n```go\n// Build canonical ID\nfunc BuildCanonicalID(platform, platformID string) string\n// → \"telegram:123456\"\n\n// Parse canonical ID\nfunc ParseCanonicalID(canonical string) (platform, id string, ok bool)\n\n// Match against allow list (backward-compatible)\nfunc MatchAllowed(sender bus.SenderInfo, allowed string) bool\n```\n\n`MatchAllowed` supported allow-list formats:\n| Format | Matching |\n|--------|----------|\n| `\"123456\"` | Matches `sender.PlatformID` |\n| `\"@alice\"` | Matches `sender.Username` |\n| `\"123456\\|alice\"` | Matches PlatformID or Username (legacy format compatibility) |\n| `\"telegram:123456\"` | Exact match on `sender.CanonicalID` (new format) |\n\n### 4.10 Shared HTTP Server\n\n**File**: `pkg/channels/manager.go`'s `SetupHTTPServer`\n\nManager creates a single `http.Server` and auto-discovers and registers:\n- Channels implementing `WebhookHandler` → mounted at `wh.WebhookPath()`\n- Channels implementing `HealthChecker` → mounted at `hc.HealthPath()`\n- Global health endpoint registered by `health.Server.RegisterOnMux`\n\nTimeout configuration: ReadTimeout = 30s, WriteTimeout = 30s\n\n---\n\n## Part 5: Key Design Decisions and Conventions\n\n### 5.1 Mandatory Conventions\n\n1. **Error classification is a contract**: A channel's `Send` method **must** return sentinel errors (or wrap them). Manager's retry strategy relies entirely on `errors.Is` checks. Returning unclassified errors will cause Manager to treat them as \"unknown errors\" (exponential backoff retry).\n\n2. **SetRunning is a lifecycle signal**: **Must** call `c.SetRunning(true)` after successful `Start`, and **must** call `c.SetRunning(false)` at the beginning of `Stop`. **Must** check `c.IsRunning()` in `Send` and return `ErrNotRunning`.\n\n3. **HandleMessage includes permission checks**: Do not perform your own permission checks before calling `HandleMessage` (unless you need platform-specific preprocessing before the check). `HandleMessage` already calls `IsAllowedSender`/`IsAllowed` internally.\n\n4. **Message splitting is handled by Manager**: A channel's `Send` method does not need to handle long message splitting. Manager automatically splits based on `MaxMessageLength()` before calling `Send`. Channels only need to declare the limit via `WithMaxMessageLength`.\n\n5. **Typing/Reaction/Placeholder is handled by BaseChannel + Manager automatically**: A channel's `Send` method does not need to manage Typing stop, Reaction undo, or Placeholder editing. `BaseChannel.HandleMessage` auto-triggers `TypingCapable`, `ReactionCapable`, and `PlaceholderCapable` on the inbound side (via `owner` type assertions); Manager's `preSend` auto-stops Typing, undoes Reaction, and edits Placeholder on the outbound side. Channels only need to implement the corresponding interfaces.\n\n6. **Factory registration belongs in init()**: Each sub-package must have an `init.go` file calling `channels.RegisterFactory`. Gateway must trigger registration via blank imports (`_ \"pkg/channels/xxx\"`).\n\n### 5.2 Metadata Field Usage Conventions\n\n**Do NOT put the following information in Metadata anymore**:\n- `peer_kind` / `peer_id` → Use `InboundMessage.Peer`\n- `message_id` → Use `InboundMessage.MessageID`\n- `sender_platform` / `sender_username` → Use `InboundMessage.Sender`\n\n**Metadata should only be used for**:\n- Channel-specific extension information (e.g., Telegram's `reply_to_message_id`)\n- Temporary information that doesn't fit into structured fields\n\n### 5.3 Concurrency Safety Conventions\n\n- `BaseChannel.running`: Uses `atomic.Bool`, thread-safe\n- `Manager.channels` / `Manager.workers`: Protected by `sync.RWMutex`\n- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`: Uses `sync.Map`\n- `MessageBus.closed`: Uses `atomic.Bool`\n- `FileMediaStore`: Uses `sync.RWMutex`, two-phase operation to minimize lock-hold time\n- Channel Worker queue: Go channel, inherently concurrent-safe\n\n### 5.4 Testing Conventions\n\nExisting test files:\n- `pkg/channels/base_test.go` — BaseChannel unit tests\n- `pkg/channels/manager_test.go` — Manager unit tests\n- `pkg/channels/split_test.go` — Message splitting tests\n- `pkg/channels/errors_test.go` — Error type tests\n- `pkg/channels/errutil_test.go` — Error classification tests\n\nTo add tests for a new channel:\n```bash\ngo test ./pkg/channels/matrix/ -v              # Sub-package tests\ngo test ./pkg/channels/ -run TestSpecific -v    # Framework tests\nmake test                                       # Full test suite\n```\n\n---\n\n## Appendix: Complete File Listing and Interface Quick Reference\n\n### A.1 Framework Layer Files\n\n| File | Responsibility |\n|------|---------------|\n| `pkg/channels/base.go` | BaseChannel struct, Channel interface, MessageLengthProvider, BaseChannelOption, HandleMessage |\n| `pkg/channels/interfaces.go` | TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder interfaces |\n| `pkg/channels/media.go` | MediaSender interface |\n| `pkg/channels/webhook.go` | WebhookHandler, HealthChecker interfaces |\n| `pkg/channels/errors.go` | ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed sentinels |\n| `pkg/channels/errutil.go` | ClassifySendError, ClassifyNetError helpers |\n| `pkg/channels/registry.go` | RegisterFactory, getFactory factory registry |\n| `pkg/channels/manager.go` | Manager: Worker queues, rate limiting, retries, preSend, shared HTTP, TTL janitor |\n| `pkg/channels/split.go` | SplitMessage long-message splitting |\n| `pkg/bus/bus.go` | MessageBus implementation |\n| `pkg/bus/types.go` | Peer, SenderInfo, InboundMessage, OutboundMessage, OutboundMediaMessage, MediaPart |\n| `pkg/media/store.go` | MediaStore interface, FileMediaStore implementation |\n| `pkg/identity/identity.go` | BuildCanonicalID, ParseCanonicalID, MatchAllowed |\n\n### A.2 Channel Sub-packages\n\n| Sub-package | Registered Name | Optional Interfaces |\n|-------------|----------------|-------------------|\n| `pkg/channels/telegram/` | `\"telegram\"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |\n| `pkg/channels/discord/` | `\"discord\"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |\n| `pkg/channels/slack/` | `\"slack\"` | ReactionCapable, MediaSender |\n| `pkg/channels/line/` | `\"line\"` | TypingCapable, MediaSender, WebhookHandler |\n| `pkg/channels/onebot/` | `\"onebot\"` | ReactionCapable, MediaSender |\n| `pkg/channels/dingtalk/` | `\"dingtalk\"` | — |\n| `pkg/channels/feishu/` | `\"feishu\"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) |\n| `pkg/channels/wecom/` | `\"wecom\"` | WebhookHandler, HealthChecker |\n| `pkg/channels/wecom/` | `\"wecom_app\"` | MediaSender, WebhookHandler, HealthChecker |\n| `pkg/channels/qq/` | `\"qq\"` | — |\n| `pkg/channels/whatsapp/` | `\"whatsapp\"` | — (Bridge mode) |\n| `pkg/channels/whatsapp_native/` | `\"whatsapp_native\"` | — (Native whatsmeow mode) |\n| `pkg/channels/maixcam/` | `\"maixcam\"` | — |\n| `pkg/channels/pico/` | `\"pico\"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |\n\n### A.3 Interface Quick Reference\n\n```go\n// ===== Required =====\ntype Channel interface {\n    Name() string\n    Start(ctx context.Context) error\n    Stop(ctx context.Context) error\n    Send(ctx context.Context, msg bus.OutboundMessage) error\n    IsRunning() bool\n    IsAllowed(senderID string) bool\n    IsAllowedSender(sender bus.SenderInfo) bool\n    ReasoningChannelID() string\n}\n\n// ===== Optional =====\ntype MediaSender interface {\n    SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error\n}\n\ntype TypingCapable interface {\n    StartTyping(ctx context.Context, chatID string) (stop func(), err error)\n}\n\ntype ReactionCapable interface {\n    ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)\n}\n\ntype PlaceholderCapable interface {\n    SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)\n}\n\ntype MessageEditor interface {\n    EditMessage(ctx context.Context, chatID, messageID, content string) error\n}\n\ntype WebhookHandler interface {\n    WebhookPath() string\n    http.Handler\n}\n\ntype HealthChecker interface {\n    HealthPath() string\n    HealthHandler(w http.ResponseWriter, r *http.Request)\n}\n\ntype MessageLengthProvider interface {\n    MaxMessageLength() int\n}\n\n// ===== Injected by Manager =====\ntype PlaceholderRecorder interface {\n    RecordPlaceholder(channel, chatID, placeholderID string)\n    RecordTypingStop(channel, chatID string, stop func())\n    RecordReactionUndo(channel, chatID string, undo func())\n}\n```\n\n### A.4 Gateway Startup Sequence (Complete Bootstrap Flow)\n\n```go\n// 1. Create core components\nmsgBus     := bus.NewMessageBus()\nprovider   := providers.CreateProvider(cfg)\nagentLoop  := agent.NewAgentLoop(cfg, msgBus, provider)\n\n// 2. Create media store (with TTL cleanup)\nmediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)\nmediaStore.Start()\n\n// 3. Create Channel Manager (triggers initChannels → factory lookup → construct → inject MediaStore/PlaceholderRecorder/Owner)\nchannelManager := channels.NewManager(cfg, msgBus, mediaStore)\n\n// 4. Inject references\nagentLoop.SetChannelManager(channelManager)\nagentLoop.SetMediaStore(mediaStore)\n\n// 5. Configure shared HTTP server\nchannelManager.SetupHTTPServer(addr, healthServer)\n\n// 6. Start\nchannelManager.StartAll(ctx)  // Start channels + workers + dispatchers + HTTP server\ngo agentLoop.Run(ctx)          // Start Agent message loop\n\n// 7. Shutdown (signal-triggered)\ncancel()                       // Cancel context\nmsgBus.Close()                 // Signal close + drain\nchannelManager.StopAll(shutdownCtx)  // Stop HTTP + workers + channels\nmediaStore.Stop()              // Stop TTL cleanup\nagentLoop.Stop()               // Stop Agent\n```\n\n### A.5 Per-channel Rate Limit Reference\n\n| Channel | Rate (msg/s) | Burst |\n|---------|-------------|-------|\n| telegram | 20 | 10 |\n| discord | 1 | 1 |\n| slack | 1 | 1 |\n| line | 10 | 5 |\n| _others_ | 10 (default) | 5 |\n\n### A.6 Known Limitations and Caveats\n\n1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active.\n\n2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.\n\n3. **WeCom has two factories**: `\"wecom\"` (Bot mode, webhook only) and `\"wecom_app\"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`.\n\n4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`).\n\n5. **WhatsApp has two modes**: `\"whatsapp\"` (Bridge mode, communicates via external bridge URL) and `\"whatsapp_native\"` (native whatsmeow mode, connects directly to WhatsApp). Manager selects which to initialize based on `WhatsAppConfig.UseNative`.\n\n6. **DingTalk uses Stream mode**: DingTalk uses the SDK's Stream/WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.\n\n7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields.\n\n8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method."
  },
  {
    "path": "pkg/channels/README.zh.md",
    "content": "# PicoClaw Channel System：完整开发指南\n\n> **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/`\n\n---\n\n## 目录\n\n- [第一部分：架构总览](#第一部分架构总览)\n- [第二部分：迁移指南——从 main 分支迁移到重构分支](#第二部分迁移指南从-main-分支迁移到重构分支)\n- [第三部分：新 Channel 开发指南——从零实现一个新 Channel](#第三部分新-channel-开发指南从零实现一个新-channel)\n- [第四部分：核心子系统详解](#第四部分核心子系统详解)\n- [第五部分：关键设计决策与约定](#第五部分关键设计决策与约定)\n- [附录：完整文件清单与接口速查表](#附录完整文件清单与接口速查表)\n\n---\n\n## 第一部分：架构总览\n\n### 1.1 重构前后对比\n\n**重构前（main 分支）**：\n\n```\npkg/channels/\n├── telegram.go          # 每个 channel 直接放在 channels 包内\n├── discord.go\n├── slack.go\n├── manager.go           # Manager 直接引用各 channel 类型\n├── ...\n```\n\n- Channel 实现全部在 `pkg/channels/` 包的顶层\n- Manager 通过 `switch` 或 `if-else` 链条直接构造各 channel\n- Peer、MessageID 等路由信息埋在 `Metadata map[string]string` 中\n- 消息发送没有速率限制和重试\n- 没有统一的媒体文件生命周期管理\n- 各 channel 各自启动 HTTP 服务器\n- 群聊触发过滤逻辑分散在各 channel 中\n\n**重构后（refactor/channel-system 分支）**：\n\n```\npkg/channels/\n├── base.go              # BaseChannel 共享抽象层\n├── interfaces.go        # 可选能力接口（TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder）\n├── README.md            # 英文文档\n├── README.zh.md         # 中文文档\n├── media.go             # MediaSender 可选接口\n├── webhook.go           # WebhookHandler, HealthChecker 可选接口\n├── errors.go            # 错误哨兵值（ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed）\n├── errutil.go           # 错误分类帮助函数\n├── registry.go          # 工厂注册表（RegisterFactory / getFactory）\n├── manager.go           # 统一编排：Worker 队列、速率限制、重试、Typing/Placeholder、共享 HTTP\n├── split.go             # 长消息智能分割（保留代码块完整性）\n├── telegram/            # 每个 channel 独立子包\n│   ├── init.go          # 工厂注册\n│   ├── telegram.go      # 实现\n│   └── telegram_commands.go\n├── discord/\n│   ├── init.go\n│   └── discord.go\n├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/\n│   └── ...\n\npkg/bus/\n├── bus.go               # MessageBus（缓冲区 64，安全关闭+排水）\n├── types.go             # 结构化消息类型（Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage）\n\npkg/media/\n├── store.go             # MediaStore 接口 + FileMediaStore 实现（两阶段释放，TTL 清理）\n\npkg/identity/\n├── identity.go          # 统一用户身份：规范 \"platform:id\" 格式 + 向后兼容匹配\n```\n\n### 1.2 消息流转全景图\n\n```\n┌────────────┐      InboundMessage       ┌───────────┐      LLM + Tools      ┌────────────┐\n│  Telegram   │──┐                        │           │                        │            │\n│  Discord    │──┤   PublishInbound()     │           │   PublishOutbound()   │            │\n│  Slack      │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop  │\n│  LINE       │──┤   (buffered chan, 64)  │           │   (buffered chan, 64) │            │\n│  ...        │──┘                        │           │                        │            │\n└────────────┘                            └─────┬─────┘                        └────────────┘\n                                                │\n                            SubscribeOutbound() │  SubscribeOutboundMedia()\n                                                ▼\n                                    ┌───────────────────┐\n                                    │   Manager          │\n                                    │   ├── dispatchOutbound()    路由到 Worker 队列\n                                    │   ├── dispatchOutboundMedia()\n                                    │   ├── runWorker()           消息分割 + sendWithRetry()\n                                    │   ├── runMediaWorker()      sendMediaWithRetry()\n                                    │   ├── preSend()             停止 Typing + 撤销 Reaction + 编辑 Placeholder\n                                    │   └── runTTLJanitor()       清理过期 Typing/Placeholder\n                                    └────────┬──────────┘\n                                             │\n                                   channel.Send() / SendMedia()\n                                             │\n                                             ▼\n                                    ┌────────────────┐\n                                    │ 各平台 API/SDK  │\n                                    └────────────────┘\n```\n\n### 1.3 关键设计原则\n\n| 原则 | 说明 |\n|------|------|\n| **子包隔离** | 每个 channel 一个独立 Go 子包，依赖 `channels` 父包提供的 `BaseChannel` 和接口 |\n| **工厂注册** | 各子包通过 `init()` 自注册，Manager 通过名字查找工厂，消除 import 耦合 |\n| **能力发现** | 可选能力通过接口（`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`）声明，Manager 运行时类型断言发现 |\n| **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 |\n| **错误分类** | Channel 返回哨兵错误（`ErrRateLimit`, `ErrTemporary` 等），Manager 据此决定重试策略 |\n| **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理，Channel 只负责 Send |\n\n---\n\n## 第二部分：迁移指南——从 main 分支迁移到重构分支\n\n### 2.1 如果你有未合并的 Channel 修改\n\n#### 步骤 1：确认你修改了哪些文件\n\n在 main 分支上，Channel 文件直接位于 `pkg/channels/` 顶层，例如：\n- `pkg/channels/telegram.go`\n- `pkg/channels/discord.go`\n\n重构后，这些文件已被删除，代码移动到了对应子包：\n- `pkg/channels/telegram/telegram.go`\n- `pkg/channels/discord/discord.go`\n\n#### 步骤 2：理解结构变化映射\n\n| main 分支文件 | 重构分支位置 | 变化 |\n|---|---|---|\n| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | 包名从 `channels` 变为 `telegram` |\n| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | 同上 |\n| `pkg/channels/manager.go` | `pkg/channels/manager.go` | 大幅重写 |\n| _(不存在)_ | `pkg/channels/base.go` | 新增共享抽象层 |\n| _(不存在)_ | `pkg/channels/registry.go` | 新增工厂注册表 |\n| _(不存在)_ | `pkg/channels/errors.go` + `errutil.go` | 新增错误分类体系 |\n| _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 |\n| _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 |\n| _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker |\n| _(不存在)_ | `pkg/channels/whatsapp_native/` | 新增 WhatsApp 原生模式（whatsmeow） |\n| _(不存在)_ | `pkg/channels/split.go` | 新增消息分割（从 utils 迁入） |\n| _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 |\n| _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 |\n| _(不存在)_ | `pkg/identity/identity.go` | 新增统一用户身份 |\n\n#### 步骤 3：迁移你的 Channel 代码\n\n以 Telegram 为例，主要改动项：\n\n**3a. 包声明和导入**\n\n```go\n// 旧代码（main 分支）\npackage channels\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// 新代码（重构分支）\npackage telegram\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"     // 引用父包\n    \"github.com/sipeed/picoclaw/pkg/config\"\n    \"github.com/sipeed/picoclaw/pkg/identity\"      // 新增\n    \"github.com/sipeed/picoclaw/pkg/media\"          // 新增（如需媒体）\n)\n```\n\n**3b. 结构体嵌入 BaseChannel**\n\n```go\n// 旧代码：直接持有 bus、config 等字段\ntype TelegramChannel struct {\n    bus       *bus.MessageBus\n    config    *config.Config\n    running   bool\n    allowList []string\n    // ...\n}\n\n// 新代码：嵌入 BaseChannel，它提供 bus、running、allowList 等\ntype TelegramChannel struct {\n    *channels.BaseChannel          // 嵌入共享抽象\n    bot    *telego.Bot\n    config *config.Config\n    // ... 只保留 channel 特有字段\n}\n```\n\n**3c. 构造函数**\n\n```go\n// 旧代码：直接赋值\nfunc NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {\n    return &TelegramChannel{\n        bus:       bus,\n        config:    cfg,\n        allowList: cfg.Channels.Telegram.AllowFrom,\n        // ...\n    }, nil\n}\n\n// 新代码：使用 NewBaseChannel + 功能选项\nfunc NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {\n    base := channels.NewBaseChannel(\n        \"telegram\",                    // 名称\n        cfg.Channels.Telegram,         // 原始配置（any 类型）\n        bus,                           // 消息总线\n        cfg.Channels.Telegram.AllowFrom, // 允许列表\n        channels.WithMaxMessageLength(4096),                     // 平台消息长度上限\n        channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置\n        channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // 思维链路由\n    )\n    return &TelegramChannel{\n        BaseChannel: base,\n        bot:         bot,\n        config:      cfg,\n    }, nil\n}\n```\n\n**3d. Start/Stop 生命周期**\n\n```go\n// 新代码：使用 SetRunning 原子操作\nfunc (c *TelegramChannel) Start(ctx context.Context) error {\n    // ... 初始化 bot、webhook 等\n    c.SetRunning(true)    // 必须在就绪后调用\n    go bh.Start()\n    return nil\n}\n\nfunc (c *TelegramChannel) Stop(ctx context.Context) error {\n    c.SetRunning(false)   // 必须在清理前调用\n    // ... 停止 bot handler、取消 context\n    return nil\n}\n```\n\n**3e. Send 方法的错误返回**\n\n```go\n// 旧代码：返回普通 error\nfunc (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    if !c.running { return fmt.Errorf(\"not running\") }\n    // ...\n    if err != nil { return err }\n}\n\n// 新代码：必须返回哨兵错误，供 Manager 判断重试策略\nfunc (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    if !c.IsRunning() {\n        return channels.ErrNotRunning    // ← Manager 不会重试\n    }\n    // ...\n    if err != nil {\n        // 使用 ClassifySendError 根据 HTTP 状态码包装错误\n        return channels.ClassifySendError(statusCode, err)\n        // 或手动包装：\n        // return fmt.Errorf(\"%w: %v\", channels.ErrTemporary, err)\n        // return fmt.Errorf(\"%w: %v\", channels.ErrRateLimit, err)\n        // return fmt.Errorf(\"%w: %v\", channels.ErrSendFailed, err)\n    }\n    return nil\n}\n```\n\n**3f. 消息接收（Inbound）**\n\n```go\n// 旧代码：直接构造 InboundMessage 并发布\nmsg := bus.InboundMessage{\n    Channel:  \"telegram\",\n    SenderID: senderID,\n    ChatID:   chatID,\n    Content:  content,\n    Metadata: map[string]string{\n        \"peer_kind\": \"group\",     // 路由信息埋在 metadata\n        \"peer_id\":   chatID,\n        \"message_id\": msgID,\n    },\n}\nc.bus.PublishInbound(ctx, msg)\n\n// 新代码：使用 BaseChannel.HandleMessage，传入结构化字段\nsender := bus.SenderInfo{\n    Platform:    \"telegram\",\n    PlatformID:  strconv.FormatInt(from.ID, 10),\n    CanonicalID: identity.BuildCanonicalID(\"telegram\", strconv.FormatInt(from.ID, 10)),\n    Username:    from.Username,\n    DisplayName: from.FirstName,\n}\n\npeer := bus.Peer{\n    Kind: \"group\",    // 或 \"direct\"\n    ID:   chatID,\n}\n\n// HandleMessage 内部调用 IsAllowedSender 检查权限，构建 MediaScope，发布到 bus\nc.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender)\n```\n\n**3g. 添加工厂注册（必需）**\n\n为你的 channel 创建 `init.go`：\n\n```go\n// pkg/channels/telegram/init.go\npackage telegram\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n    channels.RegisterFactory(\"telegram\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n        return NewTelegramChannel(cfg, b)\n    })\n}\n```\n\n**3h. 在 Gateway 中导入子包**\n\n```go\n// cmd/picoclaw/internal/gateway/helpers.go\nimport (\n    _ \"github.com/sipeed/picoclaw/pkg/channels/telegram\"   // 触发 init() 注册\n    _ \"github.com/sipeed/picoclaw/pkg/channels/discord\"\n    _ \"github.com/sipeed/picoclaw/pkg/channels/your_new_channel\"  // 新增\n)\n```\n\n#### 步骤 4：迁移 Bus 消息使用方式\n\n如果你的代码直接读取 `InboundMessage.Metadata` 中的路由字段：\n\n```go\n// 旧代码\npeerKind := msg.Metadata[\"peer_kind\"]\npeerID   := msg.Metadata[\"peer_id\"]\nmsgID    := msg.Metadata[\"message_id\"]\n\n// 新代码\npeerKind := msg.Peer.Kind      // 一等字段\npeerID   := msg.Peer.ID        // 一等字段\nmsgID    := msg.MessageID       // 一等字段\nsender   := msg.Sender          // bus.SenderInfo 结构体\nscope    := msg.MediaScope       // 媒体生命周期作用域\n```\n\n#### 步骤 5：迁移允许列表检查\n\n```go\n// 旧代码\nif !c.isAllowed(senderID) { return }\n\n// 新代码：优先使用结构化检查\nif !c.IsAllowedSender(sender) { return }\n// 或回退到字符串检查：\nif !c.IsAllowed(senderID) { return }\n```\n\n`BaseChannel.HandleMessage` 方法内部已经处理了这个逻辑，无需在 channel 中重复检查。\n\n### 2.2 如果你有 Manager 的修改\n\nManager 已被完全重写。你的修改需要理解新架构：\n\n| 旧 Manager 职责 | 新 Manager 职责 |\n|---|---|\n| 直接构造 channel（switch/if-else） | 通过工厂注册表查找并构造 |\n| 直接调用 channel.Send | 通过 per-channel Worker 队列 + 速率限制 + 重试 |\n| 无消息分割 | 自动根据 MaxMessageLength 分割长消息 |\n| 各 channel 自建 HTTP 服务器 | 统一共享 HTTP 服务器 |\n| 无 Typing/Placeholder 管理 | 统一 preSend 处理 Typing 停止 + Reaction 撤销 + Placeholder 编辑；入站侧 BaseChannel.HandleMessage 自动编排 Typing/Reaction/Placeholder |\n| 无 TTL 清理 | runTTLJanitor 定期清理过期 Typing/Reaction/Placeholder 条目 |\n\n### 2.3 如果你有 Agent Loop 的修改\n\nAgent Loop 的主要变化：\n\n1. **MediaStore 注入**：`agentLoop.SetMediaStore(mediaStore)` — Agent 通过 MediaStore 解析工具产生的媒体引用\n2. **ChannelManager 注入**：`agentLoop.SetChannelManager(channelManager)` — Agent 可查询 channel 状态\n3. **OutboundMediaMessage**：Agent 现在通过 `bus.PublishOutboundMedia()` 发送媒体消息，而非嵌入文本回复\n4. **extractPeer**：路由使用 `msg.Peer` 结构化字段而非 Metadata 查找\n\n---\n\n## 第三部分：新 Channel 开发指南——从零实现一个新 Channel\n\n### 3.1 最小实现清单\n\n要添加一个新的聊天平台（例如 `matrix`），你需要：\n\n1. ✅ 创建子包目录 `pkg/channels/matrix/`\n2. ✅ 创建 `init.go` — 工厂注册\n3. ✅ 创建 `matrix.go` — Channel 实现\n4. ✅ 在 Gateway helpers 中添加 blank import\n5. ✅ 在 Manager.initChannels() 中添加配置检查\n6. ✅ 在 `pkg/config/` 中添加配置结构体\n\n### 3.2 完整模板\n\n#### `pkg/channels/matrix/init.go`\n\n```go\npackage matrix\n\nimport (\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n    channels.RegisterFactory(\"matrix\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n        return NewMatrixChannel(cfg, b)\n    })\n}\n```\n\n#### `pkg/channels/matrix/matrix.go`\n\n```go\npackage matrix\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/sipeed/picoclaw/pkg/bus\"\n    \"github.com/sipeed/picoclaw/pkg/channels\"\n    \"github.com/sipeed/picoclaw/pkg/config\"\n    \"github.com/sipeed/picoclaw/pkg/identity\"\n    \"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// MatrixChannel implements channels.Channel for the Matrix protocol.\ntype MatrixChannel struct {\n    *channels.BaseChannel            // 必须嵌入\n    config *config.Config\n    ctx    context.Context\n    cancel context.CancelFunc\n    // ... Matrix SDK 客户端等\n}\n\nfunc NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) {\n    matrixCfg := cfg.Channels.Matrix // 假设配置中有此字段\n\n    base := channels.NewBaseChannel(\n        \"matrix\",                           // channel 名称（全局唯一）\n        matrixCfg,                          // 原始配置\n        msgBus,                             // 消息总线\n        matrixCfg.AllowFrom,                // 允许列表\n        channels.WithMaxMessageLength(65536), // Matrix 消息长度限制\n        channels.WithGroupTrigger(matrixCfg.GroupTrigger),\n        channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // 思维链路由（可选）\n    )\n\n    return &MatrixChannel{\n        BaseChannel: base,\n        config:      cfg,\n    }, nil\n}\n\n// ========== 必须实现的 Channel 接口方法 ==========\n\nfunc (c *MatrixChannel) Start(ctx context.Context) error {\n    c.ctx, c.cancel = context.WithCancel(ctx)\n\n    // 1. 初始化 Matrix 客户端\n    // 2. 开始监听消息\n    // 3. 标记为运行中\n    c.SetRunning(true)\n\n    logger.InfoC(\"matrix\", \"Matrix channel started\")\n    return nil\n}\n\nfunc (c *MatrixChannel) Stop(ctx context.Context) error {\n    c.SetRunning(false)\n\n    if c.cancel != nil {\n        c.cancel()\n    }\n\n    logger.InfoC(\"matrix\", \"Matrix channel stopped\")\n    return nil\n}\n\nfunc (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n    // 1. 检查运行状态\n    if !c.IsRunning() {\n        return channels.ErrNotRunning\n    }\n\n    // 2. 发送消息到 Matrix\n    err := c.sendToMatrix(ctx, msg.ChatID, msg.Content)\n    if err != nil {\n        // 3. 必须使用错误分类包装\n        //    如果你有 HTTP 状态码：\n        //    return channels.ClassifySendError(statusCode, err)\n        //    如果是网络错误：\n        //    return channels.ClassifyNetError(err)\n        //    如果需要手动分类：\n        return fmt.Errorf(\"%w: %v\", channels.ErrTemporary, err)\n    }\n\n    return nil\n}\n\n// ========== 消息接收处理 ==========\n\nfunc (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) {\n    // 1. 构造结构化发送者身份\n    sender := bus.SenderInfo{\n        Platform:    \"matrix\",\n        PlatformID:  senderID,\n        CanonicalID: identity.BuildCanonicalID(\"matrix\", senderID),\n        Username:    senderID,\n        DisplayName: displayName,\n    }\n\n    // 2. 确定 Peer 类型（直聊 vs 群聊）\n    peer := bus.Peer{\n        Kind: \"group\",    // 或 \"direct\"\n        ID:   roomID,\n    }\n\n    // 3. 群聊过滤（如适用）\n    isGroup := peer.Kind == \"group\"\n    if isGroup {\n        isMentioned := false // 根据平台特性检测 @提及\n        shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content)\n        if !shouldRespond {\n            return\n        }\n        content = cleanContent\n    }\n\n    // 4. 处理媒体附件（如有）\n    var mediaRefs []string\n    store := c.GetMediaStore()\n    if store != nil {\n        // 下载附件到本地 → store.Store() → 获取 ref\n        // mediaRefs = append(mediaRefs, ref)\n    }\n\n    // 5. 调用 HandleMessage 发布到 bus\n    //    HandleMessage 内部会：\n    //    - 检查 IsAllowedSender/IsAllowed\n    //    - 构建 MediaScope\n    //    - 发布 InboundMessage\n    c.HandleMessage(\n        c.ctx,\n        peer,\n        msgID,                   // 平台消息 ID\n        senderID,                // 原始发送者 ID\n        roomID,                  // 聊天/房间 ID\n        content,                 // 消息内容\n        mediaRefs,               // 媒体引用列表\n        nil,                     // 额外 metadata（通常 nil）\n        sender,                  // SenderInfo（variadic 参数）\n    )\n}\n\n// ========== 内部方法 ==========\n\nfunc (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error {\n    // 实际的 Matrix SDK 调用\n    return nil\n}\n```\n\n### 3.3 可选能力接口\n\n根据平台能力，你的 Channel 可以选择性实现以下接口：\n\n#### MediaSender — 发送媒体附件\n\n```go\n// 如果平台支持发送图片/文件/音频/视频\nfunc (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n    if !c.IsRunning() {\n        return channels.ErrNotRunning\n    }\n\n    store := c.GetMediaStore()\n    if store == nil {\n        return fmt.Errorf(\"no media store: %w\", channels.ErrSendFailed)\n    }\n\n    for _, part := range msg.Parts {\n        localPath, err := store.Resolve(part.Ref)\n        if err != nil {\n            logger.ErrorCF(\"matrix\", \"Failed to resolve media\", map[string]any{\n                \"ref\": part.Ref, \"error\": err.Error(),\n            })\n            continue\n        }\n\n        // 根据 part.Type (\"image\"|\"audio\"|\"video\"|\"file\") 调用对应 API\n        switch part.Type {\n        case \"image\":\n            // 上传图片到 Matrix\n        default:\n            // 上传文件到 Matrix\n        }\n    }\n    return nil\n}\n```\n\n#### TypingCapable — Typing 指示器\n\n```go\n// 如果平台支持 \"正在输入...\" 提示\nfunc (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) {\n    // 调用 Matrix API 发送 typing 指示器\n    // 返回的 stop 函数必须是幂等的\n    stopped := false\n    return func() {\n        if !stopped {\n            stopped = true\n            // 调用 Matrix API 停止 typing\n        }\n    }, nil\n}\n```\n\n#### ReactionCapable — 消息反应指示器\n\n```go\n// 如果平台支持对入站消息添加 emoji 反应（如 Slack 的 👀、OneBot 的表情 289）\nfunc (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) {\n    // 调用 Matrix API 添加反应到消息\n    // 返回的 undo 函数移除反应，必须是幂等的\n    err = c.addReaction(chatID, messageID, \"eyes\")\n    if err != nil {\n        return func() {}, err\n    }\n    return func() {\n        c.removeReaction(chatID, messageID, \"eyes\")\n    }, nil\n}\n```\n\n#### MessageEditor — 消息编辑\n\n```go\n// 如果平台支持编辑已发送的消息（用于 Placeholder 替换）\nfunc (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n    // 调用 Matrix API 编辑消息\n    return nil\n}\n```\n\n#### PlaceholderCapable — 占位消息\n\n```go\n// 如果平台支持发送占位消息（如 \"Thinking... 💭\"），并且实现了 MessageEditor，\n// 则 Manager 的 preSend 会在出站时自动将占位消息编辑为最终回复。\n// SendPlaceholder 内部根据 PlaceholderConfig.Enabled 决定是否发送；\n// 返回 (\"\", nil) 表示跳过。\nfunc (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n    cfg := c.config.Channels.Matrix.Placeholder\n    if !cfg.Enabled {\n        return \"\", nil\n    }\n    text := cfg.Text\n    if text == \"\" {\n        text = \"Thinking... 💭\"\n    }\n    // 调用 Matrix API 发送占位消息\n    msg, err := c.sendText(ctx, chatID, text)\n    if err != nil {\n        return \"\", err\n    }\n    return msg.ID, nil\n}\n```\n\n#### WebhookHandler — HTTP Webhook 接收\n\n```go\n// 如果 channel 通过 webhook 接收消息（而非长轮询/WebSocket）\nfunc (c *MatrixChannel) WebhookPath() string {\n    return \"/webhook/matrix\"   // 路径会被注册到共享 HTTP 服务器\n}\n\nfunc (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n    // 处理 webhook 请求\n}\n```\n\n#### HealthChecker — 健康检查端点\n\n```go\nfunc (c *MatrixChannel) HealthPath() string {\n    return \"/health/matrix\"\n}\n\nfunc (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {\n    if c.IsRunning() {\n        w.WriteHeader(http.StatusOK)\n        w.Write([]byte(\"OK\"))\n    } else {\n        w.WriteHeader(http.StatusServiceUnavailable)\n    }\n}\n```\n\n### 3.4 入站侧 Typing/Reaction/Placeholder 自动编排\n\n`BaseChannel.HandleMessage` 在发布入站消息**之前**，自动检测 channel 是否实现了 `TypingCapable`、`ReactionCapable` 和/或 `PlaceholderCapable`，并触发相应的指示器。三条管道完全独立，互不干扰：\n\n```go\n// BaseChannel.HandleMessage 内部自动执行（无需 channel 手动调用）：\nif c.owner != nil && c.placeholderRecorder != nil {\n    // Typing — 独立管道\n    if tc, ok := c.owner.(TypingCapable); ok {\n        if stop, err := tc.StartTyping(ctx, chatID); err == nil {\n            c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)\n        }\n    }\n    // Reaction — 独立管道\n    if rc, ok := c.owner.(ReactionCapable); ok && messageID != \"\" {\n        if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {\n            c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)\n        }\n    }\n    // Placeholder — 独立管道\n    if pc, ok := c.owner.(PlaceholderCapable); ok {\n        if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != \"\" {\n            c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)\n        }\n    }\n}\n```\n\n**这意味着**：\n- 实现 `TypingCapable` 的 channel（Telegram、Discord、LINE、Pico）无需在 `handleMessage` 中手动调用 `StartTyping` + `RecordTypingStop`\n- 实现 `ReactionCapable` 的 channel（Slack、OneBot）无需在 `handleMessage` 中手动调用 `AddReaction` + `RecordTypingStop`\n- 实现 `PlaceholderCapable` 的 channel（Telegram、Discord、Pico）无需在 `handleMessage` 中手动发送占位消息并调用 `RecordPlaceholder`\n- Channel 只需实现对应接口，`HandleMessage` 会自动完成编排\n- 不实现这些接口的 channel 不受影响（类型断言会失败，跳过）\n- `PlaceholderCapable` 的 `SendPlaceholder` 方法内部根据配置的 `PlaceholderConfig.Enabled` 决定是否发送；返回 `(\"\", nil)` 时跳过注册\n\n**Owner 注入**：Manager 在 `initChannel` 中自动调用 `SetOwner(ch)` 将具体 channel 注入 BaseChannel，无需开发者手动设置。\n\n当 Agent 处理完消息后，Manager 的 `preSend` 会自动：\n1. 调用已记录的 `stop()` 停止 Typing\n2. 调用已记录的 `undo()` 撤销 Reaction\n3. 如果有 Placeholder，且 channel 实现了 `MessageEditor`，尝试编辑 Placeholder 为最终回复（跳过 Send）\n\n### 3.5 注册配置和 Gateway 接入\n\n#### 在 `pkg/config/config.go` 中添加配置\n\n```go\ntype ChannelsConfig struct {\n    // ... 现有 channels\n    Matrix  MatrixChannelConfig  `json:\"matrix\"`\n}\n\ntype MatrixChannelConfig struct {\n    Enabled    bool     `json:\"enabled\"`\n    HomeServer string   `json:\"home_server\"`\n    Token      string   `json:\"token\"`\n    AllowFrom  []string `json:\"allow_from\"`\n    GroupTrigger GroupTriggerConfig `json:\"group_trigger\"`\n    Placeholder  PlaceholderConfig  `json:\"placeholder\"`\n    ReasoningChannelID string `json:\"reasoning_channel_id\"`\n}\n```\n\n#### 在 Manager.initChannels() 中添加入口\n\n```go\n// pkg/channels/manager.go 的 initChannels() 方法中\nif m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != \"\" {\n    m.initChannel(\"matrix\", \"Matrix\")\n}\n```\n\n> **注意**：如果你的 channel 有多种模式（如 WhatsApp Bridge vs Native），需要在 initChannels 中根据配置分支：\n> ```go\n> if cfg.UseNative {\n>     m.initChannel(\"whatsapp_native\", \"WhatsApp Native\")\n> } else {\n>     m.initChannel(\"whatsapp\", \"WhatsApp\")\n> }\n> ```\n\n#### 在 Gateway 中添加 blank import\n\n```go\n// cmd/picoclaw/internal/gateway/helpers.go\nimport (\n    _ \"github.com/sipeed/picoclaw/pkg/channels/matrix\"\n)\n```\n\n---\n\n## 第四部分：核心子系统详解\n\n### 4.1 MessageBus\n\n**文件**：`pkg/bus/bus.go`、`pkg/bus/types.go`\n\n```go\ntype MessageBus struct {\n    inbound       chan InboundMessage       // 缓冲区 = 64\n    outbound      chan OutboundMessage      // 缓冲区 = 64\n    outboundMedia chan OutboundMediaMessage  // 缓冲区 = 64\n    done          chan struct{}             // 关闭信号\n    closed        atomic.Bool              // 防止重复关闭\n}\n```\n\n**关键行为**：\n\n| 方法 | 行为 |\n|------|------|\n| `PublishInbound(ctx, msg)` | 检查 closed → 发送到 inbound channel → 阻塞/超时/关闭 |\n| `ConsumeInbound(ctx)` | 从 inbound 读取 → 阻塞/关闭/取消 |\n| `PublishOutbound(ctx, msg)` | 发送到 outbound channel |\n| `SubscribeOutbound(ctx)` | 从 outbound 读取（Manager dispatcher 调用） |\n| `PublishOutboundMedia(ctx, msg)` | 发送到 outboundMedia channel |\n| `SubscribeOutboundMedia(ctx)` | 从 outboundMedia 读取（Manager media dispatcher 调用） |\n| `Close()` | CAS 关闭 → close(done) → 排水所有 channel（**不关闭 channel 本身**，避免并发 send-on-closed panic） |\n\n**设计要点**：\n- 缓冲区从 16 增至 64，减少突发负载下的阻塞\n- `Close()` 不关闭底层 channel（只关闭 `done` 信号通道），因为可能有正在并发 `Publish` 的 goroutine\n- 排水循环确保 buffered 消息不被静默丢弃\n\n### 4.2 结构化消息类型\n\n**文件**：`pkg/bus/types.go`\n\n```go\n// 路由对等体\ntype Peer struct {\n    Kind string `json:\"kind\"`  // \"direct\" | \"group\" | \"channel\" | \"\"\n    ID   string `json:\"id\"`\n}\n\n// 发送者身份信息\ntype SenderInfo struct {\n    Platform    string `json:\"platform,omitempty\"`     // \"telegram\", \"discord\", ...\n    PlatformID  string `json:\"platform_id,omitempty\"`  // 平台原始 ID\n    CanonicalID string `json:\"canonical_id,omitempty\"` // \"platform:id\" 规范格式\n    Username    string `json:\"username,omitempty\"`\n    DisplayName string `json:\"display_name,omitempty\"`\n}\n\n// 入站消息\ntype InboundMessage struct {\n    Channel    string            // 来源 channel 名称\n    SenderID   string            // 发送者 ID（优先使用 CanonicalID）\n    Sender     SenderInfo        // 结构化发送者信息\n    ChatID     string            // 聊天/房间 ID\n    Content    string            // 消息文本\n    Media      []string          // 媒体引用列表（media://...）\n    Peer       Peer              // 路由对等体（一等字段）\n    MessageID  string            // 平台消息 ID（一等字段）\n    MediaScope string            // 媒体生命周期作用域\n    SessionKey string            // 会话键\n    Metadata   map[string]string // 仅用于 channel 特有扩展\n}\n\n// 出站文本消息\ntype OutboundMessage struct {\n    Channel string\n    ChatID  string\n    Content string\n}\n\n// 出站媒体消息\ntype OutboundMediaMessage struct {\n    Channel string\n    ChatID  string\n    Parts   []MediaPart\n}\n\n// 媒体片段\ntype MediaPart struct {\n    Type        string // \"image\" | \"audio\" | \"video\" | \"file\"\n    Ref         string // \"media://uuid\"\n    Caption     string\n    Filename    string\n    ContentType string\n}\n```\n\n### 4.3 BaseChannel\n\n**文件**：`pkg/channels/base.go`\n\nBaseChannel 是所有 channel 的共享抽象层，提供以下能力：\n\n| 方法/特性 | 说明 |\n|---|---|\n| `Name() string` | Channel 名称 |\n| `IsRunning() bool` | 原子读取运行状态 |\n| `SetRunning(bool)` | 原子设置运行状态 |\n| `MaxMessageLength() int` | 消息长度限制（rune 计数），0 = 无限制 |\n| `ReasoningChannelID() string` | 思维链路由目标 channel ID（空 = 不路由） |\n| `IsAllowed(senderID string) bool` | 旧格式允许列表检查（支持 `\"id\\|username\"` 和 `\"@username\"` 格式） |\n| `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查（委托给 `identity.MatchAllowed`） |\n| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 |\n| `HandleMessage(...)` | 统一入站消息处理：权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction/Placeholder → 发布到 Bus |\n| `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 |\n| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 |\n| `SetOwner(ch) ` | Manager 注入的具体 channel 引用（用于 HandleMessage 内部的 Typing/Reaction/Placeholder 类型断言） |\n\n**功能选项**：\n\n```go\nchannels.WithMaxMessageLength(4096)        // 设置平台消息长度限制\nchannels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置\nchannels.WithReasoningChannelID(id)        // 设置思维链路由目标 channel\n```\n\n### 4.4 工厂注册表\n\n**文件**：`pkg/channels/registry.go`\n\n```go\ntype ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)\n\nfunc RegisterFactory(name string, f ChannelFactory)   // 子包 init() 中调用\nfunc getFactory(name string) (ChannelFactory, bool)    // Manager 内部调用\n```\n\n工厂注册表使用 `sync.RWMutex` 保护，在 `init()` 阶段注册（进程启动时完成）。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。\n\n### 4.5 错误分类与重试\n\n**文件**：`pkg/channels/errors.go`、`pkg/channels/errutil.go`\n\n#### 哨兵错误\n\n```go\nvar (\n    ErrNotRunning = errors.New(\"channel not running\")   // 永久：不重试\n    ErrRateLimit  = errors.New(\"rate limited\")           // 固定延迟：1s 后重试\n    ErrTemporary  = errors.New(\"temporary failure\")      // 指数退避：500ms * 2^attempt，最大 8s\n    ErrSendFailed = errors.New(\"send failed\")            // 永久：不重试\n)\n```\n\n#### 错误分类帮助函数\n\n```go\n// 根据 HTTP 状态码自动分类\nfunc ClassifySendError(statusCode int, rawErr error) error {\n    // 429 → ErrRateLimit\n    // 5xx → ErrTemporary\n    // 4xx → ErrSendFailed\n}\n\n// 网络错误统一包装为临时错误\nfunc ClassifyNetError(err error) error {\n    // → ErrTemporary\n}\n```\n\n#### Manager 重试策略（`sendWithRetry`）\n\n```\n最大重试次数: 3\n速率限制延迟: 1 秒\n基础退避:     500 毫秒\n最大退避:     8 秒\n\n重试逻辑:\n  ErrNotRunning → 立即失败，不重试\n  ErrSendFailed → 立即失败，不重试\n  ErrRateLimit  → 等待 1s → 重试\n  ErrTemporary  → 等待 500ms * 2^attempt（最大 8s） → 重试\n  其他未知错误  → 等待 500ms * 2^attempt（最大 8s） → 重试\n```\n\n### 4.6 Manager 编排\n\n**文件**：`pkg/channels/manager.go`\n\n#### Per-channel Worker 架构\n\n```go\ntype channelWorker struct {\n    ch         Channel                      // channel 实例\n    queue      chan bus.OutboundMessage      // 出站文本队列（缓冲 16）\n    mediaQueue chan bus.OutboundMediaMessage // 出站媒体队列（缓冲 16）\n    done       chan struct{}                // 文本 worker 完成信号\n    mediaDone  chan struct{}                // 媒体 worker 完成信号\n    limiter    *rate.Limiter                // per-channel 速率限制器\n}\n```\n\n#### Per-channel 速率限制配置\n\n```go\nvar channelRateConfig = map[string]float64{\n    \"telegram\": 20,   // 20 msg/s\n    \"discord\":  1,    // 1 msg/s\n    \"slack\":    1,    // 1 msg/s\n    \"line\":     10,   // 10 msg/s\n}\n// 默认: 10 msg/s\n// burst = max(1, ceil(rate/2))\n```\n\n#### 生命周期管理\n\n```\nStartAll:\n  1. 遍历已注册 channels → channel.Start(ctx)\n  2. 为每个启动成功的 channel 创建 channelWorker\n  3. 启动 goroutines:\n     - runWorker (per-channel 出站文本)\n     - runMediaWorker (per-channel 出站媒体)\n     - dispatchOutbound (从 bus 路由到 worker 队列)\n     - dispatchOutboundMedia (从 bus 路由到 media worker 队列)\n     - runTTLJanitor (每 10s 清理过期 typing/reaction/placeholder)\n  4. 启动共享 HTTP 服务器（如已配置）\n\nStopAll:\n  1. 关闭共享 HTTP 服务器（5s 超时）\n  2. 取消 dispatcher context\n  3. 关闭 text worker 队列 → 等待排水完成\n  4. 关闭 media worker 队列 → 等待排水完成\n  5. 停止每个 channel（channel.Stop）\n```\n\n#### Typing/Reaction/Placeholder 管理\n\n```go\n// Manager 实现 PlaceholderRecorder 接口\nfunc (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string)\nfunc (m *Manager) RecordTypingStop(channel, chatID string, stop func())\nfunc (m *Manager) RecordReactionUndo(channel, chatID string, undo func())\n\n// 入站侧：BaseChannel.HandleMessage 自动编排\n// BaseChannel.HandleMessage 在 PublishInbound 之前，通过 owner 类型断言自动触发：\n//   - TypingCapable.StartTyping       → RecordTypingStop\n//   - ReactionCapable.ReactToMessage  → RecordReactionUndo\n//   - PlaceholderCapable.SendPlaceholder → RecordPlaceholder\n// 三者独立，互不干扰。Channel 无需手动调用。\n\n// 出站侧：发送前处理\nfunc (m *Manager) preSend(ctx, name, msg, ch) bool {\n    key := name + \":\" + msg.ChatID\n    // 1. 停止 Typing（调用存储的 stop 函数）\n    // 2. 撤销 Reaction（调用存储的 undo 函数）\n    // 3. 尝试编辑 Placeholder（如果 channel 实现了 MessageEditor）\n    //    成功 → return true（跳过 Send）\n    //    失败 → return false（继续 Send）\n}\n```\n\nManager 存储完全分离，三条管道互不干扰：\n\n```go\nManager {\n    typingStops   sync.Map  // \"channel:chatID\" → typingEntry    ← 管 TypingCapable\n    reactionUndos sync.Map  // \"channel:chatID\" → reactionEntry  ← 管 ReactionCapable\n    placeholders  sync.Map  // \"channel:chatID\" → placeholderEntry\n}\n```\n\nTTL 清理：\n- Typing 停止函数：5 分钟 TTL（到期后自动调用 stop 并删除）\n- Reaction 撤销函数：5 分钟 TTL（到期后自动调用 undo 并删除）\n- Placeholder ID：10 分钟 TTL（到期后删除）\n- 清理间隔：10 秒\n\n### 4.7 消息分割\n\n**文件**：`pkg/channels/split.go`\n\n`SplitMessage(content string, maxLen int) []string`\n\n智能分割策略：\n1. 计算有效分割点 = maxLen - 10% 缓冲区（为代码块闭合留空间）\n2. 优先在换行符处分割\n3. 其次在空格/制表符处分割\n4. 检测未闭合的代码块（` ``` `）\n5. 如果代码块未闭合：\n   - 尝试扩展到 maxLen 以包含闭合围栏\n   - 如果代码块太长，注入闭合/重开围栏（`\\n```\\n` + header）\n   - 最后手段：在代码块开始前分割\n\n### 4.8 MediaStore\n\n**文件**：`pkg/media/store.go`\n\n```go\ntype MediaStore interface {\n    Store(localPath string, meta MediaMeta, scope string) (ref string, err error)\n    Resolve(ref string) (localPath string, err error)\n    ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)\n    ReleaseAll(scope string) error\n}\n```\n\n**FileMediaStore 实现**：\n- 纯内存映射，不复制/移动文件\n- 引用格式：`media://<uuid>`\n- Scope 格式：`channel:chatID:messageID`（由 `BuildMediaScope` 生成）\n- **两阶段操作**：\n  - Phase 1（持锁）：从 map 中收集并删除条目\n  - Phase 2（无锁）：从磁盘删除文件\n  - 目的：最小化锁争用\n- **TTL 清理**：`NewFileMediaStoreWithCleanup` → `Start()` 启动后台清理协程\n- 清理间隔和最大存活时间由配置控制\n\n### 4.9 Identity\n\n**文件**：`pkg/identity/identity.go`\n\n```go\n// 构建规范 ID\nfunc BuildCanonicalID(platform, platformID string) string\n// → \"telegram:123456\"\n\n// 解析规范 ID\nfunc ParseCanonicalID(canonical string) (platform, id string, ok bool)\n\n// 匹配允许列表（向后兼容）\nfunc MatchAllowed(sender bus.SenderInfo, allowed string) bool\n```\n\n`MatchAllowed` 支持的允许列表格式：\n| 格式 | 匹配方式 |\n|------|----------|\n| `\"123456\"` | 匹配 `sender.PlatformID` |\n| `\"@alice\"` | 匹配 `sender.Username` |\n| `\"123456\\|alice\"` | 匹配 PlatformID 或 Username（旧格式兼容） |\n| `\"telegram:123456\"` | 精确匹配 `sender.CanonicalID`（新格式） |\n\n### 4.10 共享 HTTP 服务器\n\n**文件**：`pkg/channels/manager.go` 的 `SetupHTTPServer`\n\nManager 创建单一 `http.Server`，自动发现和注册：\n- 实现 `WebhookHandler` 的 channel → 挂载到 `wh.WebhookPath()`\n- 实现 `HealthChecker` 的 channel → 挂载到 `hc.HealthPath()`\n- Health 全局端点由 `health.Server.RegisterOnMux` 注册\n\n超时配置：ReadTimeout = 30s, WriteTimeout = 30s\n\n---\n\n## 第五部分：关键设计决策与约定\n\n### 5.1 必须遵守的约定\n\n1. **错误分类是合约**：Channel 的 `Send` 方法**必须**返回哨兵错误（或包装它们）。Manager 的重试策略完全依赖 `errors.Is` 检查。如果返回未分类的错误，Manager 会按\"未知错误\"处理（指数退避重试）。\n\n2. **SetRunning 是生命周期信号**：`Start` 成功后**必须**调用 `c.SetRunning(true)`，`Stop` 开始时**必须**调用 `c.SetRunning(false)`。`Send` 中**必须**检查 `c.IsRunning()` 并返回 `ErrNotRunning`。\n\n3. **HandleMessage 包含权限检查**：不要在调用 `HandleMessage` 之前自行进行权限检查（除非你需要在检查前做平台特定的预处理）。`HandleMessage` 内部已经调用 `IsAllowedSender`/`IsAllowed`。\n\n4. **消息分割由 Manager 处理**：Channel 的 `Send` 方法不需要处理长消息分割。Manager 会在调用 `Send` 之前根据 `MaxMessageLength()` 自动分割。Channel 只需通过 `WithMaxMessageLength` 声明限制。\n\n5. **Typing/Reaction/Placeholder 由 BaseChannel + Manager 自动处理**：Channel 的 `Send` 方法不需要管理 Typing 停止、Reaction 撤销或 Placeholder 编辑。`BaseChannel.HandleMessage` 在入站侧自动触发 `TypingCapable`、`ReactionCapable` 和 `PlaceholderCapable`（通过 `owner` 类型断言）；Manager 的 `preSend` 在出站侧自动停止 Typing、撤销 Reaction、编辑 Placeholder。Channel 只需实现对应接口即可。\n\n6. **工厂注册在 init() 中**：每个子包必须有 `init.go` 文件调用 `channels.RegisterFactory`。Gateway 必须通过 blank import（`_ \"pkg/channels/xxx\"`）触发注册。\n\n### 5.2 Metadata 字段使用约定\n\n**不要再把以下信息放入 Metadata**：\n- `peer_kind` / `peer_id` → 使用 `InboundMessage.Peer`\n- `message_id` → 使用 `InboundMessage.MessageID`\n- `sender_platform` / `sender_username` → 使用 `InboundMessage.Sender`\n\n**Metadata 仅用于**：\n- Channel 特有的扩展信息（如 Telegram 的 `reply_to_message_id`）\n- 不适合放入结构化字段的临时信息\n\n### 5.3 并发安全约定\n\n- `BaseChannel.running`：使用 `atomic.Bool`，线程安全\n- `Manager.channels` / `Manager.workers`：使用 `sync.RWMutex` 保护\n- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`：使用 `sync.Map`\n- `MessageBus.closed`：使用 `atomic.Bool`\n- `FileMediaStore`：使用 `sync.RWMutex`，两阶段操作减少持锁时间\n- Channel Worker queue：Go channel，天然并发安全\n\n### 5.4 测试约定\n\n已有测试文件：\n- `pkg/channels/base_test.go` — BaseChannel 单元测试\n- `pkg/channels/manager_test.go` — Manager 单元测试\n- `pkg/channels/split_test.go` — 消息分割测试\n- `pkg/channels/errors_test.go` — 错误类型测试\n- `pkg/channels/errutil_test.go` — 错误分类测试\n\n为新 channel 添加测试时：\n```bash\ngo test ./pkg/channels/matrix/ -v              # 子包测试\ngo test ./pkg/channels/ -run TestSpecific -v    # 框架测试\nmake test                                       # 全量测试\n```\n\n---\n\n## 附录：完整文件清单与接口速查表\n\n### A.1 框架层文件\n\n| 文件 | 职责 |\n|------|------|\n| `pkg/channels/base.go` | BaseChannel 结构体、Channel 接口、MessageLengthProvider、BaseChannelOption、HandleMessage |\n| `pkg/channels/interfaces.go` | TypingCapable、MessageEditor、ReactionCapable、PlaceholderCapable、PlaceholderRecorder 接口 |\n| `pkg/channels/media.go` | MediaSender 接口 |\n| `pkg/channels/webhook.go` | WebhookHandler、HealthChecker 接口 |\n| `pkg/channels/errors.go` | ErrNotRunning、ErrRateLimit、ErrTemporary、ErrSendFailed 哨兵 |\n| `pkg/channels/errutil.go` | ClassifySendError、ClassifyNetError 帮助函数 |\n| `pkg/channels/registry.go` | RegisterFactory、getFactory 工厂注册表 |\n| `pkg/channels/manager.go` | Manager：Worker 队列、速率限制、重试、preSend、共享 HTTP、TTL janitor |\n| `pkg/channels/split.go` | SplitMessage 长消息分割 |\n| `pkg/bus/bus.go` | MessageBus 实现 |\n| `pkg/bus/types.go` | Peer、SenderInfo、InboundMessage、OutboundMessage、OutboundMediaMessage、MediaPart |\n| `pkg/media/store.go` | MediaStore 接口、FileMediaStore 实现 |\n| `pkg/identity/identity.go` | BuildCanonicalID、ParseCanonicalID、MatchAllowed |\n\n### A.2 Channel 子包\n\n| 子包 | 注册名 | 可选接口 |\n|------|--------|----------|\n| `pkg/channels/telegram/` | `\"telegram\"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |\n| `pkg/channels/discord/` | `\"discord\"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender |\n| `pkg/channels/slack/` | `\"slack\"` | ReactionCapable, MediaSender |\n| `pkg/channels/line/` | `\"line\"` | TypingCapable, MediaSender, WebhookHandler |\n| `pkg/channels/onebot/` | `\"onebot\"` | ReactionCapable, MediaSender |\n| `pkg/channels/dingtalk/` | `\"dingtalk\"` | — |\n| `pkg/channels/feishu/` | `\"feishu\"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) |\n| `pkg/channels/wecom/` | `\"wecom\"` | WebhookHandler, HealthChecker |\n| `pkg/channels/wecom/` | `\"wecom_app\"` | MediaSender, WebhookHandler, HealthChecker |\n| `pkg/channels/qq/` | `\"qq\"` | — |\n| `pkg/channels/whatsapp/` | `\"whatsapp\"` | — (Bridge 模式) |\n| `pkg/channels/whatsapp_native/` | `\"whatsapp_native\"` | — (原生 whatsmeow 模式) |\n| `pkg/channels/maixcam/` | `\"maixcam\"` | — |\n| `pkg/channels/pico/` | `\"pico\"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |\n\n### A.3 接口速查表\n\n```go\n// ===== 必须实现 =====\ntype Channel interface {\n    Name() string\n    Start(ctx context.Context) error\n    Stop(ctx context.Context) error\n    Send(ctx context.Context, msg bus.OutboundMessage) error\n    IsRunning() bool\n    IsAllowed(senderID string) bool\n    IsAllowedSender(sender bus.SenderInfo) bool\n    ReasoningChannelID() string\n}\n\n// ===== 可选实现 =====\ntype MediaSender interface {\n    SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error\n}\n\ntype TypingCapable interface {\n    StartTyping(ctx context.Context, chatID string) (stop func(), err error)\n}\n\ntype ReactionCapable interface {\n    ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)\n}\n\ntype PlaceholderCapable interface {\n    SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)\n}\n\ntype MessageEditor interface {\n    EditMessage(ctx context.Context, chatID, messageID, content string) error\n}\n\ntype WebhookHandler interface {\n    WebhookPath() string\n    http.Handler\n}\n\ntype HealthChecker interface {\n    HealthPath() string\n    HealthHandler(w http.ResponseWriter, r *http.Request)\n}\n\ntype MessageLengthProvider interface {\n    MaxMessageLength() int\n}\n\n// ===== 由 Manager 注入 =====\ntype PlaceholderRecorder interface {\n    RecordPlaceholder(channel, chatID, placeholderID string)\n    RecordTypingStop(channel, chatID string, stop func())\n    RecordReactionUndo(channel, chatID string, undo func())\n}\n```\n\n### A.4 Gateway 启动序列（完整引导流程）\n\n```go\n// 1. 创建核心组件\nmsgBus     := bus.NewMessageBus()\nprovider   := providers.CreateProvider(cfg)\nagentLoop  := agent.NewAgentLoop(cfg, msgBus, provider)\n\n// 2. 创建媒体存储（带 TTL 清理）\nmediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig)\nmediaStore.Start()\n\n// 3. 创建 Channel Manager（触发 initChannels → 工厂查找 → 构造 → 注入 MediaStore/PlaceholderRecorder/Owner）\nchannelManager := channels.NewManager(cfg, msgBus, mediaStore)\n\n// 4. 注入引用\nagentLoop.SetChannelManager(channelManager)\nagentLoop.SetMediaStore(mediaStore)\n\n// 5. 配置共享 HTTP 服务器\nchannelManager.SetupHTTPServer(addr, healthServer)\n\n// 6. 启动\nchannelManager.StartAll(ctx)  // 启动 channels + workers + dispatchers + HTTP server\ngo agentLoop.Run(ctx)          // 启动 Agent 消息循环\n\n// 7. 关闭（信号触发）\ncancel()                       // 取消 context\nmsgBus.Close()                 // 信号关闭 + 排水\nchannelManager.StopAll(shutdownCtx)  // 停止 HTTP + workers + channels\nmediaStore.Stop()              // 停止 TTL 清理\nagentLoop.Stop()               // 停止 Agent\n```\n\n### A.5 Per-channel 速率限制参考\n\n| Channel | 速率 (msg/s) | Burst |\n|---------|-------------|-------|\n| telegram | 20 | 10 |\n| discord | 1 | 1 |\n| slack | 1 | 1 |\n| line | 10 | 5 |\n| _其他_ | 10 (默认) | 5 |\n\n### A.6 已知限制和注意事项\n\n1. **媒体清理暂时禁用**：Agent loop 中的 `ReleaseAll` 调用被注释掉了（`refactor(loop): disable media cleanup to prevent premature file deletion`），因为会话边界尚未明确定义。TTL 清理仍然有效。\n\n2. **Feishu 架构特定编译**：Feishu channel 使用 build tags 区分 32 位和 64 位架构（`feishu_32.go` / `feishu_64.go`）。Feishu 使用 SDK 的 WebSocket 模式（非 HTTP webhook），因此不实现 `WebhookHandler`。\n\n3. **WeCom 有两个工厂**：`\"wecom\"`（Bot 模式，纯 webhook）和 `\"wecom_app\"`（应用模式，支持 MediaSender）分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。\n\n4. **Pico Protocol**：`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel，通过 WebSocket webhook (`/pico/ws`) 接收消息。\n\n5. **WhatsApp 有两种模式**：`\"whatsapp\"`（Bridge 模式，通过外部 bridge URL 通信）和 `\"whatsapp_native\"`（原生 whatsmeow 模式，直接连接 WhatsApp）。Manager 根据 `WhatsAppConfig.UseNative` 决定初始化哪个。\n\n6. **DingTalk 使用 Stream 模式**：DingTalk 使用 SDK 的 Stream/WebSocket 模式（非 HTTP webhook），因此不实现 `WebhookHandler`。\n\n7. **PlaceholderConfig 的配置与实现**：`PlaceholderConfig` 出现在 6 个 channel config 中（Telegram、Discord、Slack、LINE、OneBot、Pico），但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel（Telegram、Discord、Pico）能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。\n\n8. **ReasoningChannelID**：大多数 channel config 都包含 `reasoning_channel_id` 字段，用于将 LLM 的思维链（reasoning/thinking）路由到指定 channel（WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp）。注意：`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。"
  },
  {
    "path": "pkg/channels/base.go",
    "content": "package channels\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/binary\"\n\t\"encoding/hex\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nvar (\n\tuniqueIDCounter uint64\n\tuniqueIDPrefix  string\n)\n\nfunc init() {\n\t// One-time read from crypto/rand for a unique prefix (single syscall).\n\tvar b [8]byte\n\tif _, err := rand.Read(b[:]); err != nil {\n\t\t// fallback to time-based prefix\n\t\tbinary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano()))\n\t}\n\tuniqueIDPrefix = hex.EncodeToString(b[:])\n}\n\n// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).\nvar audioAnnotationRe = regexp.MustCompile(`\\[(voice|audio)(?::[^\\]]*)?\\]`)\n\n// uniqueID generates a process-unique ID using a random prefix and an atomic counter.\n// This ID is intended for internal correlation (e.g. media scope keys) and is NOT\n// cryptographically secure — it must not be used in contexts where unpredictability matters.\nfunc uniqueID() string {\n\tn := atomic.AddUint64(&uniqueIDCounter, 1)\n\treturn uniqueIDPrefix + strconv.FormatUint(n, 16)\n}\n\ntype Channel interface {\n\tName() string\n\tStart(ctx context.Context) error\n\tStop(ctx context.Context) error\n\tSend(ctx context.Context, msg bus.OutboundMessage) error\n\tIsRunning() bool\n\tIsAllowed(senderID string) bool\n\tIsAllowedSender(sender bus.SenderInfo) bool\n\tReasoningChannelID() string\n}\n\n// BaseChannelOption is a functional option for configuring a BaseChannel.\ntype BaseChannelOption func(*BaseChannel)\n\n// WithMaxMessageLength sets the maximum message length (in runes) for a channel.\n// Messages exceeding this limit will be automatically split by the Manager.\n// A value of 0 means no limit.\nfunc WithMaxMessageLength(n int) BaseChannelOption {\n\treturn func(c *BaseChannel) { c.maxMessageLength = n }\n}\n\n// WithGroupTrigger sets the group trigger configuration for a channel.\nfunc WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {\n\treturn func(c *BaseChannel) { c.groupTrigger = gt }\n}\n\n// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.\nfunc WithReasoningChannelID(id string) BaseChannelOption {\n\treturn func(c *BaseChannel) { c.reasoningChannelID = id }\n}\n\n// MessageLengthProvider is an opt-in interface that channels implement\n// to advertise their maximum message length. The Manager uses this via\n// type assertion to decide whether to split outbound messages.\ntype MessageLengthProvider interface {\n\tMaxMessageLength() int\n}\n\ntype BaseChannel struct {\n\tconfig              any\n\tbus                 *bus.MessageBus\n\trunning             atomic.Bool\n\tname                string\n\tallowList           []string\n\tmaxMessageLength    int\n\tgroupTrigger        config.GroupTriggerConfig\n\tmediaStore          media.MediaStore\n\tplaceholderRecorder PlaceholderRecorder\n\towner               Channel // the concrete channel that embeds this BaseChannel\n\treasoningChannelID  string\n}\n\nfunc NewBaseChannel(\n\tname string,\n\tconfig any,\n\tbus *bus.MessageBus,\n\tallowList []string,\n\topts ...BaseChannelOption,\n) *BaseChannel {\n\tbc := &BaseChannel{\n\t\tconfig:    config,\n\t\tbus:       bus,\n\t\tname:      name,\n\t\tallowList: allowList,\n\t}\n\tfor _, opt := range opts {\n\t\topt(bc)\n\t}\n\treturn bc\n}\n\n// MaxMessageLength returns the maximum message length (in runes) for this channel.\n// A value of 0 means no limit.\nfunc (c *BaseChannel) MaxMessageLength() int {\n\treturn c.maxMessageLength\n}\n\n// ShouldRespondInGroup determines whether the bot should respond in a group chat.\n// Each channel is responsible for:\n//  1. Detecting isMentioned (platform-specific)\n//  2. Stripping bot mention from content (platform-specific)\n//  3. Calling this method to get the group response decision\n//\n// Logic:\n//   - If isMentioned → always respond\n//   - If mention_only configured and not mentioned → ignore\n//   - If prefixes configured → respond if content starts with any prefix (strip it)\n//   - If prefixes configured but no match and not mentioned → ignore\n//   - Otherwise (no group_trigger configured) → respond to all (permissive default)\nfunc (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {\n\tgt := c.groupTrigger\n\n\t// Mentioned → always respond\n\tif isMentioned {\n\t\treturn true, strings.TrimSpace(content)\n\t}\n\n\t// mention_only → require mention\n\tif gt.MentionOnly {\n\t\treturn false, content\n\t}\n\n\t// Prefix matching\n\tif len(gt.Prefixes) > 0 {\n\t\tfor _, prefix := range gt.Prefixes {\n\t\t\tif prefix != \"\" && strings.HasPrefix(content, prefix) {\n\t\t\t\treturn true, strings.TrimSpace(strings.TrimPrefix(content, prefix))\n\t\t\t}\n\t\t}\n\t\t// Prefixes configured but none matched and not mentioned → ignore\n\t\treturn false, content\n\t}\n\n\t// No group_trigger configured → permissive (respond to all)\n\treturn true, strings.TrimSpace(content)\n}\n\nfunc (c *BaseChannel) Name() string {\n\treturn c.name\n}\n\nfunc (c *BaseChannel) ReasoningChannelID() string {\n\treturn c.reasoningChannelID\n}\n\nfunc (c *BaseChannel) IsRunning() bool {\n\treturn c.running.Load()\n}\n\nfunc (c *BaseChannel) IsAllowed(senderID string) bool {\n\tif len(c.allowList) == 0 {\n\t\treturn true\n\t}\n\n\t// Extract parts from compound senderID like \"123456|username\"\n\tidPart := senderID\n\tuserPart := \"\"\n\tif idx := strings.Index(senderID, \"|\"); idx > 0 {\n\t\tidPart = senderID[:idx]\n\t\tuserPart = senderID[idx+1:]\n\t}\n\n\tfor _, allowed := range c.allowList {\n\t\t// Strip leading \"@\" from allowed value for username matching\n\t\ttrimmed := strings.TrimPrefix(allowed, \"@\")\n\t\tallowedID := trimmed\n\t\tallowedUser := \"\"\n\t\tif idx := strings.Index(trimmed, \"|\"); idx > 0 {\n\t\t\tallowedID = trimmed[:idx]\n\t\t\tallowedUser = trimmed[idx+1:]\n\t\t}\n\n\t\t// Support either side using \"id|username\" compound form.\n\t\t// This keeps backward compatibility with legacy Telegram allowlist entries.\n\t\tif senderID == allowed ||\n\t\t\tidPart == allowed ||\n\t\t\tsenderID == trimmed ||\n\t\t\tidPart == trimmed ||\n\t\t\tidPart == allowedID ||\n\t\t\t(allowedUser != \"\" && senderID == allowedUser) ||\n\t\t\t(userPart != \"\" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.\n// It delegates to identity.MatchAllowed for each entry, providing unified matching\n// across all legacy formats and the new canonical \"platform:id\" format.\nfunc (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {\n\tif len(c.allowList) == 0 {\n\t\treturn true\n\t}\n\n\tfor _, allowed := range c.allowList {\n\t\tif identity.MatchAllowed(sender, allowed) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (c *BaseChannel) HandleMessage(\n\tctx context.Context,\n\tpeer bus.Peer,\n\tmessageID, senderID, chatID, content string,\n\tmedia []string,\n\tmetadata map[string]string,\n\tsenderOpts ...bus.SenderInfo,\n) {\n\t// Use SenderInfo-based allow check when available, else fall back to string\n\tvar sender bus.SenderInfo\n\tif len(senderOpts) > 0 {\n\t\tsender = senderOpts[0]\n\t}\n\tif sender.CanonicalID != \"\" || sender.PlatformID != \"\" {\n\t\tif !c.IsAllowedSender(sender) {\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif !c.IsAllowed(senderID) {\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Set SenderID to canonical if available, otherwise keep the raw senderID\n\tresolvedSenderID := senderID\n\tif sender.CanonicalID != \"\" {\n\t\tresolvedSenderID = sender.CanonicalID\n\t}\n\n\tscope := BuildMediaScope(c.name, chatID, messageID)\n\n\tmsg := bus.InboundMessage{\n\t\tChannel:    c.name,\n\t\tSenderID:   resolvedSenderID,\n\t\tSender:     sender,\n\t\tChatID:     chatID,\n\t\tContent:    content,\n\t\tMedia:      media,\n\t\tPeer:       peer,\n\t\tMessageID:  messageID,\n\t\tMediaScope: scope,\n\t\tMetadata:   metadata,\n\t}\n\n\t// Auto-trigger typing indicator, message reaction, and placeholder before publishing.\n\t// Each capability is independent — all three may fire for the same message.\n\tif c.owner != nil && c.placeholderRecorder != nil {\n\t\t// Typing — independent pipeline\n\t\tif tc, ok := c.owner.(TypingCapable); ok {\n\t\t\tif stop, err := tc.StartTyping(ctx, chatID); err == nil {\n\t\t\t\tc.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)\n\t\t\t}\n\t\t}\n\t\t// Reaction — independent pipeline\n\t\tif rc, ok := c.owner.(ReactionCapable); ok && messageID != \"\" {\n\t\t\tif undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {\n\t\t\t\tc.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)\n\t\t\t}\n\t\t}\n\t\t// Placeholder — independent pipeline.\n\t\t// Skip when the message contains audio: the agent will send the\n\t\t// placeholder after transcription completes, so the user sees\n\t\t// \"Thinking…\" only once the voice has been processed.\n\t\tif !audioAnnotationRe.MatchString(content) {\n\t\t\tif pc, ok := c.owner.(PlaceholderCapable); ok {\n\t\t\t\tif phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != \"\" {\n\t\t\t\t\tc.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := c.bus.PublishInbound(ctx, msg); err != nil {\n\t\tlogger.ErrorCF(\"channels\", \"Failed to publish inbound message\", map[string]any{\n\t\t\t\"channel\": c.name,\n\t\t\t\"chat_id\": chatID,\n\t\t\t\"error\":   err.Error(),\n\t\t})\n\t}\n}\n\nfunc (c *BaseChannel) SetRunning(running bool) {\n\tc.running.Store(running)\n}\n\n// SetMediaStore injects a MediaStore into the channel.\nfunc (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }\n\n// GetMediaStore returns the injected MediaStore (may be nil).\nfunc (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }\n\n// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.\nfunc (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {\n\tc.placeholderRecorder = r\n}\n\n// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil).\nfunc (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder {\n\treturn c.placeholderRecorder\n}\n\n// SetOwner injects the concrete channel that embeds this BaseChannel.\n// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable.\nfunc (c *BaseChannel) SetOwner(ch Channel) {\n\tc.owner = ch\n}\n\n// BuildMediaScope constructs a scope key for media lifecycle tracking.\nfunc BuildMediaScope(channel, chatID, messageID string) string {\n\tid := messageID\n\tif id == \"\" {\n\t\tid = uniqueID()\n\t}\n\treturn channel + \":\" + chatID + \":\" + id\n}\n"
  },
  {
    "path": "pkg/channels/base_test.go",
    "content": "package channels\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestBaseChannelIsAllowed(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tallowList []string\n\t\tsenderID  string\n\t\twant      bool\n\t}{\n\t\t{\n\t\t\tname:      \"empty allowlist allows all\",\n\t\t\tallowList: nil,\n\t\t\tsenderID:  \"anyone\",\n\t\t\twant:      true,\n\t\t},\n\t\t{\n\t\t\tname:      \"compound sender matches numeric allowlist\",\n\t\t\tallowList: []string{\"123456\"},\n\t\t\tsenderID:  \"123456|alice\",\n\t\t\twant:      true,\n\t\t},\n\t\t{\n\t\t\tname:      \"compound sender matches username allowlist\",\n\t\t\tallowList: []string{\"@alice\"},\n\t\t\tsenderID:  \"123456|alice\",\n\t\t\twant:      true,\n\t\t},\n\t\t{\n\t\t\tname:      \"numeric sender matches legacy compound allowlist\",\n\t\t\tallowList: []string{\"123456|alice\"},\n\t\t\tsenderID:  \"123456\",\n\t\t\twant:      true,\n\t\t},\n\t\t{\n\t\t\tname:      \"non matching sender is denied\",\n\t\t\tallowList: []string{\"123456\"},\n\t\t\tsenderID:  \"654321|bob\",\n\t\t\twant:      false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tch := NewBaseChannel(\"test\", nil, nil, tt.allowList)\n\t\t\tif got := ch.IsAllowed(tt.senderID); got != tt.want {\n\t\t\t\tt.Fatalf(\"IsAllowed(%q) = %v, want %v\", tt.senderID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestShouldRespondInGroup(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tgt          config.GroupTriggerConfig\n\t\tisMentioned bool\n\t\tcontent     string\n\t\twantRespond bool\n\t\twantContent string\n\t}{\n\t\t{\n\t\t\tname:        \"no config - permissive default\",\n\t\t\tgt:          config.GroupTriggerConfig{},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no config - mentioned\",\n\t\t\tgt:          config.GroupTriggerConfig{},\n\t\t\tisMentioned: true,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"mention_only - not mentioned\",\n\t\t\tgt:          config.GroupTriggerConfig{MentionOnly: true},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: false,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"mention_only - mentioned\",\n\t\t\tgt:          config.GroupTriggerConfig{MentionOnly: true},\n\t\t\tisMentioned: true,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix match\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"/ask\"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"/ask hello\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix no match - not mentioned\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"/ask\"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: false,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix no match - but mentioned\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"/ask\"}},\n\t\t\tisMentioned: true,\n\t\t\tcontent:     \"hello world\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"multiple prefixes - second matches\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"/ask\", \"/bot\"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"/bot help me\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"help me\",\n\t\t},\n\t\t{\n\t\t\tname:        \"mention_only with prefixes - mentioned overrides\",\n\t\t\tgt:          config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{\"/ask\"}},\n\t\t\tisMentioned: true,\n\t\t\tcontent:     \"hello\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:        \"mention_only with prefixes - not mentioned, no prefix\",\n\t\t\tgt:          config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{\"/ask\"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"hello\",\n\t\t\twantRespond: false,\n\t\t\twantContent: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty prefix in list is skipped\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"\", \"/ask\"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"/ask test\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"test\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix strips leading whitespace after prefix\",\n\t\t\tgt:          config.GroupTriggerConfig{Prefixes: []string{\"/ask \"}},\n\t\t\tisMentioned: false,\n\t\t\tcontent:     \"/ask hello\",\n\t\t\twantRespond: true,\n\t\t\twantContent: \"hello\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tch := NewBaseChannel(\"test\", nil, nil, nil, WithGroupTrigger(tt.gt))\n\t\t\tgotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)\n\t\t\tif gotRespond != tt.wantRespond {\n\t\t\t\tt.Errorf(\"ShouldRespondInGroup() respond = %v, want %v\", gotRespond, tt.wantRespond)\n\t\t\t}\n\t\t\tif gotContent != tt.wantContent {\n\t\t\t\tt.Errorf(\"ShouldRespondInGroup() content = %q, want %q\", gotContent, tt.wantContent)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsAllowedSender(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tallowList []string\n\t\tsender    bus.SenderInfo\n\t\twant      bool\n\t}{\n\t\t{\n\t\t\tname:      \"empty allowlist allows all\",\n\t\t\tallowList: nil,\n\t\t\tsender:    bus.SenderInfo{PlatformID: \"anyone\"},\n\t\t\twant:      true,\n\t\t},\n\t\t{\n\t\t\tname:      \"numeric ID matches PlatformID\",\n\t\t\tallowList: []string{\"123456\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"canonical format matches\",\n\t\t\tallowList: []string{\"telegram:123456\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"canonical format wrong platform\",\n\t\t\tallowList: []string{\"discord:123456\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"@username matches\",\n\t\t\tallowList: []string{\"@alice\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t\tUsername:    \"alice\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"compound id|username matches by ID\",\n\t\t\tallowList: []string{\"123456|alice\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t\tUsername:    \"alice\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"non matching sender denied\",\n\t\t\tallowList: []string{\"654321\"},\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:    \"telegram\",\n\t\t\t\tPlatformID:  \"123456\",\n\t\t\t\tCanonicalID: \"telegram:123456\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tch := NewBaseChannel(\"test\", nil, nil, tt.allowList)\n\t\t\tif got := ch.IsAllowedSender(tt.sender); got != tt.want {\n\t\t\t\tt.Fatalf(\"IsAllowedSender(%+v) = %v, want %v\", tt.sender, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/dingtalk/dingtalk.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// DingTalk channel implementation using Stream Mode\n\npackage dingtalk\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot\"\n\t\"github.com/open-dingtalk/dingtalk-stream-sdk-go/client\"\n\tdinglog \"github.com/open-dingtalk/dingtalk-stream-sdk-go/logger\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// DingTalkChannel implements the Channel interface for DingTalk (钉钉)\n// It uses WebSocket for receiving messages via stream mode and API for sending\ntype DingTalkChannel struct {\n\t*channels.BaseChannel\n\tconfig       config.DingTalkConfig\n\tclientID     string\n\tclientSecret string\n\tstreamClient *client.StreamClient\n\tctx          context.Context\n\tcancel       context.CancelFunc\n\t// Map to store session webhooks for each chat\n\tsessionWebhooks sync.Map // chatID -> sessionWebhook\n}\n\n// NewDingTalkChannel creates a new DingTalk channel instance\nfunc NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {\n\tif cfg.ClientID == \"\" || cfg.ClientSecret == \"\" {\n\t\treturn nil, fmt.Errorf(\"dingtalk client_id and client_secret are required\")\n\t}\n\n\t// Set the logger for the Stream SDK\n\tdinglog.SetLogger(logger.NewLogger(\"dingtalk\"))\n\n\tbase := channels.NewBaseChannel(\"dingtalk\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(20000),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &DingTalkChannel{\n\t\tBaseChannel:  base,\n\t\tconfig:       cfg,\n\t\tclientID:     cfg.ClientID,\n\t\tclientSecret: cfg.ClientSecret,\n\t}, nil\n}\n\n// Start initializes the DingTalk channel with Stream Mode\nfunc (c *DingTalkChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"dingtalk\", \"Starting DingTalk channel (Stream Mode)...\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// Create credential config\n\tcred := client.NewAppCredentialConfig(c.clientID, c.clientSecret)\n\n\t// Create the stream client with options\n\tc.streamClient = client.NewStreamClient(\n\t\tclient.WithAppCredential(cred),\n\t\tclient.WithAutoReconnect(true),\n\t)\n\n\t// Register chatbot callback handler (IChatBotMessageHandler is a function type)\n\tc.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived)\n\n\t// Start the stream client\n\tif err := c.streamClient.Start(c.ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to start stream client: %w\", err)\n\t}\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"dingtalk\", \"DingTalk channel started (Stream Mode)\")\n\treturn nil\n}\n\n// Stop gracefully stops the DingTalk channel\nfunc (c *DingTalkChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"dingtalk\", \"Stopping DingTalk channel...\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tif c.streamClient != nil {\n\t\tc.streamClient.Close()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"dingtalk\", \"DingTalk channel stopped\")\n\treturn nil\n}\n\n// Send sends a message to DingTalk via the chatbot reply API\nfunc (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// Get session webhook from storage\n\tsessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID)\n\tif !ok {\n\t\treturn fmt.Errorf(\"no session_webhook found for chat %s, cannot send message\", msg.ChatID)\n\t}\n\n\tsessionWebhook, ok := sessionWebhookRaw.(string)\n\tif !ok {\n\t\treturn fmt.Errorf(\"invalid session_webhook type for chat %s\", msg.ChatID)\n\t}\n\n\tlogger.DebugCF(\"dingtalk\", \"Sending message\", map[string]any{\n\t\t\"chat_id\": msg.ChatID,\n\t\t\"preview\": utils.Truncate(msg.Content, 100),\n\t})\n\n\t// Use the session webhook to send the reply\n\treturn c.SendDirectReply(ctx, sessionWebhook, msg.Content)\n}\n\n// onChatBotMessageReceived implements the IChatBotMessageHandler function signature\n// This is called by the Stream SDK when a new message arrives\n// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)\nfunc (c *DingTalkChannel) onChatBotMessageReceived(\n\tctx context.Context,\n\tdata *chatbot.BotCallbackDataModel,\n) ([]byte, error) {\n\t// Extract message content from Text field\n\tcontent := data.Text.Content\n\tif content == \"\" {\n\t\t// Try to extract from Content interface{} if Text is empty\n\t\tif contentMap, ok := data.Content.(map[string]any); ok {\n\t\t\tif textContent, ok := contentMap[\"content\"].(string); ok {\n\t\t\t\tcontent = textContent\n\t\t\t}\n\t\t}\n\t}\n\n\tif content == \"\" {\n\t\treturn nil, nil // Ignore empty messages\n\t}\n\n\tsenderID := data.SenderStaffId\n\tsenderNick := data.SenderNick\n\tchatID := senderID\n\tif data.ConversationType != \"1\" {\n\t\t// For group chats\n\t\tchatID = data.ConversationId\n\t}\n\n\t// Store the session webhook for this chat so we can reply later\n\tc.sessionWebhooks.Store(chatID, data.SessionWebhook)\n\n\tmetadata := map[string]string{\n\t\t\"sender_name\":       senderNick,\n\t\t\"conversation_id\":   data.ConversationId,\n\t\t\"conversation_type\": data.ConversationType,\n\t\t\"platform\":          \"dingtalk\",\n\t\t\"session_webhook\":   data.SessionWebhook,\n\t}\n\n\tvar peer bus.Peer\n\tif data.ConversationType == \"1\" {\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: senderID}\n\t} else {\n\t\tpeer = bus.Peer{Kind: \"group\", ID: data.ConversationId}\n\t\t// In group chats, apply unified group trigger filtering\n\t\trespond, cleaned := c.ShouldRespondInGroup(false, content)\n\t\tif !respond {\n\t\t\treturn nil, nil\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tlogger.DebugCF(\"dingtalk\", \"Received message\", map[string]any{\n\t\t\"sender_nick\": senderNick,\n\t\t\"sender_id\":   senderID,\n\t\t\"preview\":     utils.Truncate(content, 50),\n\t})\n\n\t// Build sender info\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"dingtalk\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"dingtalk\", senderID),\n\t\tDisplayName: senderNick,\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn nil, nil\n\t}\n\n\t// Handle the message through the base channel\n\tc.HandleMessage(ctx, peer, \"\", senderID, chatID, content, nil, metadata, sender)\n\n\t// Return nil to indicate we've handled the message asynchronously\n\t// The response will be sent through the message bus\n\treturn nil, nil\n}\n\n// SendDirectReply sends a direct reply using the session webhook\nfunc (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error {\n\treplier := chatbot.NewChatbotReplier()\n\n\t// Convert string content to []byte for the API\n\tcontentBytes := []byte(content)\n\ttitleBytes := []byte(\"PicoClaw\")\n\n\t// Send markdown formatted reply\n\terr := replier.SimpleReplyMarkdown(\n\t\tctx,\n\t\tsessionWebhook,\n\t\ttitleBytes,\n\t\tcontentBytes,\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"dingtalk send: %w\", channels.ErrTemporary)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/channels/dingtalk/init.go",
    "content": "package dingtalk\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"dingtalk\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewDingTalkChannel(cfg.Channels.DingTalk, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/discord/discord.go",
    "content": "package discord\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/bwmarrin/discordgo\"\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tsendTimeout = 10 * time.Second\n)\n\nvar (\n\t// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)\n\tchannelRefRe = regexp.MustCompile(`<#(\\d+)>`)\n\tmsgLinkRe    = regexp.MustCompile(`https://(?:discord\\.com|discordapp\\.com)/channels/(\\d+)/(\\d+)/(\\d+)`)\n)\n\ntype DiscordChannel struct {\n\t*channels.BaseChannel\n\tsession    *discordgo.Session\n\tconfig     config.DiscordConfig\n\tctx        context.Context\n\tcancel     context.CancelFunc\n\ttypingMu   sync.Mutex\n\ttypingStop map[string]chan struct{} // chatID → stop signal\n\tbotUserID  string                   // stored for mention checking\n}\n\nfunc NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {\n\tdiscordgo.Logger = logger.NewLogger(\"discord\").\n\t\tWithLevels(map[int]logger.LogLevel{\n\t\t\tdiscordgo.LogError:         logger.ERROR,\n\t\t\tdiscordgo.LogWarning:       logger.WARN,\n\t\t\tdiscordgo.LogInformational: logger.INFO,\n\t\t\tdiscordgo.LogDebug:         logger.DEBUG,\n\t\t}).Log\n\n\tsession, err := discordgo.New(\"Bot \" + cfg.Token)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create discord session: %w\", err)\n\t}\n\n\tif err := applyDiscordProxy(session, cfg.Proxy); err != nil {\n\t\treturn nil, err\n\t}\n\tbase := channels.NewBaseChannel(\"discord\", cfg, bus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(2000),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &DiscordChannel{\n\t\tBaseChannel: base,\n\t\tsession:     session,\n\t\tconfig:      cfg,\n\t\tctx:         context.Background(),\n\t\ttypingStop:  make(map[string]chan struct{}),\n\t}, nil\n}\n\nfunc (c *DiscordChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"discord\", \"Starting Discord bot\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// Get bot user ID before opening session to avoid race condition\n\tbotUser, err := c.session.User(\"@me\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get bot user: %w\", err)\n\t}\n\tc.botUserID = botUser.ID\n\n\tc.session.AddHandler(c.handleMessage)\n\n\tif err := c.session.Open(); err != nil {\n\t\treturn fmt.Errorf(\"failed to open discord session: %w\", err)\n\t}\n\n\tc.SetRunning(true)\n\n\tlogger.InfoCF(\"discord\", \"Discord bot connected\", map[string]any{\n\t\t\"username\": botUser.Username,\n\t\t\"user_id\":  botUser.ID,\n\t})\n\n\treturn nil\n}\n\nfunc (c *DiscordChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"discord\", \"Stopping Discord bot\")\n\tc.SetRunning(false)\n\n\t// Stop all typing goroutines before closing session\n\tc.typingMu.Lock()\n\tfor chatID, stop := range c.typingStop {\n\t\tclose(stop)\n\t\tdelete(c.typingStop, chatID)\n\t}\n\tc.typingMu.Unlock()\n\n\t// Cancel our context so typing goroutines using c.ctx.Done() exit\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tif err := c.session.Close(); err != nil {\n\t\treturn fmt.Errorf(\"failed to close discord session: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchannelID := msg.ChatID\n\tif channelID == \"\" {\n\t\treturn fmt.Errorf(\"channel ID is empty\")\n\t}\n\n\tif len([]rune(msg.Content)) == 0 {\n\t\treturn nil\n\t}\n\n\treturn c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)\n}\n\n// SendMedia implements the channels.MediaSender interface.\nfunc (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchannelID := msg.ChatID\n\tif channelID == \"\" {\n\t\treturn fmt.Errorf(\"channel ID is empty\")\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\t// Collect all files into a single ChannelMessageSendComplex call\n\tfiles := make([]*discordgo.File, 0, len(msg.Parts))\n\tvar caption string\n\n\tfor _, part := range msg.Parts {\n\t\tlocalPath, err := store.Resolve(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"discord\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfile, err := os.Open(localPath)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"discord\", \"Failed to open media file\", map[string]any{\n\t\t\t\t\"path\":  localPath,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\t// Note: discordgo reads from the Reader and we can't close it before send\n\n\t\tfilename := part.Filename\n\t\tif filename == \"\" {\n\t\t\tfilename = \"file\"\n\t\t}\n\n\t\tfiles = append(files, &discordgo.File{\n\t\t\tName:        filename,\n\t\t\tContentType: part.ContentType,\n\t\t\tReader:      file,\n\t\t})\n\n\t\tif part.Caption != \"\" && caption == \"\" {\n\t\t\tcaption = part.Caption\n\t\t}\n\t}\n\n\tif len(files) == 0 {\n\t\treturn nil\n\t}\n\n\tsendCtx, cancel := context.WithTimeout(ctx, sendTimeout)\n\tdefer cancel()\n\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{\n\t\t\tContent: caption,\n\t\t\tFiles:   files,\n\t\t})\n\t\tdone <- err\n\t}()\n\n\tselect {\n\tcase err := <-done:\n\t\t// Close all file readers\n\t\tfor _, f := range files {\n\t\t\tif closer, ok := f.Reader.(*os.File); ok {\n\t\t\t\tcloser.Close()\n\t\t\t}\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"discord send media: %w\", channels.ErrTemporary)\n\t\t}\n\t\treturn nil\n\tcase <-sendCtx.Done():\n\t\t// Close all file readers\n\t\tfor _, f := range files {\n\t\t\tif closer, ok := f.Reader.(*os.File); ok {\n\t\t\t\tcloser.Close()\n\t\t\t}\n\t\t}\n\t\treturn sendCtx.Err()\n\t}\n}\n\n// EditMessage implements channels.MessageEditor.\nfunc (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {\n\t_, err := c.session.ChannelMessageEdit(chatID, messageID, content)\n\treturn err\n}\n\n// SendPlaceholder implements channels.PlaceholderCapable.\n// It sends a placeholder message that will later be edited to the actual\n// response via EditMessage (channels.MessageEditor).\nfunc (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tif !c.config.Placeholder.Enabled {\n\t\treturn \"\", nil\n\t}\n\n\ttext := c.config.Placeholder.Text\n\tif text == \"\" {\n\t\ttext = \"Thinking... 💭\"\n\t}\n\n\tmsg, err := c.session.ChannelMessageSend(chatID, text)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn msg.ID, nil\n}\n\nfunc (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {\n\t// Use the passed ctx for timeout control\n\tsendCtx, cancel := context.WithTimeout(ctx, sendTimeout)\n\tdefer cancel()\n\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tvar err error\n\n\t\t// If we have an ID, we send the message as \"Reply\"\n\t\tif replyToID != \"\" {\n\t\t\t_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{\n\t\t\t\tContent: content,\n\t\t\t\tReference: &discordgo.MessageReference{\n\t\t\t\t\tMessageID: replyToID,\n\t\t\t\t\tChannelID: channelID,\n\t\t\t\t},\n\t\t\t})\n\t\t} else {\n\t\t\t// Otherwise, we send a normal message\n\t\t\t_, err = c.session.ChannelMessageSend(channelID, content)\n\t\t}\n\n\t\tdone <- err\n\t}()\n\n\tselect {\n\tcase err := <-done:\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"discord send: %w\", channels.ErrTemporary)\n\t\t}\n\t\treturn nil\n\tcase <-sendCtx.Done():\n\t\treturn sendCtx.Err()\n\t}\n}\n\n// appendContent safely appends content to existing text\nfunc appendContent(content, suffix string) string {\n\tif content == \"\" {\n\t\treturn suffix\n\t}\n\treturn content + \"\\n\" + suffix\n}\n\nfunc (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {\n\tif m == nil || m.Author == nil {\n\t\treturn\n\t}\n\n\tif m.Author.ID == s.State.User.ID {\n\t\treturn\n\t}\n\n\t// Check allowlist first to avoid downloading attachments for rejected users\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"discord\",\n\t\tPlatformID:  m.Author.ID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"discord\", m.Author.ID),\n\t\tUsername:    m.Author.Username,\n\t}\n\t// Build display name\n\tdisplayName := m.Author.Username\n\tif m.Author.Discriminator != \"\" && m.Author.Discriminator != \"0\" {\n\t\tdisplayName += \"#\" + m.Author.Discriminator\n\t}\n\tsender.DisplayName = displayName\n\n\tif !c.IsAllowedSender(sender) {\n\t\tlogger.DebugCF(\"discord\", \"Message rejected by allowlist\", map[string]any{\n\t\t\t\"user_id\": m.Author.ID,\n\t\t})\n\t\treturn\n\t}\n\n\tcontent := m.Content\n\n\t// In guild (group) channels, apply unified group trigger filtering\n\t// DMs (GuildID is empty) always get a response\n\tif m.GuildID != \"\" {\n\t\tisMentioned := false\n\t\tfor _, mention := range m.Mentions {\n\t\t\tif mention.ID == c.botUserID {\n\t\t\t\tisMentioned = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tcontent = c.stripBotMention(content)\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\tlogger.DebugCF(\"discord\", \"Group message ignored by group trigger\", map[string]any{\n\t\t\t\t\"user_id\": m.Author.ID,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t} else {\n\t\t// DMs: just strip bot mention without filtering\n\t\tcontent = c.stripBotMention(content)\n\t}\n\n\t// Resolve Discord refs in main content before concatenation to avoid\n\t// double-expanding links that appear in the referenced message.\n\tcontent = c.resolveDiscordRefs(s, content, m.GuildID)\n\n\t// Prepend referenced (quoted) message content if this is a reply\n\tif m.MessageReference != nil && m.ReferencedMessage != nil {\n\t\trefContent := m.ReferencedMessage.Content\n\t\tif refContent != \"\" {\n\t\t\trefAuthor := \"unknown\"\n\t\t\tif m.ReferencedMessage.Author != nil {\n\t\t\t\trefAuthor = m.ReferencedMessage.Author.Username\n\t\t\t}\n\t\t\trefContent = c.resolveDiscordRefs(s, refContent, m.GuildID)\n\t\t\tcontent = fmt.Sprintf(\"[quoted message from %s]: %s\\n\\n%s\",\n\t\t\t\trefAuthor, refContent, content)\n\t\t}\n\t}\n\n\tsenderID := m.Author.ID\n\n\tmediaPaths := make([]string, 0, len(m.Attachments))\n\n\tscope := channels.BuildMediaScope(\"discord\", m.ChannelID, m.ID)\n\n\t// Helper to register a local file with the media store\n\tstoreMedia := func(localPath, filename string) string {\n\t\tif store := c.GetMediaStore(); store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename: filename,\n\t\t\t\tSource:   \"discord\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath // fallback\n\t}\n\n\tfor _, attachment := range m.Attachments {\n\t\tisAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)\n\n\t\tif isAudio {\n\t\t\tlocalPath := c.downloadAttachment(attachment.URL, attachment.Filename)\n\t\t\tif localPath != \"\" {\n\t\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))\n\t\t\t\tcontent = appendContent(content, fmt.Sprintf(\"[audio: %s]\", attachment.Filename))\n\t\t\t} else {\n\t\t\t\tlogger.WarnCF(\"discord\", \"Failed to download audio attachment\", map[string]any{\n\t\t\t\t\t\"url\":      attachment.URL,\n\t\t\t\t\t\"filename\": attachment.Filename,\n\t\t\t\t})\n\t\t\t\tmediaPaths = append(mediaPaths, attachment.URL)\n\t\t\t\tcontent = appendContent(content, fmt.Sprintf(\"[attachment: %s]\", attachment.URL))\n\t\t\t}\n\t\t} else {\n\t\t\tmediaPaths = append(mediaPaths, attachment.URL)\n\t\t\tcontent = appendContent(content, fmt.Sprintf(\"[attachment: %s]\", attachment.URL))\n\t\t}\n\t}\n\n\tif content == \"\" && len(mediaPaths) == 0 {\n\t\treturn\n\t}\n\n\tif content == \"\" {\n\t\tcontent = \"[media only]\"\n\t}\n\n\tlogger.DebugCF(\"discord\", \"Received message\", map[string]any{\n\t\t\"sender_name\": sender.DisplayName,\n\t\t\"sender_id\":   senderID,\n\t\t\"preview\":     utils.Truncate(content, 50),\n\t})\n\n\tpeerKind := \"channel\"\n\tpeerID := m.ChannelID\n\tif m.GuildID == \"\" {\n\t\tpeerKind = \"direct\"\n\t\tpeerID = senderID\n\t}\n\n\tpeer := bus.Peer{Kind: peerKind, ID: peerID}\n\n\tmetadata := map[string]string{\n\t\t\"user_id\":      senderID,\n\t\t\"username\":     m.Author.Username,\n\t\t\"display_name\": sender.DisplayName,\n\t\t\"guild_id\":     m.GuildID,\n\t\t\"channel_id\":   m.ChannelID,\n\t\t\"is_dm\":        fmt.Sprintf(\"%t\", m.GuildID == \"\"),\n\t}\n\n\tc.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)\n}\n\n// startTyping starts a continuous typing indicator loop for the given chatID.\n// It stops any existing typing loop for that chatID before starting a new one.\nfunc (c *DiscordChannel) startTyping(chatID string) {\n\tc.typingMu.Lock()\n\t// Stop existing loop for this chatID if any\n\tif stop, ok := c.typingStop[chatID]; ok {\n\t\tclose(stop)\n\t}\n\tstop := make(chan struct{})\n\tc.typingStop[chatID] = stop\n\tc.typingMu.Unlock()\n\n\tgo func() {\n\t\tif err := c.session.ChannelTyping(chatID); err != nil {\n\t\t\tlogger.DebugCF(\"discord\", \"ChannelTyping error\", map[string]any{\"chatID\": chatID, \"err\": err})\n\t\t}\n\t\tticker := time.NewTicker(8 * time.Second)\n\t\tdefer ticker.Stop()\n\t\ttimeout := time.After(5 * time.Minute)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-stop:\n\t\t\t\treturn\n\t\t\tcase <-timeout:\n\t\t\t\treturn\n\t\t\tcase <-c.ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tif err := c.session.ChannelTyping(chatID); err != nil {\n\t\t\t\t\tlogger.DebugCF(\"discord\", \"ChannelTyping error\", map[string]any{\"chatID\": chatID, \"err\": err})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// stopTyping stops the typing indicator loop for the given chatID.\nfunc (c *DiscordChannel) stopTyping(chatID string) {\n\tc.typingMu.Lock()\n\tdefer c.typingMu.Unlock()\n\tif stop, ok := c.typingStop[chatID]; ok {\n\t\tclose(stop)\n\t\tdelete(c.typingStop, chatID)\n\t}\n}\n\n// StartTyping implements channels.TypingCapable.\n// It starts a continuous typing indicator and returns an idempotent stop function.\nfunc (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tc.startTyping(chatID)\n\treturn func() { c.stopTyping(chatID) }, nil\n}\n\nfunc (c *DiscordChannel) downloadAttachment(url, filename string) string {\n\treturn utils.DownloadFile(url, filename, utils.DownloadOptions{\n\t\tLoggerPrefix: \"discord\",\n\t\tProxyURL:     c.config.Proxy,\n\t})\n}\n\nfunc applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {\n\tvar proxyFunc func(*http.Request) (*url.URL, error)\n\tif proxyAddr != \"\" {\n\t\tproxyURL, err := url.Parse(proxyAddr)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid discord proxy URL %q: %w\", proxyAddr, err)\n\t\t}\n\t\tproxyFunc = http.ProxyURL(proxyURL)\n\t} else if os.Getenv(\"HTTP_PROXY\") != \"\" || os.Getenv(\"HTTPS_PROXY\") != \"\" {\n\t\tproxyFunc = http.ProxyFromEnvironment\n\t}\n\n\tif proxyFunc == nil {\n\t\treturn nil\n\t}\n\n\ttransport := &http.Transport{Proxy: proxyFunc}\n\tsession.Client = &http.Client{\n\t\tTimeout:   sendTimeout,\n\t\tTransport: transport,\n\t}\n\n\tif session.Dialer != nil {\n\t\tdialerCopy := *session.Dialer\n\t\tdialerCopy.Proxy = proxyFunc\n\t\tsession.Dialer = &dialerCopy\n\t} else {\n\t\tsession.Dialer = &websocket.Dialer{Proxy: proxyFunc}\n\t}\n\n\treturn nil\n}\n\n// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and\n// expands Discord message links to show the linked message content.\n// Only links pointing to the same guild are expanded to prevent cross-guild leakage.\nfunc (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {\n\t// 1. Resolve channel references: <#id> → #channel-name\n\ttext = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {\n\t\tparts := channelRefRe.FindStringSubmatch(match)\n\t\tif len(parts) < 2 {\n\t\t\treturn match\n\t\t}\n\t\t// Prefer session state cache to avoid API calls\n\t\tif ch, err := s.State.Channel(parts[1]); err == nil {\n\t\t\treturn \"#\" + ch.Name\n\t\t}\n\t\tif ch, err := s.Channel(parts[1]); err == nil {\n\t\t\treturn \"#\" + ch.Name\n\t\t}\n\t\treturn match\n\t})\n\n\t// 2. Expand Discord message links (max 3, same guild only)\n\tmatches := msgLinkRe.FindAllStringSubmatch(text, 3)\n\tfor _, m := range matches {\n\t\tif len(m) < 4 {\n\t\t\tcontinue\n\t\t}\n\t\tlinkGuildID, channelID, messageID := m[1], m[2], m[3]\n\t\t// Security: only expand links from the same guild\n\t\tif linkGuildID != guildID {\n\t\t\tcontinue\n\t\t}\n\t\tmsg, err := s.ChannelMessage(channelID, messageID)\n\t\tif err != nil || msg == nil || msg.Content == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tauthor := \"unknown\"\n\t\tif msg.Author != nil {\n\t\t\tauthor = msg.Author.Username\n\t\t}\n\t\ttext += fmt.Sprintf(\"\\n[linked message from %s]: %s\", author, msg.Content)\n\t}\n\n\treturn text\n}\n\n// stripBotMention removes the bot mention from the message content.\n// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).\nfunc (c *DiscordChannel) stripBotMention(text string) string {\n\tif c.botUserID == \"\" {\n\t\treturn text\n\t}\n\t// Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>\n\ttext = strings.ReplaceAll(text, fmt.Sprintf(\"<@%s>\", c.botUserID), \"\")\n\ttext = strings.ReplaceAll(text, fmt.Sprintf(\"<@!%s>\", c.botUserID), \"\")\n\treturn strings.TrimSpace(text)\n}\n"
  },
  {
    "path": "pkg/channels/discord/discord_resolve_test.go",
    "content": "package discord\n\nimport (\n\t\"testing\"\n)\n\nfunc TestChannelRefRegex(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tinput  string\n\t\twantID string\n\t\twantOK bool\n\t}{\n\t\t{\"basic channel ref\", \"<#123456789>\", \"123456789\", true},\n\t\t{\"long id\", \"<#9876543210123456>\", \"9876543210123456\", true},\n\t\t{\"no match plain text\", \"hello world\", \"\", false},\n\t\t{\"no match partial\", \"<#>\", \"\", false},\n\t\t{\"no match letters\", \"<#abc>\", \"\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmatches := channelRefRe.FindStringSubmatch(tt.input)\n\t\t\tif tt.wantOK {\n\t\t\t\tif len(matches) < 2 || matches[1] != tt.wantID {\n\t\t\t\t\tt.Errorf(\"channelRefRe(%q) = %v, want ID %q\", tt.input, matches, tt.wantID)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif len(matches) >= 2 {\n\t\t\t\t\tt.Errorf(\"channelRefRe(%q) should not match, got %v\", tt.input, matches)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMsgLinkRegex(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tinput     string\n\t\twantGuild string\n\t\twantChan  string\n\t\twantMsg   string\n\t\twantOK    bool\n\t}{\n\t\t{\n\t\t\t\"discord.com link\",\n\t\t\t\"https://discord.com/channels/111/222/333\",\n\t\t\t\"111\", \"222\", \"333\", true,\n\t\t},\n\t\t{\n\t\t\t\"discordapp.com link\",\n\t\t\t\"https://discordapp.com/channels/111/222/333\",\n\t\t\t\"111\", \"222\", \"333\", true,\n\t\t},\n\t\t{\n\t\t\t\"real world ids\",\n\t\t\t\"check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please\",\n\t\t\t\"9000000000000001\", \"9000000000000002\", \"9000000000000003\", true,\n\t\t},\n\t\t{\"no match http\", \"http://discord.com/channels/1/2/3\", \"\", \"\", \"\", false},\n\t\t{\"no match missing segment\", \"https://discord.com/channels/1/2\", \"\", \"\", \"\", false},\n\t\t{\"no match plain text\", \"hello world\", \"\", \"\", \"\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmatches := msgLinkRe.FindStringSubmatch(tt.input)\n\t\t\tif tt.wantOK {\n\t\t\t\tif len(matches) < 4 {\n\t\t\t\t\tt.Fatalf(\"msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s\",\n\t\t\t\t\t\ttt.input, tt.wantGuild, tt.wantChan, tt.wantMsg)\n\t\t\t\t}\n\t\t\t\tif matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg {\n\t\t\t\t\tt.Errorf(\"msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s\",\n\t\t\t\t\t\ttt.input, matches[1], matches[2], matches[3],\n\t\t\t\t\t\ttt.wantGuild, tt.wantChan, tt.wantMsg)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif len(matches) >= 4 {\n\t\t\t\t\tt.Errorf(\"msgLinkRe(%q) should not match, got %v\", tt.input, matches)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMsgLinkRegex_MultipleMatches(t *testing.T) {\n\tinput := \"see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12\"\n\tmatches := msgLinkRe.FindAllStringSubmatch(input, 3)\n\tif len(matches) != 3 {\n\t\tt.Fatalf(\"expected 3 matches (capped), got %d\", len(matches))\n\t}\n\t// Verify the 3rd match is 7/8/9 (not 10/11/12)\n\tif matches[2][1] != \"7\" || matches[2][2] != \"8\" || matches[2][3] != \"9\" {\n\t\tt.Errorf(\"3rd match = %v, want guild=7 chan=8 msg=9\", matches[2])\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/discord/discord_test.go",
    "content": "package discord\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/bwmarrin/discordgo\"\n)\n\nfunc TestApplyDiscordProxy_CustomProxy(t *testing.T) {\n\tsession, err := discordgo.New(\"Bot test-token\")\n\tif err != nil {\n\t\tt.Fatalf(\"discordgo.New() error: %v\", err)\n\t}\n\n\tif err = applyDiscordProxy(session, \"http://127.0.0.1:7890\"); err != nil {\n\t\tt.Fatalf(\"applyDiscordProxy() error: %v\", err)\n\t}\n\n\treq, err := http.NewRequest(\"GET\", \"https://discord.com/api/v10/gateway\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"http.NewRequest() error: %v\", err)\n\t}\n\n\trestProxy := session.Client.Transport.(*http.Transport).Proxy\n\trestProxyURL, err := restProxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"rest proxy func error: %v\", err)\n\t}\n\tif got, want := restProxyURL.String(), \"http://127.0.0.1:7890\"; got != want {\n\t\tt.Fatalf(\"REST proxy = %q, want %q\", got, want)\n\t}\n\n\twsProxyURL, err := session.Dialer.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"ws proxy func error: %v\", err)\n\t}\n\tif got, want := wsProxyURL.String(), \"http://127.0.0.1:7890\"; got != want {\n\t\tt.Fatalf(\"WS proxy = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestApplyDiscordProxy_FromEnvironment(t *testing.T) {\n\tt.Setenv(\"HTTP_PROXY\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"http_proxy\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"HTTPS_PROXY\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"https_proxy\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"ALL_PROXY\", \"\")\n\tt.Setenv(\"all_proxy\", \"\")\n\tt.Setenv(\"NO_PROXY\", \"\")\n\tt.Setenv(\"no_proxy\", \"\")\n\n\tsession, err := discordgo.New(\"Bot test-token\")\n\tif err != nil {\n\t\tt.Fatalf(\"discordgo.New() error: %v\", err)\n\t}\n\n\tif err = applyDiscordProxy(session, \"\"); err != nil {\n\t\tt.Fatalf(\"applyDiscordProxy() error: %v\", err)\n\t}\n\n\treq, err := http.NewRequest(\"GET\", \"https://discord.com/api/v10/gateway\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"http.NewRequest() error: %v\", err)\n\t}\n\n\tgotURL, err := session.Dialer.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"ws proxy func error: %v\", err)\n\t}\n\n\twantURL, err := url.Parse(\"http://127.0.0.1:8888\")\n\tif err != nil {\n\t\tt.Fatalf(\"url.Parse() error: %v\", err)\n\t}\n\tif gotURL.String() != wantURL.String() {\n\t\tt.Fatalf(\"WS proxy = %q, want %q\", gotURL.String(), wantURL.String())\n\t}\n}\n\nfunc TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) {\n\tsession, err := discordgo.New(\"Bot test-token\")\n\tif err != nil {\n\t\tt.Fatalf(\"discordgo.New() error: %v\", err)\n\t}\n\n\tif err = applyDiscordProxy(session, \"://bad-proxy\"); err == nil {\n\t\tt.Fatal(\"applyDiscordProxy() expected error for invalid proxy URL, got nil\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/discord/init.go",
    "content": "package discord\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"discord\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewDiscordChannel(cfg.Channels.Discord, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/errors.go",
    "content": "package channels\n\nimport \"errors\"\n\nvar (\n\t// ErrNotRunning indicates the channel is not running.\n\t// Manager will not retry.\n\tErrNotRunning = errors.New(\"channel not running\")\n\n\t// ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429).\n\t// Manager will wait a fixed delay and retry.\n\tErrRateLimit = errors.New(\"rate limited\")\n\n\t// ErrTemporary indicates a transient failure (e.g. network timeout, 5xx).\n\t// Manager will use exponential backoff and retry.\n\tErrTemporary = errors.New(\"temporary failure\")\n\n\t// ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429).\n\t// Manager will not retry.\n\tErrSendFailed = errors.New(\"send failed\")\n)\n"
  },
  {
    "path": "pkg/channels/errors_test.go",
    "content": "package channels\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestErrorsIs(t *testing.T) {\n\twrapped := fmt.Errorf(\"telegram API: %w\", ErrRateLimit)\n\tif !errors.Is(wrapped, ErrRateLimit) {\n\t\tt.Error(\"wrapped ErrRateLimit should match\")\n\t}\n\tif errors.Is(wrapped, ErrTemporary) {\n\t\tt.Error(\"wrapped ErrRateLimit should not match ErrTemporary\")\n\t}\n}\n\nfunc TestErrorsIsAllTypes(t *testing.T) {\n\tsentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed}\n\n\tfor _, sentinel := range sentinels {\n\t\twrapped := fmt.Errorf(\"context: %w\", sentinel)\n\t\tif !errors.Is(wrapped, sentinel) {\n\t\t\tt.Errorf(\"wrapped %v should match itself\", sentinel)\n\t\t}\n\n\t\t// Verify it doesn't match other sentinel errors\n\t\tfor _, other := range sentinels {\n\t\t\tif other == sentinel {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif errors.Is(wrapped, other) {\n\t\t\t\tt.Errorf(\"wrapped %v should not match %v\", sentinel, other)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestErrorMessages(t *testing.T) {\n\ttests := []struct {\n\t\terr  error\n\t\twant string\n\t}{\n\t\t{ErrNotRunning, \"channel not running\"},\n\t\t{ErrRateLimit, \"rate limited\"},\n\t\t{ErrTemporary, \"temporary failure\"},\n\t\t{ErrSendFailed, \"send failed\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tif got := tt.err.Error(); got != tt.want {\n\t\t\tt.Errorf(\"error message = %q, want %q\", got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/errutil.go",
    "content": "package channels\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n)\n\n// ClassifySendError wraps a raw error with the appropriate sentinel based on\n// an HTTP status code. Channels that perform HTTP API calls should use this\n// in their Send path.\nfunc ClassifySendError(statusCode int, rawErr error) error {\n\tswitch {\n\tcase statusCode == http.StatusTooManyRequests:\n\t\treturn fmt.Errorf(\"%w: %v\", ErrRateLimit, rawErr)\n\tcase statusCode >= 500:\n\t\treturn fmt.Errorf(\"%w: %v\", ErrTemporary, rawErr)\n\tcase statusCode >= 400:\n\t\treturn fmt.Errorf(\"%w: %v\", ErrSendFailed, rawErr)\n\tdefault:\n\t\treturn rawErr\n\t}\n}\n\n// ClassifyNetError wraps a network/timeout error as ErrTemporary.\nfunc ClassifyNetError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"%w: %v\", ErrTemporary, err)\n}\n"
  },
  {
    "path": "pkg/channels/errutil_test.go",
    "content": "package channels\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestClassifySendError(t *testing.T) {\n\traw := fmt.Errorf(\"some API error\")\n\n\ttests := []struct {\n\t\tname       string\n\t\tstatusCode int\n\t\twantIs     error\n\t\twantNil    bool\n\t}{\n\t\t{\"429 -> ErrRateLimit\", 429, ErrRateLimit, false},\n\t\t{\"500 -> ErrTemporary\", 500, ErrTemporary, false},\n\t\t{\"502 -> ErrTemporary\", 502, ErrTemporary, false},\n\t\t{\"503 -> ErrTemporary\", 503, ErrTemporary, false},\n\t\t{\"400 -> ErrSendFailed\", 400, ErrSendFailed, false},\n\t\t{\"403 -> ErrSendFailed\", 403, ErrSendFailed, false},\n\t\t{\"404 -> ErrSendFailed\", 404, ErrSendFailed, false},\n\t\t{\"200 -> raw error\", 200, nil, false},\n\t\t{\"201 -> raw error\", 201, nil, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := ClassifySendError(tt.statusCode, raw)\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"expected non-nil error\")\n\t\t\t}\n\t\t\tif tt.wantIs != nil {\n\t\t\t\tif !errors.Is(err, tt.wantIs) {\n\t\t\t\t\tt.Errorf(\"errors.Is(err, %v) = false, want true; err = %v\", tt.wantIs, err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Should return the raw error unchanged\n\t\t\t\tif err != raw {\n\t\t\t\t\tt.Errorf(\"expected raw error to be returned unchanged for status %d, got %v\", tt.statusCode, err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClassifySendErrorNoFalsePositive(t *testing.T) {\n\traw := fmt.Errorf(\"some error\")\n\n\t// 429 should NOT match ErrTemporary or ErrSendFailed\n\terr := ClassifySendError(429, raw)\n\tif errors.Is(err, ErrTemporary) {\n\t\tt.Error(\"429 should not match ErrTemporary\")\n\t}\n\tif errors.Is(err, ErrSendFailed) {\n\t\tt.Error(\"429 should not match ErrSendFailed\")\n\t}\n\n\t// 500 should NOT match ErrRateLimit or ErrSendFailed\n\terr = ClassifySendError(500, raw)\n\tif errors.Is(err, ErrRateLimit) {\n\t\tt.Error(\"500 should not match ErrRateLimit\")\n\t}\n\tif errors.Is(err, ErrSendFailed) {\n\t\tt.Error(\"500 should not match ErrSendFailed\")\n\t}\n\n\t// 400 should NOT match ErrRateLimit or ErrTemporary\n\terr = ClassifySendError(400, raw)\n\tif errors.Is(err, ErrRateLimit) {\n\t\tt.Error(\"400 should not match ErrRateLimit\")\n\t}\n\tif errors.Is(err, ErrTemporary) {\n\t\tt.Error(\"400 should not match ErrTemporary\")\n\t}\n}\n\nfunc TestClassifyNetError(t *testing.T) {\n\tt.Run(\"nil error returns nil\", func(t *testing.T) {\n\t\tif err := ClassifyNetError(nil); err != nil {\n\t\t\tt.Errorf(\"expected nil, got %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"non-nil error wraps as ErrTemporary\", func(t *testing.T) {\n\t\traw := fmt.Errorf(\"connection refused\")\n\t\terr := ClassifyNetError(raw)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected non-nil error\")\n\t\t}\n\t\tif !errors.Is(err, ErrTemporary) {\n\t\t\tt.Errorf(\"errors.Is(err, ErrTemporary) = false, want true; err = %v\", err)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/feishu/common.go",
    "content": "package feishu\n\nimport (\n\t\"encoding/json\"\n\t\"regexp\"\n\t\"strings\"\n\n\tlarkim \"github.com/larksuite/oapi-sdk-go/v3/service/im/v1\"\n)\n\n// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.\nvar mentionPlaceholderRegex = regexp.MustCompile(`@_user_\\d+`)\n\n// stringValue safely dereferences a *string pointer.\nfunc stringValue(v *string) string {\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\treturn *v\n}\n\n// buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content.\n// JSON 2.0 cards support full CommonMark standard markdown syntax.\nfunc buildMarkdownCard(content string) (string, error) {\n\tcard := map[string]any{\n\t\t\"schema\": \"2.0\",\n\t\t\"body\": map[string]any{\n\t\t\t\"elements\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"tag\":     \"markdown\",\n\t\t\t\t\t\"content\": content,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tdata, err := json.Marshal(card)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(data), nil\n}\n\n// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.\n// Returns \"\" if the content is invalid JSON or the field is missing/empty.\nfunc extractJSONStringField(content, field string) string {\n\tvar m map[string]json.RawMessage\n\tif err := json.Unmarshal([]byte(content), &m); err != nil {\n\t\treturn \"\"\n\t}\n\traw, ok := m[field]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err != nil {\n\t\treturn \"\"\n\t}\n\treturn s\n}\n\n// extractImageKey extracts the image_key from a Feishu image message content JSON.\n// Format: {\"image_key\": \"img_xxx\"}\nfunc extractImageKey(content string) string { return extractJSONStringField(content, \"image_key\") }\n\n// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.\n// Format: {\"file_key\": \"file_xxx\", \"file_name\": \"...\", ...}\nfunc extractFileKey(content string) string { return extractJSONStringField(content, \"file_key\") }\n\n// extractFileName extracts the file_name from a Feishu file message content JSON.\nfunc extractFileName(content string) string { return extractJSONStringField(content, \"file_name\") }\n\n// stripMentionPlaceholders removes @_user_N placeholders from the text content.\n// These are inserted by Feishu when users @mention someone in a message.\nfunc stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {\n\tif len(mentions) == 0 {\n\t\treturn content\n\t}\n\tfor _, m := range mentions {\n\t\tif m.Key != nil && *m.Key != \"\" {\n\t\t\tcontent = strings.ReplaceAll(content, *m.Key, \"\")\n\t\t}\n\t}\n\t// Also clean up any remaining @_user_N patterns\n\tcontent = mentionPlaceholderRegex.ReplaceAllString(content, \"\")\n\treturn strings.TrimSpace(content)\n}\n\n// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card.\n// Image keys are used to download images from Feishu API.\n// Returns two slices: Feishu-hosted keys and external URLs.\nfunc extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) {\n\tif rawContent == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tvar card map[string]any\n\tif err := json.Unmarshal([]byte(rawContent), &card); err != nil {\n\t\treturn nil, nil\n\t}\n\n\textractImageKeysRecursive(card, &feishuKeys, &externalURLs)\n\treturn feishuKeys, externalURLs\n}\n\n// isExternalURL returns true if the string is an external HTTP/HTTPS URL.\nfunc isExternalURL(s string) bool {\n\treturn strings.HasPrefix(s, \"http://\") || strings.HasPrefix(s, \"https://\")\n}\n\n// extractImageKeysRecursive traverses card structure to find all image keys.\n// Collects both Feishu-hosted keys and external URLs separately.\nfunc extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) {\n\tswitch val := v.(type) {\n\tcase map[string]any:\n\t\t// Check if this is an img element\n\t\tif tag, ok := val[\"tag\"].(string); ok {\n\t\t\tswitch tag {\n\t\t\tcase \"img\":\n\t\t\t\t// Try img_key first (always Feishu-hosted)\n\t\t\t\tif imgKey, ok := val[\"img_key\"].(string); ok && imgKey != \"\" {\n\t\t\t\t\t*feishuKeys = append(*feishuKeys, imgKey)\n\t\t\t\t}\n\t\t\t\t// Check src - could be Feishu key or external URL\n\t\t\t\tif src, ok := val[\"src\"].(string); ok && src != \"\" {\n\t\t\t\t\tif isExternalURL(src) {\n\t\t\t\t\t\t*externalURLs = append(*externalURLs, src)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t*feishuKeys = append(*feishuKeys, src)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase \"icon\":\n\t\t\t\t// Icon elements use icon_key\n\t\t\t\tif iconKey, ok := val[\"icon_key\"].(string); ok && iconKey != \"\" {\n\t\t\t\t\t*feishuKeys = append(*feishuKeys, iconKey)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Recurse into all nested structures\n\t\tfor _, child := range val {\n\t\t\textractImageKeysRecursive(child, feishuKeys, externalURLs)\n\t\t}\n\tcase []any:\n\t\tfor _, item := range val {\n\t\t\textractImageKeysRecursive(item, feishuKeys, externalURLs)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/feishu/common_test.go",
    "content": "package feishu\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\tlarkim \"github.com/larksuite/oapi-sdk-go/v3/service/im/v1\"\n)\n\nfunc TestExtractJSONStringField(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\tfield   string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname:    \"valid field\",\n\t\t\tcontent: `{\"image_key\": \"img_v2_xxx\"}`,\n\t\t\tfield:   \"image_key\",\n\t\t\twant:    \"img_v2_xxx\",\n\t\t},\n\t\t{\n\t\t\tname:    \"missing field\",\n\t\t\tcontent: `{\"image_key\": \"img_v2_xxx\"}`,\n\t\t\tfield:   \"file_key\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid JSON\",\n\t\t\tcontent: `not json at all`,\n\t\t\tfield:   \"image_key\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"empty content\",\n\t\t\tcontent: \"\",\n\t\t\tfield:   \"image_key\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"non-string field value\",\n\t\t\tcontent: `{\"count\": 42}`,\n\t\t\tfield:   \"count\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"empty string value\",\n\t\t\tcontent: `{\"image_key\": \"\"}`,\n\t\t\tfield:   \"image_key\",\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"multiple fields\",\n\t\t\tcontent: `{\"file_key\": \"file_xxx\", \"file_name\": \"test.pdf\"}`,\n\t\t\tfield:   \"file_name\",\n\t\t\twant:    \"test.pdf\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractJSONStringField(tt.content, tt.field)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractJSONStringField(%q, %q) = %q, want %q\", tt.content, tt.field, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractImageKey(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname:    \"normal\",\n\t\t\tcontent: `{\"image_key\": \"img_v2_abc123\"}`,\n\t\t\twant:    \"img_v2_abc123\",\n\t\t},\n\t\t{\n\t\t\tname:    \"missing key\",\n\t\t\tcontent: `{\"file_key\": \"file_xxx\"}`,\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"malformed JSON\",\n\t\t\tcontent: `{broken`,\n\t\t\twant:    \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractImageKey(tt.content)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractImageKey(%q) = %q, want %q\", tt.content, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractFileKey(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname:    \"normal\",\n\t\t\tcontent: `{\"file_key\": \"file_v2_abc123\", \"file_name\": \"test.doc\"}`,\n\t\t\twant:    \"file_v2_abc123\",\n\t\t},\n\t\t{\n\t\t\tname:    \"missing key\",\n\t\t\tcontent: `{\"image_key\": \"img_xxx\"}`,\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"malformed JSON\",\n\t\t\tcontent: `not json`,\n\t\t\twant:    \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractFileKey(tt.content)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractFileKey(%q) = %q, want %q\", tt.content, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractFileName(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname:    \"normal\",\n\t\t\tcontent: `{\"file_key\": \"file_xxx\", \"file_name\": \"report.pdf\"}`,\n\t\t\twant:    \"report.pdf\",\n\t\t},\n\t\t{\n\t\t\tname:    \"missing name\",\n\t\t\tcontent: `{\"file_key\": \"file_xxx\"}`,\n\t\t\twant:    \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"malformed JSON\",\n\t\t\tcontent: `{bad`,\n\t\t\twant:    \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractFileName(tt.content)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractFileName(%q) = %q, want %q\", tt.content, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildMarkdownCard(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t}{\n\t\t{\n\t\t\tname:    \"normal content\",\n\t\t\tcontent: \"Hello **world**\",\n\t\t},\n\t\t{\n\t\t\tname:    \"empty content\",\n\t\t\tcontent: \"\",\n\t\t},\n\t\t{\n\t\t\tname:    \"special characters\",\n\t\t\tcontent: `Code: \"foo\" & <bar> 'baz'`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := buildMarkdownCard(tt.content)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"buildMarkdownCard(%q) unexpected error: %v\", tt.content, err)\n\t\t\t}\n\n\t\t\t// Verify valid JSON\n\t\t\tvar parsed map[string]any\n\t\t\tif err := json.Unmarshal([]byte(result), &parsed); err != nil {\n\t\t\t\tt.Fatalf(\"buildMarkdownCard(%q) produced invalid JSON: %v\", tt.content, err)\n\t\t\t}\n\n\t\t\t// Verify schema\n\t\t\tif parsed[\"schema\"] != \"2.0\" {\n\t\t\t\tt.Errorf(\"schema = %v, want %q\", parsed[\"schema\"], \"2.0\")\n\t\t\t}\n\n\t\t\t// Verify body.elements[0].content == input\n\t\t\tbody, ok := parsed[\"body\"].(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"missing body in card JSON\")\n\t\t\t}\n\t\t\telements, ok := body[\"elements\"].([]any)\n\t\t\tif !ok || len(elements) == 0 {\n\t\t\t\tt.Fatal(\"missing or empty elements in card JSON\")\n\t\t\t}\n\t\t\telem, ok := elements[0].(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"first element is not an object\")\n\t\t\t}\n\t\t\tif elem[\"tag\"] != \"markdown\" {\n\t\t\t\tt.Errorf(\"tag = %v, want %q\", elem[\"tag\"], \"markdown\")\n\t\t\t}\n\t\t\tif elem[\"content\"] != tt.content {\n\t\t\t\tt.Errorf(\"content = %v, want %q\", elem[\"content\"], tt.content)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStripMentionPlaceholders(t *testing.T) {\n\tstrPtr := func(s string) *string { return &s }\n\n\ttests := []struct {\n\t\tname     string\n\t\tcontent  string\n\t\tmentions []*larkim.MentionEvent\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname:     \"no mentions\",\n\t\t\tcontent:  \"Hello world\",\n\t\t\tmentions: nil,\n\t\t\twant:     \"Hello world\",\n\t\t},\n\t\t{\n\t\t\tname:    \"single mention\",\n\t\t\tcontent: \"@_user_1 hello\",\n\t\t\tmentions: []*larkim.MentionEvent{\n\t\t\t\t{Key: strPtr(\"@_user_1\")},\n\t\t\t},\n\t\t\twant: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:    \"multiple mentions\",\n\t\t\tcontent: \"@_user_1 @_user_2 hey\",\n\t\t\tmentions: []*larkim.MentionEvent{\n\t\t\t\t{Key: strPtr(\"@_user_1\")},\n\t\t\t\t{Key: strPtr(\"@_user_2\")},\n\t\t\t},\n\t\t\twant: \"hey\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty content\",\n\t\t\tcontent:  \"\",\n\t\t\tmentions: []*larkim.MentionEvent{{Key: strPtr(\"@_user_1\")}},\n\t\t\twant:     \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty mentions slice\",\n\t\t\tcontent:  \"@_user_1 test\",\n\t\t\tmentions: []*larkim.MentionEvent{},\n\t\t\twant:     \"@_user_1 test\",\n\t\t},\n\t\t{\n\t\t\tname:    \"mention with nil key\",\n\t\t\tcontent: \"@_user_1 test\",\n\t\t\tmentions: []*larkim.MentionEvent{\n\t\t\t\t{Key: nil},\n\t\t\t},\n\t\t\twant: \"test\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := stripMentionPlaceholders(tt.content, tt.mentions)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"stripMentionPlaceholders(%q, ...) = %q, want %q\", tt.content, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractCardImageKeys(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tcontent          string\n\t\twantFeishuKeys   []string\n\t\twantExternalURLs []string\n\t}{\n\t\t{\n\t\t\tname:             \"empty content\",\n\t\t\tcontent:          \"\",\n\t\t\twantFeishuKeys:   nil,\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid JSON\",\n\t\t\tcontent:          \"not json\",\n\t\t\twantFeishuKeys:   nil,\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"card with no images\",\n\t\t\tcontent:          `{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"text\"}]}}`,\n\t\t\twantFeishuKeys:   nil,\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"single image with img_key\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"img_key\":\"img_abc123\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_abc123\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"single image with src as Feishu key\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"src\":\"img_xyz789\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_xyz789\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"multiple images\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"img_key\":\"img_1\"},{\"tag\":\"div\",\"text\":{\"content\":\"text\"}},{\"tag\":\"img\",\"img_key\":\"img_2\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_1\", \"img_2\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"nested image in columns\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"div\",\"columns\":[{\"tag\":\"img\",\"img_key\":\"img_col1\"},{\"tag\":\"img\",\"img_key\":\"img_col2\"}]}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_col1\", \"img_col2\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"image in action\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"action\",\"actions\":[{\"tag\":\"img\",\"img_key\":\"img_action\"}]}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_action\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"icon element\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"icon\",\"icon_key\":\"icon_123\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"icon_123\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"complex card with text and images\",\n\t\t\tcontent:          `{\"header\":{\"title\":{\"content\":\"Title\"}},\"elements\":[{\"tag\":\"div\",\"text\":{\"content\":\"Description\"}},{\"tag\":\"img\",\"img_key\":\"img_main\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_main\"},\n\t\t\twantExternalURLs: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"external URL in src\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"src\":\"https://example.com/image.png\"}]}`,\n\t\t\twantFeishuKeys:   nil,\n\t\t\twantExternalURLs: []string{\"https://example.com/image.png\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"mixed Feishu keys and external URLs\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"img_key\":\"img_feishu\"},{\"tag\":\"img\",\"src\":\"https://cdn.example.com/external.jpg\"},{\"tag\":\"img\",\"src\":\"img_another\"}]}`,\n\t\t\twantFeishuKeys:   []string{\"img_feishu\", \"img_another\"},\n\t\t\twantExternalURLs: []string{\"https://cdn.example.com/external.jpg\"},\n\t\t},\n\t\t{\n\t\t\tname:             \"multiple external URLs\",\n\t\t\tcontent:          `{\"elements\":[{\"tag\":\"img\",\"src\":\"https://a.com/1.png\"},{\"tag\":\"img\",\"src\":\"http://b.com/2.jpg\"}]}`,\n\t\t\twantFeishuKeys:   nil,\n\t\t\twantExternalURLs: []string{\"https://a.com/1.png\", \"http://b.com/2.jpg\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content)\n\n\t\t\t// Compare Feishu keys\n\t\t\tif len(gotFeishuKeys) != len(tt.wantFeishuKeys) {\n\t\t\t\tt.Errorf(\"extractCardImageKeys() feishuKeys = %v, want %v\", gotFeishuKeys, tt.wantFeishuKeys)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i, v := range gotFeishuKeys {\n\t\t\t\tif v != tt.wantFeishuKeys[i] {\n\t\t\t\t\tt.Errorf(\"extractCardImageKeys() feishuKeys[%d] = %q, want %q\", i, v, tt.wantFeishuKeys[i])\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Compare external URLs\n\t\t\tif len(gotExternalURLs) != len(tt.wantExternalURLs) {\n\t\t\t\tt.Errorf(\"extractCardImageKeys() externalURLs = %v, want %v\", gotExternalURLs, tt.wantExternalURLs)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i, v := range gotExternalURLs {\n\t\t\t\tif v != tt.wantExternalURLs[i] {\n\t\t\t\t\tt.Errorf(\"extractCardImageKeys() externalURLs[%d] = %q, want %q\", i, v, tt.wantExternalURLs[i])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/feishu/feishu_32.go",
    "content": "//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64\n\npackage feishu\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// FeishuChannel is a stub implementation for 32-bit architectures\ntype FeishuChannel struct {\n\t*channels.BaseChannel\n}\n\nvar errUnsupported = errors.New(\"feishu channel is not supported on 32-bit architectures\")\n\n// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported\nfunc NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {\n\treturn nil, errors.New(\n\t\t\"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config\",\n\t)\n}\n\n// Start is a stub method to satisfy the Channel interface\nfunc (c *FeishuChannel) Start(ctx context.Context) error {\n\treturn errUnsupported\n}\n\n// Stop is a stub method to satisfy the Channel interface\nfunc (c *FeishuChannel) Stop(ctx context.Context) error {\n\treturn errUnsupported\n}\n\n// Send is a stub method to satisfy the Channel interface\nfunc (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\treturn errUnsupported\n}\n\n// EditMessage is a stub method to satisfy MessageEditor\nfunc (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n\treturn errUnsupported\n}\n\n// SendPlaceholder is a stub method to satisfy PlaceholderCapable\nfunc (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\treturn \"\", errUnsupported\n}\n\n// ReactToMessage is a stub method to satisfy ReactionCapable\nfunc (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {\n\treturn func() {}, errUnsupported\n}\n\n// SendMedia is a stub method to satisfy MediaSender\nfunc (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\treturn errUnsupported\n}\n"
  },
  {
    "path": "pkg/channels/feishu/feishu_64.go",
    "content": "//go:build amd64 || arm64 || riscv64 || mips64 || ppc64\n\npackage feishu\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tlark \"github.com/larksuite/oapi-sdk-go/v3\"\n\tlarkcore \"github.com/larksuite/oapi-sdk-go/v3/core\"\n\tlarkdispatcher \"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher\"\n\tlarkim \"github.com/larksuite/oapi-sdk-go/v3/service/im/v1\"\n\tlarkws \"github.com/larksuite/oapi-sdk-go/v3/ws\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked\n// tenant_access_token. The Lark SDK's built-in retry does not clear its cache\n// on this error, so we do it ourselves.\nconst errCodeTenantTokenInvalid = 99991663\n\ntype FeishuChannel struct {\n\t*channels.BaseChannel\n\tconfig     config.FeishuConfig\n\tclient     *lark.Client\n\twsClient   *larkws.Client\n\ttokenCache *tokenCache // custom cache that supports invalidation\n\n\tbotOpenID atomic.Value // stores string; populated lazily for @mention detection\n\n\tmu     sync.Mutex\n\tcancel context.CancelFunc\n}\n\nfunc NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {\n\tbase := channels.NewBaseChannel(\"feishu\", cfg, bus, cfg.AllowFrom,\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\ttc := newTokenCache()\n\topts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)}\n\tif cfg.IsLark {\n\t\topts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl))\n\t}\n\tch := &FeishuChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\ttokenCache:  tc,\n\t\tclient:      lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),\n\t}\n\tch.SetOwner(ch)\n\treturn ch, nil\n}\n\nfunc (c *FeishuChannel) Start(ctx context.Context) error {\n\tif c.config.AppID == \"\" || c.config.AppSecret == \"\" {\n\t\treturn fmt.Errorf(\"feishu app_id or app_secret is empty\")\n\t}\n\n\t// Fetch bot open_id via API for reliable @mention detection.\n\tif err := c.fetchBotOpenID(ctx); err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to fetch bot open_id, @mention detection may not work\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t}\n\n\tdispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).\n\t\tOnP2MessageReceiveV1(c.handleMessageReceive)\n\n\trunCtx, cancel := context.WithCancel(ctx)\n\n\tc.mu.Lock()\n\tc.cancel = cancel\n\tdomain := lark.FeishuBaseUrl\n\tif c.config.IsLark {\n\t\tdomain = lark.LarkBaseUrl\n\t}\n\tc.wsClient = larkws.NewClient(\n\t\tc.config.AppID,\n\t\tc.config.AppSecret,\n\t\tlarkws.WithEventHandler(dispatcher),\n\t\tlarkws.WithDomain(domain),\n\t)\n\twsClient := c.wsClient\n\tc.mu.Unlock()\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"feishu\", \"Feishu channel started (websocket mode)\")\n\n\tgo func() {\n\t\tif err := wsClient.Start(runCtx); err != nil {\n\t\t\tlogger.ErrorCF(\"feishu\", \"Feishu websocket stopped with error\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *FeishuChannel) Stop(ctx context.Context) error {\n\tc.mu.Lock()\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t\tc.cancel = nil\n\t}\n\tc.wsClient = nil\n\tc.mu.Unlock()\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"feishu\", \"Feishu channel stopped\")\n\treturn nil\n}\n\n// Send sends a message using Interactive Card format for markdown rendering.\n// Falls back to plain text message if card sending fails (e.g., table limit exceeded).\nfunc (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tif msg.ChatID == \"\" {\n\t\treturn fmt.Errorf(\"chat ID is empty: %w\", channels.ErrSendFailed)\n\t}\n\n\t// Build interactive card with markdown content\n\tcardContent, err := buildMarkdownCard(msg.Content)\n\tif err != nil {\n\t\t// If card build fails, fall back to plain text\n\t\treturn c.sendText(ctx, msg.ChatID, msg.Content)\n\t}\n\n\t// First attempt: try sending as interactive card\n\terr = c.sendCard(ctx, msg.ChatID, cardContent)\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\t// Check if error is due to card table limit (error code 11310)\n\t// See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json\n\terrMsg := err.Error()\n\tisCardLimitError := strings.Contains(errMsg, \"11310\")\n\n\tif isCardLimitError {\n\t\tlogger.WarnCF(\"feishu\", \"Card send failed (table limit), falling back to text message\", map[string]any{\n\t\t\t\"chat_id\": msg.ChatID,\n\t\t\t\"error\":   errMsg,\n\t\t})\n\n\t\t// Second attempt: fall back to plain text message\n\t\ttextErr := c.sendText(ctx, msg.ChatID, msg.Content)\n\t\tif textErr == nil {\n\t\t\treturn nil\n\t\t}\n\t\t// If text also fails, return the text error\n\t\treturn textErr\n\t}\n\n\t// For other errors, return the original card error\n\treturn err\n}\n\n// EditMessage implements channels.MessageEditor.\n// Uses Message.Patch to update an interactive card message.\nfunc (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n\tcardContent, err := buildMarkdownCard(content)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu edit: card build failed: %w\", err)\n\t}\n\n\treq := larkim.NewPatchMessageReqBuilder().\n\t\tMessageId(messageID).\n\t\tBody(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Patch(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu edit: %w\", err)\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\treturn fmt.Errorf(\"feishu edit api error (code=%d msg=%s)\", resp.Code, resp.Msg)\n\t}\n\treturn nil\n}\n\n// SendPlaceholder implements channels.PlaceholderCapable.\n// Sends an interactive card with placeholder text and returns its message ID.\nfunc (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tif !c.config.Placeholder.Enabled {\n\t\tlogger.DebugCF(\"feishu\", \"Placeholder disabled, skipping\", map[string]any{\n\t\t\t\"chat_id\": chatID,\n\t\t})\n\t\treturn \"\", nil\n\t}\n\n\ttext := c.config.Placeholder.Text\n\tif text == \"\" {\n\t\ttext = \"Thinking...\"\n\t}\n\n\tcardContent, err := buildMarkdownCard(text)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"feishu placeholder: card build failed: %w\", err)\n\t}\n\n\treq := larkim.NewCreateMessageReqBuilder().\n\t\tReceiveIdType(larkim.ReceiveIdTypeChatId).\n\t\tBody(larkim.NewCreateMessageReqBodyBuilder().\n\t\t\tReceiveId(chatID).\n\t\t\tMsgType(larkim.MsgTypeInteractive).\n\t\t\tContent(cardContent).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Create(ctx, req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"feishu placeholder send: %w\", err)\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\treturn \"\", fmt.Errorf(\"feishu placeholder api error (code=%d msg=%s)\", resp.Code, resp.Msg)\n\t}\n\n\tif resp.Data != nil && resp.Data.MessageId != nil {\n\t\treturn *resp.Data.MessageId, nil\n\t}\n\treturn \"\", nil\n}\n\n// ReactToMessage implements channels.ReactionCapable.\n// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.\nfunc (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {\n\t// Get emoji list from config\n\temojiList := c.config.RandomReactionEmoji\n\tvar chosenEmoji string\n\tif len(emojiList) == 0 {\n\t\t// Default to \"Pin\" if no config\n\t\tchosenEmoji = \"Pin\"\n\t} else {\n\t\tidx := rand.Intn(len(emojiList))\n\t\tchosenEmoji = emojiList[idx]\n\t}\n\n\treq := larkim.NewCreateMessageReactionReqBuilder().\n\t\tMessageId(messageID).\n\t\tBody(larkim.NewCreateMessageReactionReqBodyBuilder().\n\t\t\tReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to add reaction\", map[string]any{\n\t\t\t\"emoji\":      chosenEmoji,\n\t\t\t\"message_id\": messageID,\n\t\t\t\"error\":      err.Error(),\n\t\t})\n\t\treturn func() {}, fmt.Errorf(\"feishu react: %w\", err)\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\tlogger.ErrorCF(\"feishu\", \"Reaction API error\", map[string]any{\n\t\t\t\"emoji\":      chosenEmoji,\n\t\t\t\"message_id\": messageID,\n\t\t\t\"code\":       resp.Code,\n\t\t\t\"msg\":        resp.Msg,\n\t\t})\n\t\treturn func() {}, fmt.Errorf(\"feishu react api error (code=%d msg=%s)\", resp.Code, resp.Msg)\n\t}\n\n\tvar reactionID string\n\tif resp.Data != nil && resp.Data.ReactionId != nil {\n\t\treactionID = *resp.Data.ReactionId\n\t}\n\tif reactionID == \"\" {\n\t\treturn func() {}, nil\n\t}\n\n\tvar undone atomic.Bool\n\tundo := func() {\n\t\tif !undone.CompareAndSwap(false, true) {\n\t\t\treturn\n\t\t}\n\t\tdelReq := larkim.NewDeleteMessageReactionReqBuilder().\n\t\t\tMessageId(messageID).\n\t\t\tReactionId(reactionID).\n\t\t\tBuild()\n\t\t_, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq)\n\t}\n\treturn undo, nil\n}\n\n// SendMedia implements channels.MediaSender.\n// Uploads images/files via Feishu API then sends as messages.\nfunc (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tif msg.ChatID == \"\" {\n\t\treturn fmt.Errorf(\"chat ID is empty: %w\", channels.ErrSendFailed)\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tfor _, part := range msg.Parts {\n\t\tif err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// sendMediaPart resolves and sends a single media part.\nfunc (c *FeishuChannel) sendMediaPart(\n\tctx context.Context,\n\tchatID string,\n\tpart bus.MediaPart,\n\tstore media.MediaStore,\n) error {\n\tlocalPath, err := store.Resolve(part.Ref)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\"ref\":   part.Ref,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn nil // skip this part\n\t}\n\n\tfile, err := os.Open(localPath)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to open media file\", map[string]any{\n\t\t\t\"path\":  localPath,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn nil // skip this part\n\t}\n\tdefer file.Close()\n\n\tswitch part.Type {\n\tcase \"image\":\n\t\terr = c.sendImage(ctx, chatID, file)\n\tdefault:\n\t\tfilename := part.Filename\n\t\tif filename == \"\" {\n\t\t\tfilename = \"file\"\n\t\t}\n\t\terr = c.sendFile(ctx, chatID, file, filename, part.Type)\n\t}\n\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to send media\", map[string]any{\n\t\t\t\"type\":  part.Type,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn fmt.Errorf(\"feishu send media: %w\", channels.ErrTemporary)\n\t}\n\treturn nil\n}\n\n// --- Inbound message handling ---\n\nfunc (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {\n\tif event == nil || event.Event == nil || event.Event.Message == nil {\n\t\treturn nil\n\t}\n\n\tmessage := event.Event.Message\n\tsender := event.Event.Sender\n\n\tchatID := stringValue(message.ChatId)\n\tif chatID == \"\" {\n\t\treturn nil\n\t}\n\n\tsenderID := extractFeishuSenderID(sender)\n\tif senderID == \"\" {\n\t\tsenderID = \"unknown\"\n\t}\n\n\tmessageType := stringValue(message.MessageType)\n\tmessageID := stringValue(message.MessageId)\n\trawContent := stringValue(message.Content)\n\n\t// Check allowlist early to avoid downloading media for rejected senders.\n\t// BaseChannel.HandleMessage will check again, but this avoids wasted network I/O.\n\tsenderInfo := bus.SenderInfo{\n\t\tPlatform:    \"feishu\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"feishu\", senderID),\n\t}\n\tif !c.IsAllowedSender(senderInfo) {\n\t\treturn nil\n\t}\n\n\t// Extract content based on message type\n\tcontent := extractContent(messageType, rawContent)\n\n\t// Handle media messages (download and store)\n\tvar mediaRefs []string\n\tif store := c.GetMediaStore(); store != nil && messageID != \"\" {\n\t\tmediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)\n\t}\n\n\t// For interactive cards, pass external image URLs via media refs.\n\t// Keep content as valid raw JSON for downstream parsing.\n\tif messageType == larkim.MsgTypeInteractive {\n\t\t_, externalURLs := extractCardImageKeys(rawContent)\n\t\tif len(externalURLs) > 0 {\n\t\t\tmediaRefs = append(mediaRefs, externalURLs...)\n\t\t}\n\t}\n\n\t// Append media tags to content (like Telegram does)\n\tcontent = appendMediaTags(content, messageType, mediaRefs)\n\n\tif content == \"\" {\n\t\tcontent = \"[empty message]\"\n\t}\n\n\tmetadata := map[string]string{}\n\tif messageID != \"\" {\n\t\tmetadata[\"message_id\"] = messageID\n\t}\n\tif messageType != \"\" {\n\t\tmetadata[\"message_type\"] = messageType\n\t}\n\tchatType := stringValue(message.ChatType)\n\tif chatType != \"\" {\n\t\tmetadata[\"chat_type\"] = chatType\n\t}\n\tif sender != nil && sender.TenantKey != nil {\n\t\tmetadata[\"tenant_key\"] = *sender.TenantKey\n\t}\n\n\tvar peer bus.Peer\n\tif chatType == \"p2p\" {\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: senderID}\n\t} else {\n\t\tpeer = bus.Peer{Kind: \"group\", ID: chatID}\n\n\t\t// Check if bot was mentioned\n\t\tisMentioned := c.isBotMentioned(message)\n\n\t\t// Strip mention placeholders from content before group trigger check\n\t\tif len(message.Mentions) > 0 {\n\t\t\tcontent = stripMentionPlaceholders(content, message.Mentions)\n\t\t}\n\n\t\t// In group chats, apply unified group trigger filtering\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\treturn nil\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tlogger.InfoCF(\"feishu\", \"Feishu message received\", map[string]any{\n\t\t\"sender_id\":  senderID,\n\t\t\"chat_id\":    chatID,\n\t\t\"message_id\": messageID,\n\t\t\"preview\":    utils.Truncate(content, 80),\n\t})\n\n\tc.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)\n\treturn nil\n}\n\n// --- Internal helpers ---\n\n// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.\nfunc (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {\n\tresp, err := c.client.Do(ctx, &larkcore.ApiReq{\n\t\tHttpMethod:                http.MethodGet,\n\t\tApiPath:                   \"/open-apis/bot/v3/info\",\n\t\tSupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"bot info request: %w\", err)\n\t}\n\n\tvar result struct {\n\t\tCode int `json:\"code\"`\n\t\tBot  struct {\n\t\t\tOpenID string `json:\"open_id\"`\n\t\t} `json:\"bot\"`\n\t}\n\tif err := json.Unmarshal(resp.RawBody, &result); err != nil {\n\t\treturn fmt.Errorf(\"bot info parse: %w\", err)\n\t}\n\tif result.Code != 0 {\n\t\tc.invalidateTokenOnAuthError(result.Code)\n\t\treturn fmt.Errorf(\"bot info api error (code=%d)\", result.Code)\n\t}\n\tif result.Bot.OpenID == \"\" {\n\t\treturn fmt.Errorf(\"bot info: empty open_id\")\n\t}\n\n\tc.botOpenID.Store(result.Bot.OpenID)\n\tlogger.InfoCF(\"feishu\", \"Fetched bot open_id from API\", map[string]any{\n\t\t\"open_id\": result.Bot.OpenID,\n\t})\n\treturn nil\n}\n\n// isBotMentioned checks if the bot was @mentioned in the message.\nfunc (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {\n\tif message.Mentions == nil {\n\t\treturn false\n\t}\n\n\tknownID, _ := c.botOpenID.Load().(string)\n\tif knownID == \"\" {\n\t\tlogger.DebugCF(\"feishu\", \"Bot open_id unknown, cannot detect @mention\", nil)\n\t\treturn false\n\t}\n\n\tfor _, m := range message.Mentions {\n\t\tif m.Id == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif m.Id.OpenId != nil && *m.Id.OpenId == knownID {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// extractContent extracts text content from different message types.\nfunc extractContent(messageType, rawContent string) string {\n\tif rawContent == \"\" {\n\t\treturn \"\"\n\t}\n\n\tswitch messageType {\n\tcase larkim.MsgTypeText:\n\t\tvar textPayload struct {\n\t\t\tText string `json:\"text\"`\n\t\t}\n\t\tif err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil {\n\t\t\treturn textPayload.Text\n\t\t}\n\t\treturn rawContent\n\n\tcase larkim.MsgTypePost:\n\t\t// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text\n\t\treturn rawContent\n\n\tcase larkim.MsgTypeInteractive:\n\t\t// Pass raw JSON to LLM — structured card is more informative than flattened text\n\t\treturn rawContent\n\n\tcase larkim.MsgTypeImage:\n\t\t// Image messages don't have text content\n\t\treturn \"\"\n\n\tcase larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:\n\t\t// File/audio/video messages may have a filename\n\t\tname := extractFileName(rawContent)\n\t\tif name != \"\" {\n\t\t\treturn name\n\t\t}\n\t\treturn \"\"\n\n\tdefault:\n\t\treturn rawContent\n\t}\n}\n\n// downloadInboundMedia downloads media from inbound messages and stores in MediaStore.\nfunc (c *FeishuChannel) downloadInboundMedia(\n\tctx context.Context,\n\tchatID, messageID, messageType, rawContent string,\n\tstore media.MediaStore,\n) []string {\n\tvar refs []string\n\tscope := channels.BuildMediaScope(\"feishu\", chatID, messageID)\n\n\tswitch messageType {\n\tcase larkim.MsgTypeImage:\n\t\timageKey := extractImageKey(rawContent)\n\t\tif imageKey == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\tref := c.downloadResource(ctx, messageID, imageKey, \"image\", \".jpg\", store, scope)\n\t\tif ref != \"\" {\n\t\t\trefs = append(refs, ref)\n\t\t}\n\n\tcase larkim.MsgTypeInteractive:\n\t\t// Extract and download images embedded in interactive cards\n\t\tfeishuKeys, _ := extractCardImageKeys(rawContent)\n\t\t// Download Feishu-hosted images via API\n\t\tfor _, imageKey := range feishuKeys {\n\t\t\tref := c.downloadResource(ctx, messageID, imageKey, \"image\", \".jpg\", store, scope)\n\t\t\tif ref != \"\" {\n\t\t\t\trefs = append(refs, ref)\n\t\t\t}\n\t\t}\n\t\t// External URLs are passed directly to LLM, not downloaded\n\n\tcase larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:\n\t\tfileKey := extractFileKey(rawContent)\n\t\tif fileKey == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\t// Derive a fallback extension from the message type.\n\t\tvar ext string\n\t\tswitch messageType {\n\t\tcase larkim.MsgTypeAudio:\n\t\t\text = \".ogg\"\n\t\tcase larkim.MsgTypeMedia:\n\t\t\text = \".mp4\"\n\t\tdefault:\n\t\t\text = \"\" // generic file — rely on resp.FileName\n\t\t}\n\t\tref := c.downloadResource(ctx, messageID, fileKey, \"file\", ext, store, scope)\n\t\tif ref != \"\" {\n\t\t\trefs = append(refs, ref)\n\t\t}\n\t}\n\n\treturn refs\n}\n\n// downloadResource downloads a message resource (image/file) from Feishu,\n// writes it to the project media directory, and stores the reference in MediaStore.\n// fallbackExt (e.g. \".jpg\") is appended when the resolved filename has no extension.\nfunc (c *FeishuChannel) downloadResource(\n\tctx context.Context,\n\tmessageID, fileKey, resourceType, fallbackExt string,\n\tstore media.MediaStore,\n\tscope string,\n) string {\n\treq := larkim.NewGetMessageResourceReqBuilder().\n\t\tMessageId(messageID).\n\t\tFileKey(fileKey).\n\t\tType(resourceType).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.MessageResource.Get(ctx, req)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to download resource\", map[string]any{\n\t\t\t\"message_id\": messageID,\n\t\t\t\"file_key\":   fileKey,\n\t\t\t\"error\":      err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\tlogger.ErrorCF(\"feishu\", \"Resource download api error\", map[string]any{\n\t\t\t\"code\": resp.Code,\n\t\t\t\"msg\":  resp.Msg,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tif resp.File == nil {\n\t\treturn \"\"\n\t}\n\t// Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).\n\tif closer, ok := resp.File.(io.Closer); ok {\n\t\tdefer closer.Close()\n\t}\n\n\tfilename := resp.FileName\n\tif filename == \"\" {\n\t\tfilename = fileKey\n\t}\n\t// If filename still has no extension, append the fallback (like Telegram's ext parameter).\n\tif filepath.Ext(filename) == \"\" && fallbackExt != \"\" {\n\t\tfilename += fallbackExt\n\t}\n\n\t// Write to the shared picoclaw_media directory using a unique name to avoid collisions.\n\tmediaDir := media.TempDir()\n\tif mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to create media directory\", map[string]any{\n\t\t\t\"error\": mkdirErr.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\text := filepath.Ext(filename)\n\tlocalPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+\"-\"+fileKey+ext))\n\n\tout, err := os.Create(localPath)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to create local file for resource\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tif _, copyErr := io.Copy(out, resp.File); copyErr != nil {\n\t\tout.Close()\n\t\tos.Remove(localPath)\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to write resource to file\", map[string]any{\n\t\t\t\"error\": copyErr.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\tout.Close()\n\n\tref, err := store.Store(localPath, media.MediaMeta{\n\t\tFilename: filename,\n\t\tSource:   \"feishu\",\n\t}, scope)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"feishu\", \"Failed to store downloaded resource\", map[string]any{\n\t\t\t\"file_key\": fileKey,\n\t\t\t\"error\":    err.Error(),\n\t\t})\n\t\tos.Remove(localPath)\n\t\treturn \"\"\n\t}\n\n\treturn ref\n}\n\n// appendMediaTags appends media type tags to content (like Telegram's \"[image: photo]\").\n// For interactive cards, media tags are not appended because content is raw JSON\n// and appending would produce invalid JSON format.\nfunc appendMediaTags(content, messageType string, mediaRefs []string) string {\n\tif len(mediaRefs) == 0 {\n\t\treturn content\n\t}\n\n\t// Don't append tags to JSON content (interactive cards) - would produce invalid JSON\n\tif messageType == larkim.MsgTypeInteractive {\n\t\treturn content\n\t}\n\n\tvar tag string\n\tswitch messageType {\n\tcase larkim.MsgTypeImage:\n\t\ttag = \"[image: photo]\"\n\tcase larkim.MsgTypeAudio:\n\t\ttag = \"[audio]\"\n\tcase larkim.MsgTypeMedia:\n\t\ttag = \"[video]\"\n\tcase larkim.MsgTypeFile:\n\t\ttag = \"[file]\"\n\tdefault:\n\t\ttag = \"[attachment]\"\n\t}\n\n\tif content == \"\" {\n\t\treturn tag\n\t}\n\treturn content + \" \" + tag\n}\n\n// sendCard sends an interactive card message to a chat.\nfunc (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {\n\treq := larkim.NewCreateMessageReqBuilder().\n\t\tReceiveIdType(larkim.ReceiveIdTypeChatId).\n\t\tBody(larkim.NewCreateMessageReqBodyBuilder().\n\t\t\tReceiveId(chatID).\n\t\t\tMsgType(larkim.MsgTypeInteractive).\n\t\t\tContent(cardContent).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Create(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu send card: %w\", channels.ErrTemporary)\n\t}\n\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\treturn fmt.Errorf(\"feishu api error (code=%d msg=%s): %w\", resp.Code, resp.Msg, channels.ErrTemporary)\n\t}\n\n\tlogger.DebugCF(\"feishu\", \"Feishu card message sent\", map[string]any{\n\t\t\"chat_id\": chatID,\n\t})\n\n\treturn nil\n}\n\n// sendText sends a plain text message to a chat (fallback when card fails).\nfunc (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error {\n\tcontent, _ := json.Marshal(map[string]string{\"text\": text})\n\n\treq := larkim.NewCreateMessageReqBuilder().\n\t\tReceiveIdType(larkim.ReceiveIdTypeChatId).\n\t\tBody(larkim.NewCreateMessageReqBodyBuilder().\n\t\t\tReceiveId(chatID).\n\t\t\tMsgType(larkim.MsgTypeText).\n\t\t\tContent(string(content)).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Create(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu send text: %w\", channels.ErrTemporary)\n\t}\n\n\tif !resp.Success() {\n\t\treturn fmt.Errorf(\"feishu text api error (code=%d msg=%s): %w\", resp.Code, resp.Msg, channels.ErrTemporary)\n\t}\n\n\tlogger.DebugCF(\"feishu\", \"Feishu text message sent (fallback)\", map[string]any{\n\t\t\"chat_id\": chatID,\n\t})\n\n\treturn nil\n}\n\n// sendImage uploads an image and sends it as a message.\nfunc (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {\n\t// Upload image to get image_key\n\tuploadReq := larkim.NewCreateImageReqBuilder().\n\t\tBody(larkim.NewCreateImageReqBodyBuilder().\n\t\t\tImageType(\"message\").\n\t\t\tImage(file).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tuploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu image upload: %w\", err)\n\t}\n\tif !uploadResp.Success() {\n\t\tc.invalidateTokenOnAuthError(uploadResp.Code)\n\t\treturn fmt.Errorf(\"feishu image upload api error (code=%d msg=%s)\", uploadResp.Code, uploadResp.Msg)\n\t}\n\tif uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {\n\t\treturn fmt.Errorf(\"feishu image upload: no image_key returned\")\n\t}\n\n\timageKey := *uploadResp.Data.ImageKey\n\n\t// Send image message\n\tcontent, _ := json.Marshal(map[string]string{\"image_key\": imageKey})\n\treq := larkim.NewCreateMessageReqBuilder().\n\t\tReceiveIdType(larkim.ReceiveIdTypeChatId).\n\t\tBody(larkim.NewCreateMessageReqBodyBuilder().\n\t\t\tReceiveId(chatID).\n\t\t\tMsgType(larkim.MsgTypeImage).\n\t\t\tContent(string(content)).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Create(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu image send: %w\", err)\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\treturn fmt.Errorf(\"feishu image send api error (code=%d msg=%s)\", resp.Code, resp.Msg)\n\t}\n\treturn nil\n}\n\n// sendFile uploads a file and sends it as a message.\nfunc (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error {\n\t// Map part type to Feishu file type\n\tfeishuFileType := \"stream\"\n\tswitch fileType {\n\tcase \"audio\":\n\t\tfeishuFileType = \"opus\"\n\tcase \"video\":\n\t\tfeishuFileType = \"mp4\"\n\t}\n\n\t// Upload file to get file_key\n\tuploadReq := larkim.NewCreateFileReqBuilder().\n\t\tBody(larkim.NewCreateFileReqBodyBuilder().\n\t\t\tFileType(feishuFileType).\n\t\t\tFileName(filename).\n\t\t\tFile(file).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tuploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu file upload: %w\", err)\n\t}\n\tif !uploadResp.Success() {\n\t\tc.invalidateTokenOnAuthError(uploadResp.Code)\n\t\treturn fmt.Errorf(\"feishu file upload api error (code=%d msg=%s)\", uploadResp.Code, uploadResp.Msg)\n\t}\n\tif uploadResp.Data == nil || uploadResp.Data.FileKey == nil {\n\t\treturn fmt.Errorf(\"feishu file upload: no file_key returned\")\n\t}\n\n\tfileKey := *uploadResp.Data.FileKey\n\n\t// Send file message\n\tcontent, _ := json.Marshal(map[string]string{\"file_key\": fileKey})\n\treq := larkim.NewCreateMessageReqBuilder().\n\t\tReceiveIdType(larkim.ReceiveIdTypeChatId).\n\t\tBody(larkim.NewCreateMessageReqBodyBuilder().\n\t\t\tReceiveId(chatID).\n\t\t\tMsgType(larkim.MsgTypeFile).\n\t\t\tContent(string(content)).\n\t\t\tBuild()).\n\t\tBuild()\n\n\tresp, err := c.client.Im.V1.Message.Create(ctx, req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"feishu file send: %w\", err)\n\t}\n\tif !resp.Success() {\n\t\tc.invalidateTokenOnAuthError(resp.Code)\n\t\treturn fmt.Errorf(\"feishu file send api error (code=%d msg=%s)\", resp.Code, resp.Msg)\n\t}\n\treturn nil\n}\n\nfunc extractFeishuSenderID(sender *larkim.EventSender) string {\n\tif sender == nil || sender.SenderId == nil {\n\t\treturn \"\"\n\t}\n\n\tif sender.SenderId.UserId != nil && *sender.SenderId.UserId != \"\" {\n\t\treturn *sender.SenderId.UserId\n\t}\n\tif sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != \"\" {\n\t\treturn *sender.SenderId.OpenId\n\t}\n\tif sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != \"\" {\n\t\treturn *sender.SenderId.UnionId\n\t}\n\n\treturn \"\"\n}\n\n// invalidateTokenOnAuthError clears the cached tenant_access_token when the\n// Feishu API reports it as invalid (99991663), so the next request fetches a\n// fresh one. The Lark SDK's built-in retry does not clear the cache, causing\n// all API calls to fail until the token naturally expires (~2 hours).\nfunc (c *FeishuChannel) invalidateTokenOnAuthError(code int) {\n\tif code == errCodeTenantTokenInvalid {\n\t\tc.tokenCache.InvalidateAll()\n\t\tlogger.WarnCF(\"feishu\", \"Invalidated cached token due to auth error\", nil)\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/feishu/feishu_64_test.go",
    "content": "//go:build amd64 || arm64 || riscv64 || mips64 || ppc64\n\npackage feishu\n\nimport (\n\t\"testing\"\n\n\tlarkim \"github.com/larksuite/oapi-sdk-go/v3/service/im/v1\"\n)\n\nfunc TestExtractContent(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tmessageType string\n\t\trawContent  string\n\t\twant        string\n\t}{\n\t\t{\n\t\t\tname:        \"text message\",\n\t\t\tmessageType: \"text\",\n\t\t\trawContent:  `{\"text\": \"hello world\"}`,\n\t\t\twant:        \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:        \"text message invalid JSON\",\n\t\t\tmessageType: \"text\",\n\t\t\trawContent:  `not json`,\n\t\t\twant:        \"not json\",\n\t\t},\n\t\t{\n\t\t\tname:        \"post message returns raw JSON\",\n\t\t\tmessageType: \"post\",\n\t\t\trawContent:  `{\"title\": \"test post\"}`,\n\t\t\twant:        `{\"title\": \"test post\"}`,\n\t\t},\n\t\t{\n\t\t\tname:        \"image message returns empty\",\n\t\t\tmessageType: \"image\",\n\t\t\trawContent:  `{\"image_key\": \"img_xxx\"}`,\n\t\t\twant:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:        \"file message with filename\",\n\t\t\tmessageType: \"file\",\n\t\t\trawContent:  `{\"file_key\": \"file_xxx\", \"file_name\": \"report.pdf\"}`,\n\t\t\twant:        \"report.pdf\",\n\t\t},\n\t\t{\n\t\t\tname:        \"file message without filename\",\n\t\t\tmessageType: \"file\",\n\t\t\trawContent:  `{\"file_key\": \"file_xxx\"}`,\n\t\t\twant:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:        \"audio message with filename\",\n\t\t\tmessageType: \"audio\",\n\t\t\trawContent:  `{\"file_key\": \"file_xxx\", \"file_name\": \"recording.ogg\"}`,\n\t\t\twant:        \"recording.ogg\",\n\t\t},\n\t\t{\n\t\t\tname:        \"media message with filename\",\n\t\t\tmessageType: \"media\",\n\t\t\trawContent:  `{\"file_key\": \"file_xxx\", \"file_name\": \"video.mp4\"}`,\n\t\t\twant:        \"video.mp4\",\n\t\t},\n\t\t{\n\t\t\tname:        \"unknown message type returns raw\",\n\t\t\tmessageType: \"sticker\",\n\t\t\trawContent:  `{\"sticker_id\": \"sticker_xxx\"}`,\n\t\t\twant:        `{\"sticker_id\": \"sticker_xxx\"}`,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty raw content\",\n\t\t\tmessageType: \"text\",\n\t\t\trawContent:  \"\",\n\t\t\twant:        \"\",\n\t\t},\n\t\t{\n\t\t\tname:        \"interactive card returns raw JSON\",\n\t\t\tmessageType: \"interactive\",\n\t\t\trawContent:  `{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Hello from card\"}]}}`,\n\t\t\twant:        `{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Hello from card\"}]}}`,\n\t\t},\n\t\t{\n\t\t\tname:        \"interactive card with complex structure returns raw JSON\",\n\t\t\tmessageType: \"interactive\",\n\t\t\trawContent:  `{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Title\"}},\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"lark_md\",\"content\":\"Card content\"}}]}`,\n\t\t\twant:        `{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Title\"}},\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"lark_md\",\"content\":\"Card content\"}}]}`,\n\t\t},\n\t\t{\n\t\t\tname:        \"interactive card invalid JSON returns as-is\",\n\t\t\tmessageType: \"interactive\",\n\t\t\trawContent:  `not valid json`,\n\t\t\twant:        `not valid json`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractContent(tt.messageType, tt.rawContent)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractContent(%q, %q) = %q, want %q\", tt.messageType, tt.rawContent, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAppendMediaTags(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcontent     string\n\t\tmessageType string\n\t\tmediaRefs   []string\n\t\twant        string\n\t}{\n\t\t{\n\t\t\tname:        \"no refs returns content unchanged\",\n\t\t\tcontent:     \"hello\",\n\t\t\tmessageType: \"image\",\n\t\t\tmediaRefs:   nil,\n\t\t\twant:        \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty refs returns content unchanged\",\n\t\t\tcontent:     \"hello\",\n\t\t\tmessageType: \"image\",\n\t\t\tmediaRefs:   []string{},\n\t\t\twant:        \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:        \"image with content\",\n\t\t\tcontent:     \"check this\",\n\t\t\tmessageType: \"image\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"check this [image: photo]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"image empty content\",\n\t\t\tcontent:     \"\",\n\t\t\tmessageType: \"image\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"[image: photo]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"audio\",\n\t\t\tcontent:     \"listen\",\n\t\t\tmessageType: \"audio\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"listen [audio]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"media/video\",\n\t\t\tcontent:     \"watch\",\n\t\t\tmessageType: \"media\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"watch [video]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"file\",\n\t\t\tcontent:     \"report.pdf\",\n\t\t\tmessageType: \"file\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"report.pdf [file]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"unknown type\",\n\t\t\tcontent:     \"something\",\n\t\t\tmessageType: \"sticker\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        \"something [attachment]\",\n\t\t},\n\t\t{\n\t\t\tname:        \"interactive card with images returns content unchanged\",\n\t\t\tcontent:     `{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"img\",\"img_key\":\"img_123\"}]}}`,\n\t\t\tmessageType: \"interactive\",\n\t\t\tmediaRefs:   []string{\"ref1\"},\n\t\t\twant:        `{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"img\",\"img_key\":\"img_123\"}]}}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\n\t\t\t\t\t\"appendMediaTags(%q, %q, %v) = %q, want %q\",\n\t\t\t\t\ttt.content,\n\t\t\t\t\ttt.messageType,\n\t\t\t\t\ttt.mediaRefs,\n\t\t\t\t\tgot,\n\t\t\t\t\ttt.want,\n\t\t\t\t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractFeishuSenderID(t *testing.T) {\n\tstrPtr := func(s string) *string { return &s }\n\n\ttests := []struct {\n\t\tname   string\n\t\tsender *larkim.EventSender\n\t\twant   string\n\t}{\n\t\t{\n\t\t\tname:   \"nil sender\",\n\t\t\tsender: nil,\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:   \"nil sender ID\",\n\t\t\tsender: &larkim.EventSender{SenderId: nil},\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"userId preferred\",\n\t\t\tsender: &larkim.EventSender{\n\t\t\t\tSenderId: &larkim.UserId{\n\t\t\t\t\tUserId:  strPtr(\"u_abc123\"),\n\t\t\t\t\tOpenId:  strPtr(\"ou_def456\"),\n\t\t\t\t\tUnionId: strPtr(\"on_ghi789\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: \"u_abc123\",\n\t\t},\n\t\t{\n\t\t\tname: \"openId fallback\",\n\t\t\tsender: &larkim.EventSender{\n\t\t\t\tSenderId: &larkim.UserId{\n\t\t\t\t\tUserId:  strPtr(\"\"),\n\t\t\t\t\tOpenId:  strPtr(\"ou_def456\"),\n\t\t\t\t\tUnionId: strPtr(\"on_ghi789\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: \"ou_def456\",\n\t\t},\n\t\t{\n\t\t\tname: \"unionId fallback\",\n\t\t\tsender: &larkim.EventSender{\n\t\t\t\tSenderId: &larkim.UserId{\n\t\t\t\t\tUserId:  strPtr(\"\"),\n\t\t\t\t\tOpenId:  strPtr(\"\"),\n\t\t\t\t\tUnionId: strPtr(\"on_ghi789\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: \"on_ghi789\",\n\t\t},\n\t\t{\n\t\t\tname: \"all empty strings\",\n\t\t\tsender: &larkim.EventSender{\n\t\t\t\tSenderId: &larkim.UserId{\n\t\t\t\t\tUserId:  strPtr(\"\"),\n\t\t\t\t\tOpenId:  strPtr(\"\"),\n\t\t\t\t\tUnionId: strPtr(\"\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"nil userId pointer falls through\",\n\t\t\tsender: &larkim.EventSender{\n\t\t\t\tSenderId: &larkim.UserId{\n\t\t\t\t\tUserId:  nil,\n\t\t\t\t\tOpenId:  strPtr(\"ou_def456\"),\n\t\t\t\t\tUnionId: nil,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: \"ou_def456\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := extractFeishuSenderID(tt.sender)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractFeishuSenderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/feishu/init.go",
    "content": "package feishu\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"feishu\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewFeishuChannel(cfg.Channels.Feishu, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/feishu/token_cache.go",
    "content": "package feishu\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"time\"\n)\n\n// tokenCache implements larkcore.Cache with an extra InvalidateAll method.\n// This works around a bug in the Lark SDK v3 where the built-in token retry\n// loop does not clear stale tokens from cache on auth errors.\ntype tokenCache struct {\n\tmu    sync.RWMutex\n\tstore map[string]*tokenEntry\n}\n\ntype tokenEntry struct {\n\tvalue    string\n\texpireAt time.Time\n}\n\nfunc newTokenCache() *tokenCache {\n\treturn &tokenCache{store: make(map[string]*tokenEntry)}\n}\n\nfunc (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)}\n\treturn nil\n}\n\nfunc (c *tokenCache) Get(_ context.Context, key string) (string, error) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\te, ok := c.store[key]\n\tif !ok {\n\t\treturn \"\", nil\n\t}\n\tif e.expireAt.Before(time.Now()) {\n\t\tdelete(c.store, key)\n\t\treturn \"\", nil\n\t}\n\treturn e.value, nil\n}\n\n// InvalidateAll removes all cached tokens, forcing fresh acquisition.\nfunc (c *tokenCache) InvalidateAll() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tclear(c.store)\n}\n"
  },
  {
    "path": "pkg/channels/interfaces.go",
    "content": "package channels\n\nimport (\n\t\"context\"\n\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n)\n\n// TypingCapable — channels that can show a typing/thinking indicator.\n// StartTyping begins the indicator and returns a stop function.\n// The stop function MUST be idempotent and safe to call multiple times.\ntype TypingCapable interface {\n\tStartTyping(ctx context.Context, chatID string) (stop func(), err error)\n}\n\n// MessageEditor — channels that can edit an existing message.\n// messageID is always string; channels convert platform-specific types internally.\ntype MessageEditor interface {\n\tEditMessage(ctx context.Context, chatID string, messageID string, content string) error\n}\n\n// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.\n// ReactToMessage adds a reaction and returns an undo function to remove it.\n// The undo function MUST be idempotent and safe to call multiple times.\ntype ReactionCapable interface {\n\tReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)\n}\n\n// PlaceholderCapable — channels that can send a placeholder message\n// (e.g. \"Thinking... 💭\") that will later be edited to the actual response.\n// The channel MUST also implement MessageEditor for the placeholder to be useful.\n// SendPlaceholder returns the platform message ID of the placeholder so that\n// Manager.preSend can later edit it via MessageEditor.EditMessage.\ntype PlaceholderCapable interface {\n\tSendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)\n}\n\n// PlaceholderRecorder is injected into channels by Manager.\n// Channels call these methods on inbound to register typing/placeholder state.\n// Manager uses the registered state on outbound to stop typing and edit placeholders.\ntype PlaceholderRecorder interface {\n\tRecordPlaceholder(channel, chatID, placeholderID string)\n\tRecordTypingStop(channel, chatID string, stop func())\n\tRecordReactionUndo(channel, chatID string, undo func())\n}\n\n// CommandRegistrarCapable is implemented by channels that can register\n// command menus with their upstream platform (e.g. Telegram BotCommand).\n// Channels that do not support platform-level command menus can ignore it.\ntype CommandRegistrarCapable interface {\n\tRegisterCommands(ctx context.Context, defs []commands.Definition) error\n}\n"
  },
  {
    "path": "pkg/channels/interfaces_command_test.go",
    "content": "package channels\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n)\n\ntype mockRegistrar struct{}\n\nfunc (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil }\n\nfunc TestCommandRegistrarCapable_Compiles(t *testing.T) {\n\tvar _ CommandRegistrarCapable = mockRegistrar{}\n}\n"
  },
  {
    "path": "pkg/channels/irc/handler.go",
    "content": "package irc\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/ergochat/irc-go/ircevent\"\n\t\"github.com/ergochat/irc-go/ircmsg\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// onConnect is called after a successful connection (and on reconnect).\nfunc (c *IRCChannel) onConnect(conn *ircevent.Connection) {\n\t// NickServ auth (only if SASL is not configured)\n\tif c.config.NickServPassword != \"\" && c.config.SASLUser == \"\" {\n\t\tconn.Privmsg(\"NickServ\", \"IDENTIFY \"+c.config.NickServPassword)\n\t}\n\n\t// Join configured channels\n\tfor _, ch := range c.config.Channels {\n\t\tconn.Join(ch)\n\t\tlogger.InfoCF(\"irc\", \"Joined IRC channel\", map[string]any{\n\t\t\t\"channel\": ch,\n\t\t})\n\t}\n}\n\n// onPrivmsg handles incoming PRIVMSG events.\nfunc (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {\n\tif len(e.Params) < 2 {\n\t\treturn\n\t}\n\n\tnick := e.Nick()\n\tcurrentNick := conn.CurrentNick()\n\n\t// Ignore own messages\n\tif strings.EqualFold(nick, currentNick) {\n\t\treturn\n\t}\n\n\ttarget := e.Params[0]  // channel name or bot's nick\n\tcontent := e.Params[1] // message text\n\n\t// Determine if this is a DM or channel message\n\tisDM := !strings.HasPrefix(target, \"#\") && !strings.HasPrefix(target, \"&\")\n\n\tvar chatID string\n\tvar peer bus.Peer\n\n\tif isDM {\n\t\tchatID = nick\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: nick}\n\t} else {\n\t\tchatID = target\n\t\tpeer = bus.Peer{Kind: \"group\", ID: target}\n\t}\n\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"irc\",\n\t\tPlatformID:  nick,\n\t\tCanonicalID: identity.BuildCanonicalID(\"irc\", nick),\n\t\tUsername:    nick,\n\t\tDisplayName: nick,\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\t// For channel messages, check group trigger (mention detection)\n\tif !isDM {\n\t\tisMentioned := isBotMentioned(content, currentNick)\n\t\tif isMentioned {\n\t\t\tcontent = stripBotMention(content, currentNick)\n\t\t}\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tif strings.TrimSpace(content) == \"\" {\n\t\treturn\n\t}\n\n\tmessageID := fmt.Sprintf(\"%s-%d\", nick, time.Now().UnixNano())\n\n\tmetadata := map[string]string{\n\t\t\"platform\": \"irc\",\n\t\t\"server\":   c.config.Server,\n\t}\n\tif !isDM {\n\t\tmetadata[\"channel\"] = target\n\t}\n\n\tc.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender)\n}\n\n// nickMentionedAt returns the byte index where botNick is mentioned in content\n// with word-boundary checks, or -1 if not found. Also checks for \"nick:\" /\n// \"nick,\" prefix convention.\nfunc nickMentionedAt(content, botNick string) int {\n\tlower := strings.ToLower(content)\n\tlowerNick := strings.ToLower(botNick)\n\n\t// \"nick:\" or \"nick,\" at start (most common IRC convention)\n\tif strings.HasPrefix(lower, lowerNick+\":\") || strings.HasPrefix(lower, lowerNick+\",\") {\n\t\treturn 0\n\t}\n\n\t// Word-boundary match anywhere in the message\n\tidx := strings.Index(lower, lowerNick)\n\tif idx < 0 {\n\t\treturn -1\n\t}\n\trunes := []rune(lower)\n\tnickRunes := []rune(lowerNick)\n\tendIdx := idx + len(string(nickRunes))\n\tbefore := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1])\n\tafter := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx]))\n\tif before && after {\n\t\treturn idx\n\t}\n\treturn -1\n}\n\n// isBotMentioned checks if the bot's nick appears in the message.\nfunc isBotMentioned(content, botNick string) bool {\n\treturn nickMentionedAt(content, botNick) >= 0\n}\n\n// stripBotMention removes \"nick: \" or \"nick, \" prefix from content.\nfunc stripBotMention(content, botNick string) string {\n\tidx := nickMentionedAt(content, botNick)\n\tif idx != 0 {\n\t\treturn content\n\t}\n\tlowerNick := strings.ToLower(botNick)\n\tlower := strings.ToLower(content)\n\tfor _, sep := range []string{\":\", \",\"} {\n\t\tprefix := lowerNick + sep\n\t\tif strings.HasPrefix(lower, prefix) {\n\t\t\treturn strings.TrimSpace(content[len(prefix):])\n\t\t}\n\t}\n\treturn content\n}\n"
  },
  {
    "path": "pkg/channels/irc/init.go",
    "content": "package irc\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"irc\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\tif !cfg.Channels.IRC.Enabled {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn NewIRCChannel(cfg.Channels.IRC, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/irc/irc.go",
    "content": "package irc\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/ergochat/irc-go/ircevent\"\n\t\"github.com/ergochat/irc-go/ircmsg\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// IRCChannel implements the Channel interface for IRC servers.\ntype IRCChannel struct {\n\t*channels.BaseChannel\n\tconfig config.IRCConfig\n\tconn   *ircevent.Connection\n\tctx    context.Context\n\tcancel context.CancelFunc\n}\n\n// NewIRCChannel creates a new IRC channel.\nfunc NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {\n\tif cfg.Server == \"\" {\n\t\treturn nil, fmt.Errorf(\"irc server is required\")\n\t}\n\tif cfg.Nick == \"\" {\n\t\treturn nil, fmt.Errorf(\"irc nick is required\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"irc\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(400),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &IRCChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t}, nil\n}\n\n// Start connects to the IRC server and begins listening.\nfunc (c *IRCChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"irc\", \"Starting IRC channel\")\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tuser := c.config.User\n\tif user == \"\" {\n\t\tuser = c.config.Nick\n\t}\n\trealName := c.config.RealName\n\tif realName == \"\" {\n\t\trealName = c.config.Nick\n\t}\n\tcaps := []string(c.config.RequestCaps)\n\tif len(caps) == 0 {\n\t\tcaps = []string{\"server-time\", \"message-tags\"}\n\t}\n\n\tconn := &ircevent.Connection{\n\t\tServer:      c.config.Server,\n\t\tNick:        c.config.Nick,\n\t\tUser:        user,\n\t\tRealName:    realName,\n\t\tPassword:    c.config.Password,\n\t\tUseTLS:      c.config.TLS,\n\t\tRequestCaps: caps,\n\t\tQuitMessage: \"Goodbye\",\n\t\tDebug:       false,\n\t\tLog:         nil,\n\t}\n\n\tif c.config.TLS {\n\t\tconn.TLSConfig = &tls.Config{\n\t\t\tServerName: extractHost(c.config.Server),\n\t\t}\n\t}\n\n\t// SASL auth (takes priority over NickServ)\n\tif c.config.SASLUser != \"\" && c.config.SASLPassword != \"\" {\n\t\tconn.SASLLogin = c.config.SASLUser\n\t\tconn.SASLPassword = c.config.SASLPassword\n\t}\n\n\t// Register event handlers\n\tconn.AddConnectCallback(func(e ircmsg.Message) {\n\t\tc.onConnect(conn)\n\t})\n\tconn.AddCallback(\"PRIVMSG\", func(e ircmsg.Message) {\n\t\tc.onPrivmsg(conn, e)\n\t})\n\n\tif err := conn.Connect(); err != nil {\n\t\treturn fmt.Errorf(\"irc connect failed: %w\", err)\n\t}\n\n\tc.conn = conn\n\n\t// ircevent.Connection.Loop() handles reconnection internally.\n\tgo conn.Loop()\n\n\tc.SetRunning(true)\n\tlogger.InfoCF(\"irc\", \"IRC channel started\", map[string]any{\n\t\t\"server\": c.config.Server,\n\t\t\"nick\":   c.config.Nick,\n\t})\n\treturn nil\n}\n\n// Stop disconnects from the IRC server.\nfunc (c *IRCChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"irc\", \"Stopping IRC channel\")\n\tc.SetRunning(false)\n\n\tif c.conn != nil {\n\t\tc.conn.Quit()\n\t}\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tlogger.InfoC(\"irc\", \"IRC channel stopped\")\n\treturn nil\n}\n\n// Send sends a message to an IRC channel or user.\nfunc (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\ttarget := msg.ChatID\n\tif target == \"\" {\n\t\treturn fmt.Errorf(\"chat ID is empty: %w\", channels.ErrSendFailed)\n\t}\n\n\tif strings.TrimSpace(msg.Content) == \"\" {\n\t\treturn nil\n\t}\n\n\t// Send each line separately (IRC is line-oriented)\n\tlines := strings.Split(msg.Content, \"\\n\")\n\tfor _, line := range lines {\n\t\tline = strings.TrimRight(line, \"\\r\")\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tc.conn.Privmsg(target, line)\n\t}\n\n\tlogger.DebugCF(\"irc\", \"Message sent\", map[string]any{\n\t\t\"target\": target,\n\t\t\"lines\":  len(lines),\n\t})\n\treturn nil\n}\n\n// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.\n// Requires typing.enabled in config and server support for message-tags capability.\nfunc (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tnoop := func() {}\n\n\tif !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {\n\t\treturn noop, nil\n\t}\n\n\t// Check if server supports message-tags (required for TAGMSG)\n\tif _, ok := c.conn.AcknowledgedCaps()[\"message-tags\"]; !ok {\n\t\treturn noop, nil\n\t}\n\n\tc.conn.SendWithTags(map[string]string{\"+typing\": \"active\"}, \"TAGMSG\", chatID)\n\n\treturn func() {\n\t\tif c.IsRunning() && c.conn != nil {\n\t\t\tc.conn.SendWithTags(map[string]string{\"+typing\": \"done\"}, \"TAGMSG\", chatID)\n\t\t}\n\t}, nil\n}\n\n// extractHost returns the hostname portion of a host:port string.\nfunc extractHost(server string) string {\n\thost, _, found := strings.Cut(server, \":\")\n\tif found {\n\t\treturn host\n\t}\n\treturn server\n}\n"
  },
  {
    "path": "pkg/channels/irc/irc_test.go",
    "content": "package irc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestNewIRCChannel(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"missing server\", func(t *testing.T) {\n\t\tcfg := config.IRCConfig{Nick: \"bot\"}\n\t\t_, err := NewIRCChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing server, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing nick\", func(t *testing.T) {\n\t\tcfg := config.IRCConfig{Server: \"irc.example.com:6667\"}\n\t\t_, err := NewIRCChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing nick, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"valid config\", func(t *testing.T) {\n\t\tcfg := config.IRCConfig{\n\t\t\tServer:   \"irc.example.com:6667\",\n\t\t\tNick:     \"testbot\",\n\t\t\tChannels: []string{\"#test\"},\n\t\t}\n\t\tch, err := NewIRCChannel(cfg, msgBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif ch.Name() != \"irc\" {\n\t\t\tt.Errorf(\"Name() = %q, want %q\", ch.Name(), \"irc\")\n\t\t}\n\t\tif ch.IsRunning() {\n\t\t\tt.Error(\"new channel should not be running\")\n\t\t}\n\t})\n}\n\nfunc TestExtractHost(t *testing.T) {\n\ttests := []struct {\n\t\tserver string\n\t\twant   string\n\t}{\n\t\t{\"irc.libera.chat:6697\", \"irc.libera.chat\"},\n\t\t{\"localhost:6667\", \"localhost\"},\n\t\t{\"irc.example.com\", \"irc.example.com\"},\n\t\t{\"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.server, func(t *testing.T) {\n\t\t\tgot := extractHost(tt.server)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"extractHost(%q) = %q, want %q\", tt.server, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNickMentionedAt(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\tnick    string\n\t\twant    int\n\t}{\n\t\t{\"colon prefix\", \"bot: hello\", \"bot\", 0},\n\t\t{\"comma prefix\", \"bot, hello\", \"bot\", 0},\n\t\t{\"case insensitive\", \"BOT: hello\", \"bot\", 0},\n\t\t{\"word boundary mid\", \"hey bot what's up\", \"bot\", 4},\n\t\t{\"no mention\", \"hello world\", \"bot\", -1},\n\t\t{\"substring mismatch\", \"robotics are cool\", \"bot\", -1},\n\t\t{\"nick at end\", \"hello bot\", \"bot\", 6},\n\t\t{\"empty content\", \"\", \"bot\", -1},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := nickMentionedAt(tt.content, tt.nick)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"nickMentionedAt(%q, %q) = %d, want %d\", tt.content, tt.nick, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsBotMentioned(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\tnick    string\n\t\twant    bool\n\t}{\n\t\t{\"colon prefix\", \"bot: hello\", \"bot\", true},\n\t\t{\"comma prefix\", \"bot, hello\", \"bot\", true},\n\t\t{\"case insensitive\", \"BOT: hello\", \"bot\", true},\n\t\t{\"word boundary mid\", \"hey bot what's up\", \"bot\", true},\n\t\t{\"no mention\", \"hello world\", \"bot\", false},\n\t\t{\"substring mismatch\", \"robotics are cool\", \"bot\", false},\n\t\t{\"nick at end\", \"hello bot\", \"bot\", true},\n\t\t{\"empty content\", \"\", \"bot\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := isBotMentioned(tt.content, tt.nick)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"isBotMentioned(%q, %q) = %v, want %v\", tt.content, tt.nick, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStripBotMention(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcontent string\n\t\tnick    string\n\t\twant    string\n\t}{\n\t\t{\"colon prefix\", \"bot: hello there\", \"bot\", \"hello there\"},\n\t\t{\"comma prefix\", \"bot, help me\", \"bot\", \"help me\"},\n\t\t{\"case insensitive\", \"BOT: hello\", \"bot\", \"hello\"},\n\t\t{\"no prefix match\", \"hello bot\", \"bot\", \"hello bot\"},\n\t\t{\"only prefix\", \"bot:\", \"bot\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := stripBotMention(tt.content, tt.nick)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"stripBotMention(%q, %q) = %q, want %q\", tt.content, tt.nick, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/line/init.go",
    "content": "package line\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"line\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewLINEChannel(cfg.Channels.LINE, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/line/line.go",
    "content": "package line\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tlineAPIBase          = \"https://api.line.me/v2/bot\"\n\tlineDataAPIBase      = \"https://api-data.line.me/v2/bot\"\n\tlineReplyEndpoint    = lineAPIBase + \"/message/reply\"\n\tlinePushEndpoint     = lineAPIBase + \"/message/push\"\n\tlineContentEndpoint  = lineDataAPIBase + \"/message/%s/content\"\n\tlineBotInfoEndpoint  = lineAPIBase + \"/info\"\n\tlineLoadingEndpoint  = lineAPIBase + \"/chat/loading/start\"\n\tlineReplyTokenMaxAge = 25 * time.Second\n\n\t// Limit request body to prevent memory exhaustion (DoS).\n\t// LINE webhook payloads are typically a few KB; 1 MiB is generous.\n\tmaxWebhookBodySize = 1 << 20 // 1 MiB\n)\n\ntype replyTokenEntry struct {\n\ttoken     string\n\ttimestamp time.Time\n}\n\n// LINEChannel implements the Channel interface for LINE Official Account\n// using the LINE Messaging API with HTTP webhook for receiving messages\n// and REST API for sending messages.\ntype LINEChannel struct {\n\t*channels.BaseChannel\n\tconfig         config.LINEConfig\n\tinfoClient     *http.Client // for bot info lookups (short timeout)\n\tapiClient      *http.Client // for messaging API calls\n\tbotUserID      string       // Bot's user ID\n\tbotBasicID     string       // Bot's basic ID (e.g. @216ru...)\n\tbotDisplayName string       // Bot's display name for text-based mention detection\n\treplyTokens    sync.Map     // chatID -> replyTokenEntry\n\tquoteTokens    sync.Map     // chatID -> quoteToken (string)\n\tctx            context.Context\n\tcancel         context.CancelFunc\n}\n\n// NewLINEChannel creates a new LINE channel instance.\nfunc NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {\n\tif cfg.ChannelSecret == \"\" || cfg.ChannelAccessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"line channel_secret and channel_access_token are required\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"line\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(5000),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &LINEChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tinfoClient:  &http.Client{Timeout: 10 * time.Second},\n\t\tapiClient:   &http.Client{Timeout: 30 * time.Second},\n\t}, nil\n}\n\n// Start initializes the LINE channel.\nfunc (c *LINEChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"line\", \"Starting LINE channel (Webhook Mode)\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// Fetch bot profile to get bot's userId for mention detection\n\tif err := c.fetchBotInfo(); err != nil {\n\t\tlogger.WarnCF(\"line\", \"Failed to fetch bot info (mention detection disabled)\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t} else {\n\t\tlogger.InfoCF(\"line\", \"Bot info fetched\", map[string]any{\n\t\t\t\"bot_user_id\":  c.botUserID,\n\t\t\t\"basic_id\":     c.botBasicID,\n\t\t\t\"display_name\": c.botDisplayName,\n\t\t})\n\t}\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"line\", \"LINE channel started (Webhook Mode)\")\n\treturn nil\n}\n\n// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.\nfunc (c *LINEChannel) fetchBotInfo() error {\n\treq, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.config.ChannelAccessToken)\n\n\tresp, err := c.infoClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"bot info API returned status %d\", resp.StatusCode)\n\t}\n\n\tvar info struct {\n\t\tUserID      string `json:\"userId\"`\n\t\tBasicID     string `json:\"basicId\"`\n\t\tDisplayName string `json:\"displayName\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&info); err != nil {\n\t\treturn err\n\t}\n\n\tc.botUserID = info.UserID\n\tc.botBasicID = info.BasicID\n\tc.botDisplayName = info.DisplayName\n\treturn nil\n}\n\n// Stop gracefully stops the LINE channel.\nfunc (c *LINEChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"line\", \"Stopping LINE channel\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"line\", \"LINE channel stopped\")\n\treturn nil\n}\n\n// WebhookPath returns the path for registering on the shared HTTP server.\nfunc (c *LINEChannel) WebhookPath() string {\n\tif c.config.WebhookPath != \"\" {\n\t\treturn c.config.WebhookPath\n\t}\n\treturn \"/webhook/line\"\n}\n\n// ServeHTTP implements http.Handler for the shared HTTP server.\nfunc (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tc.webhookHandler(w, r)\n}\n\n// webhookHandler handles incoming LINE webhook requests.\nfunc (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))\n\tif err != nil {\n\t\tlogger.ErrorCF(\"line\", \"Failed to read request body\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Bad request\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif int64(len(body)) > maxWebhookBodySize {\n\t\tlogger.WarnC(\"line\", \"Webhook request body too large, rejected\")\n\t\thttp.Error(w, \"Request entity too large\", http.StatusRequestEntityTooLarge)\n\t\treturn\n\t}\n\n\tsignature := r.Header.Get(\"X-Line-Signature\")\n\tif !c.verifySignature(body, signature) {\n\t\tlogger.WarnC(\"line\", \"Invalid webhook signature\")\n\t\thttp.Error(w, \"Forbidden\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\tvar payload struct {\n\t\tEvents []lineEvent `json:\"events\"`\n\t}\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tlogger.ErrorCF(\"line\", \"Failed to parse webhook payload\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Bad request\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Return 200 immediately, process events asynchronously\n\tw.WriteHeader(http.StatusOK)\n\n\tfor _, event := range payload.Events {\n\t\tgo c.processEvent(event)\n\t}\n}\n\n// verifySignature validates the X-Line-Signature using HMAC-SHA256.\nfunc (c *LINEChannel) verifySignature(body []byte, signature string) bool {\n\tif signature == \"\" {\n\t\treturn false\n\t}\n\n\tmac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))\n\tmac.Write(body)\n\texpected := base64.StdEncoding.EncodeToString(mac.Sum(nil))\n\n\treturn hmac.Equal([]byte(expected), []byte(signature))\n}\n\n// LINE webhook event types\ntype lineEvent struct {\n\tType       string          `json:\"type\"`\n\tReplyToken string          `json:\"replyToken\"`\n\tSource     lineSource      `json:\"source\"`\n\tMessage    json.RawMessage `json:\"message\"`\n\tTimestamp  int64           `json:\"timestamp\"`\n}\n\ntype lineSource struct {\n\tType    string `json:\"type\"` // \"user\", \"group\", \"room\"\n\tUserID  string `json:\"userId\"`\n\tGroupID string `json:\"groupId\"`\n\tRoomID  string `json:\"roomId\"`\n}\n\ntype lineMessage struct {\n\tID         string `json:\"id\"`\n\tType       string `json:\"type\"` // \"text\", \"image\", \"video\", \"audio\", \"file\", \"sticker\"\n\tText       string `json:\"text\"`\n\tQuoteToken string `json:\"quoteToken\"`\n\tMention    *struct {\n\t\tMentionees []lineMentionee `json:\"mentionees\"`\n\t} `json:\"mention\"`\n\tContentProvider struct {\n\t\tType string `json:\"type\"`\n\t} `json:\"contentProvider\"`\n}\n\ntype lineMentionee struct {\n\tIndex  int    `json:\"index\"`\n\tLength int    `json:\"length\"`\n\tType   string `json:\"type\"` // \"user\", \"all\"\n\tUserID string `json:\"userId\"`\n}\n\nfunc (c *LINEChannel) processEvent(event lineEvent) {\n\tif event.Type != \"message\" {\n\t\tlogger.DebugCF(\"line\", \"Ignoring non-message event\", map[string]any{\n\t\t\t\"type\": event.Type,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := event.Source.UserID\n\tchatID := c.resolveChatID(event.Source)\n\tisGroup := event.Source.Type == \"group\" || event.Source.Type == \"room\"\n\n\tvar msg lineMessage\n\tif err := json.Unmarshal(event.Message, &msg); err != nil {\n\t\tlogger.ErrorCF(\"line\", \"Failed to parse message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Store reply token for later use\n\tif event.ReplyToken != \"\" {\n\t\tc.replyTokens.Store(chatID, replyTokenEntry{\n\t\t\ttoken:     event.ReplyToken,\n\t\t\ttimestamp: time.Now(),\n\t\t})\n\t}\n\n\t// Store quote token for quoting the original message in reply\n\tif msg.QuoteToken != \"\" {\n\t\tc.quoteTokens.Store(chatID, msg.QuoteToken)\n\t}\n\n\tvar content string\n\tvar mediaPaths []string\n\n\tscope := channels.BuildMediaScope(\"line\", chatID, msg.ID)\n\n\t// Helper to register a local file with the media store\n\tstoreMedia := func(localPath, filename string) string {\n\t\tif store := c.GetMediaStore(); store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename: filename,\n\t\t\t\tSource:   \"line\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath // fallback\n\t}\n\n\tswitch msg.Type {\n\tcase \"text\":\n\t\tcontent = msg.Text\n\t\t// Strip bot mention from text in group chats\n\t\tif isGroup {\n\t\t\tcontent = c.stripBotMention(content, msg)\n\t\t}\n\tcase \"image\":\n\t\tlocalPath := c.downloadContent(msg.ID, \"image.jpg\")\n\t\tif localPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, \"image.jpg\"))\n\t\t\tcontent = \"[image]\"\n\t\t}\n\tcase \"audio\":\n\t\tlocalPath := c.downloadContent(msg.ID, \"audio.m4a\")\n\t\tif localPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, \"audio.m4a\"))\n\t\t\tcontent = \"[audio]\"\n\t\t}\n\tcase \"video\":\n\t\tlocalPath := c.downloadContent(msg.ID, \"video.mp4\")\n\t\tif localPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, \"video.mp4\"))\n\t\t\tcontent = \"[video]\"\n\t\t}\n\tcase \"file\":\n\t\tcontent = \"[file]\"\n\tcase \"sticker\":\n\t\tcontent = \"[sticker]\"\n\tdefault:\n\t\tcontent = fmt.Sprintf(\"[%s]\", msg.Type)\n\t}\n\n\tif strings.TrimSpace(content) == \"\" {\n\t\treturn\n\t}\n\n\t// In group chats, apply unified group trigger filtering\n\tif isGroup {\n\t\tisMentioned := c.isBotMentioned(msg)\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\tlogger.DebugCF(\"line\", \"Ignoring group message by group trigger\", map[string]any{\n\t\t\t\t\"chat_id\": chatID,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tmetadata := map[string]string{\n\t\t\"platform\":    \"line\",\n\t\t\"source_type\": event.Source.Type,\n\t}\n\n\tvar peer bus.Peer\n\tif isGroup {\n\t\tpeer = bus.Peer{Kind: \"group\", ID: chatID}\n\t} else {\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: senderID}\n\t}\n\n\tlogger.DebugCF(\"line\", \"Received message\", map[string]any{\n\t\t\"sender_id\":    senderID,\n\t\t\"chat_id\":      chatID,\n\t\t\"message_type\": msg.Type,\n\t\t\"is_group\":     isGroup,\n\t\t\"preview\":      utils.Truncate(content, 50),\n\t})\n\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"line\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"line\", senderID),\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\tc.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)\n}\n\n// isBotMentioned checks if the bot is mentioned in the message.\n// It first checks the mention metadata (userId match), then falls back\n// to text-based detection using the bot's display name, since LINE may\n// not include userId in mentionees for Official Accounts.\nfunc (c *LINEChannel) isBotMentioned(msg lineMessage) bool {\n\t// Check mention metadata\n\tif msg.Mention != nil {\n\t\tfor _, m := range msg.Mention.Mentionees {\n\t\t\tif m.Type == \"all\" {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif c.botUserID != \"\" && m.UserID == c.botUserID {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\t// Mention metadata exists with mentionees but bot not matched by userId.\n\t\t// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),\n\t\t// so check if any mentionee overlaps with bot display name in text.\n\t\tif c.botDisplayName != \"\" {\n\t\t\tfor _, m := range msg.Mention.Mentionees {\n\t\t\t\tif m.Index >= 0 && m.Length > 0 {\n\t\t\t\t\trunes := []rune(msg.Text)\n\t\t\t\t\tend := m.Index + m.Length\n\t\t\t\t\tif end <= len(runes) {\n\t\t\t\t\t\tmentionText := string(runes[m.Index:end])\n\t\t\t\t\t\tif strings.Contains(mentionText, c.botDisplayName) {\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: text-based detection with display name\n\tif c.botDisplayName != \"\" && strings.Contains(msg.Text, \"@\"+c.botDisplayName) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// stripBotMention removes the @BotName mention text from the message.\nfunc (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {\n\tstripped := false\n\n\t// Try to strip using mention metadata indices\n\tif msg.Mention != nil {\n\t\trunes := []rune(text)\n\t\tfor i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {\n\t\t\tm := msg.Mention.Mentionees[i]\n\t\t\t// Strip if userId matches OR if the mention text contains the bot display name\n\t\t\tshouldStrip := false\n\t\t\tif c.botUserID != \"\" && m.UserID == c.botUserID {\n\t\t\t\tshouldStrip = true\n\t\t\t} else if c.botDisplayName != \"\" && m.Index >= 0 && m.Length > 0 {\n\t\t\t\tend := m.Index + m.Length\n\t\t\t\tif end <= len(runes) {\n\t\t\t\t\tmentionText := string(runes[m.Index:end])\n\t\t\t\t\tif strings.Contains(mentionText, c.botDisplayName) {\n\t\t\t\t\t\tshouldStrip = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif shouldStrip {\n\t\t\t\tstart := m.Index\n\t\t\t\tend := m.Index + m.Length\n\t\t\t\tif start >= 0 && end <= len(runes) {\n\t\t\t\t\trunes = append(runes[:start], runes[end:]...)\n\t\t\t\t\tstripped = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif stripped {\n\t\t\treturn strings.TrimSpace(string(runes))\n\t\t}\n\t}\n\n\t// Fallback: strip @DisplayName from text\n\tif c.botDisplayName != \"\" {\n\t\ttext = strings.ReplaceAll(text, \"@\"+c.botDisplayName, \"\")\n\t}\n\n\treturn strings.TrimSpace(text)\n}\n\n// resolveChatID determines the chat ID from the event source.\n// For group/room messages, use the group/room ID; for 1:1, use the user ID.\nfunc (c *LINEChannel) resolveChatID(source lineSource) string {\n\tswitch source.Type {\n\tcase \"group\":\n\t\treturn source.GroupID\n\tcase \"room\":\n\t\treturn source.RoomID\n\tdefault:\n\t\treturn source.UserID\n\t}\n}\n\n// Send sends a message to LINE. It first tries the Reply API (free)\n// using a cached reply token, then falls back to the Push API.\nfunc (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// Load and consume quote token for this chat\n\tvar quoteToken string\n\tif qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {\n\t\tquoteToken = qt.(string)\n\t}\n\n\t// Try reply token first (free, valid for ~25 seconds)\n\tif entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {\n\t\ttokenEntry := entry.(replyTokenEntry)\n\t\tif time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {\n\t\t\tif err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {\n\t\t\t\tlogger.DebugCF(\"line\", \"Message sent via Reply API\", map[string]any{\n\t\t\t\t\t\"chat_id\": msg.ChatID,\n\t\t\t\t\t\"quoted\":  quoteToken != \"\",\n\t\t\t\t})\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tlogger.DebugC(\"line\", \"Reply API failed, falling back to Push API\")\n\t\t}\n\t}\n\n\t// Fall back to Push API\n\treturn c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)\n}\n\n// SendMedia implements the channels.MediaSender interface.\n// LINE requires media to be accessible via public URL; since we only have local files,\n// we fall back to sending a text message with the filename/caption.\n// For full support, an external file hosting service would be needed.\nfunc (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\t// LINE Messaging API requires publicly accessible URLs for media messages.\n\t// Since we only have local file paths, send caption text as fallback.\n\tfor _, part := range msg.Parts {\n\t\tcaption := part.Caption\n\t\tif caption == \"\" {\n\t\t\tcaption = fmt.Sprintf(\"[%s: %s]\", part.Type, part.Filename)\n\t\t}\n\n\t\tif err := c.sendPush(ctx, msg.ChatID, caption, \"\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// buildTextMessage creates a text message object, optionally with quoteToken.\nfunc buildTextMessage(content, quoteToken string) map[string]string {\n\tmsg := map[string]string{\n\t\t\"type\": \"text\",\n\t\t\"text\": content,\n\t}\n\tif quoteToken != \"\" {\n\t\tmsg[\"quoteToken\"] = quoteToken\n\t}\n\treturn msg\n}\n\n// sendReply sends a message using the LINE Reply API.\nfunc (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {\n\tpayload := map[string]any{\n\t\t\"replyToken\": replyToken,\n\t\t\"messages\":   []map[string]string{buildTextMessage(content, quoteToken)},\n\t}\n\n\treturn c.callAPI(ctx, lineReplyEndpoint, payload)\n}\n\n// sendPush sends a message using the LINE Push API.\nfunc (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {\n\tpayload := map[string]any{\n\t\t\"to\":       to,\n\t\t\"messages\": []map[string]string{buildTextMessage(content, quoteToken)},\n\t}\n\n\treturn c.callAPI(ctx, linePushEndpoint, payload)\n}\n\n// StartTyping implements channels.TypingCapable using LINE's loading animation.\n//\n// NOTE: The LINE loading animation API only works for 1:1 chats.\n// Group/room chat IDs (starting with \"C\" or \"R\") are detected automatically;\n// for these, a no-op stop function is returned without calling the API.\nfunc (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tif chatID == \"\" {\n\t\treturn func() {}, nil\n\t}\n\n\t// Group/room chats: LINE loading animation is 1:1 only.\n\tif strings.HasPrefix(chatID, \"C\") || strings.HasPrefix(chatID, \"R\") {\n\t\treturn func() {}, nil\n\t}\n\n\ttypingCtx, cancel := context.WithCancel(ctx)\n\tvar once sync.Once\n\tstop := func() { once.Do(cancel) }\n\n\t// Send immediately, then refresh periodically for long-running tasks.\n\tif err := c.sendLoading(typingCtx, chatID); err != nil {\n\t\tstop()\n\t\treturn stop, err\n\t}\n\n\tticker := time.NewTicker(50 * time.Second)\n\tgo func() {\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-typingCtx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tif err := c.sendLoading(typingCtx, chatID); err != nil {\n\t\t\t\t\tlogger.DebugCF(\"line\", \"Failed to refresh loading indicator\", map[string]any{\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn stop, nil\n}\n\n// sendLoading sends a loading animation indicator to the chat.\nfunc (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {\n\tpayload := map[string]any{\n\t\t\"chatId\":         chatID,\n\t\t\"loadingSeconds\": 60,\n\t}\n\treturn c.callAPI(ctx, lineLoadingEndpoint, payload)\n}\n\n// callAPI makes an authenticated POST request to the LINE API.\nfunc (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {\n\tbody, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal payload: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+c.config.ChannelAccessToken)\n\n\tresp, err := c.apiClient.Do(req)\n\tif err != nil {\n\t\treturn channels.ClassifyNetError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\trespBody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn channels.ClassifySendError(resp.StatusCode, fmt.Errorf(\"reading LINE API error response: %w\", err))\n\t\t}\n\t\treturn channels.ClassifySendError(resp.StatusCode, fmt.Errorf(\"LINE API error: %s\", string(respBody)))\n\t}\n\n\treturn nil\n}\n\n// downloadContent downloads media content from the LINE API.\nfunc (c *LINEChannel) downloadContent(messageID, filename string) string {\n\turl := fmt.Sprintf(lineContentEndpoint, messageID)\n\treturn utils.DownloadFile(url, filename, utils.DownloadOptions{\n\t\tLoggerPrefix: \"line\",\n\t\tExtraHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer \" + c.config.ChannelAccessToken,\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/line/line_test.go",
    "content": "package line\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestWebhookRejectsOversizedBody(t *testing.T) {\n\tch := &LINEChannel{}\n\n\toversized := bytes.Repeat([]byte(\"A\"), maxWebhookBodySize+1)\n\treq := httptest.NewRequest(http.MethodPost, \"/webhook\", bytes.NewReader(oversized))\n\trec := httptest.NewRecorder()\n\n\tch.webhookHandler(rec, req)\n\n\tif rec.Code != http.StatusRequestEntityTooLarge {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusRequestEntityTooLarge, rec.Code)\n\t}\n}\n\nfunc TestWebhookAcceptsMaxBodySize(t *testing.T) {\n\tch := &LINEChannel{}\n\n\tbody := bytes.Repeat([]byte(\"A\"), maxWebhookBodySize)\n\treq := httptest.NewRequest(http.MethodPost, \"/webhook\", bytes.NewReader(body))\n\trec := httptest.NewRecorder()\n\n\tch.webhookHandler(rec, req)\n\n\t// Missing signature should be rejected, but the body size should not trigger 413.\n\tif rec.Code != http.StatusForbidden {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusForbidden, rec.Code)\n\t}\n}\n\nfunc TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {\n\tch := &LINEChannel{}\n\n\toversized := bytes.Repeat([]byte(\"A\"), maxWebhookBodySize+1)\n\treq := httptest.NewRequest(http.MethodPost, \"/webhook\", bytes.NewReader(oversized))\n\treq.Header.Set(\"X-Line-Signature\", \"invalidsignature\")\n\trec := httptest.NewRecorder()\n\n\tch.webhookHandler(rec, req)\n\n\tif rec.Code != http.StatusRequestEntityTooLarge {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusRequestEntityTooLarge, rec.Code)\n\t}\n}\n\nfunc TestWebhookRejectsNonPostMethod(t *testing.T) {\n\tch := &LINEChannel{}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/webhook\", nil)\n\trec := httptest.NewRecorder()\n\n\tch.webhookHandler(rec, req)\n\n\tif rec.Code != http.StatusMethodNotAllowed {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusMethodNotAllowed, rec.Code)\n\t}\n}\n\nfunc TestWebhookRejectsInvalidSignature(t *testing.T) {\n\tch := &LINEChannel{}\n\n\tbody := `{\"events\":[]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/webhook\", strings.NewReader(body))\n\treq.Header.Set(\"X-Line-Signature\", \"invalidsignature\")\n\trec := httptest.NewRecorder()\n\n\tch.webhookHandler(rec, req)\n\n\tif rec.Code != http.StatusForbidden {\n\t\tt.Errorf(\"expected status %d, got %d\", http.StatusForbidden, rec.Code)\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/maixcam/init.go",
    "content": "package maixcam\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"maixcam\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewMaixCamChannel(cfg.Channels.MaixCam, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/maixcam/maixcam.go",
    "content": "package maixcam\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\ntype MaixCamChannel struct {\n\t*channels.BaseChannel\n\tconfig     config.MaixCamConfig\n\tlistener   net.Listener\n\tctx        context.Context\n\tcancel     context.CancelFunc\n\tclients    map[net.Conn]bool\n\tclientsMux sync.RWMutex\n}\n\ntype MaixCamMessage struct {\n\tType      string         `json:\"type\"`\n\tTips      string         `json:\"tips\"`\n\tTimestamp float64        `json:\"timestamp\"`\n\tData      map[string]any `json:\"data\"`\n}\n\nfunc NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {\n\tbase := channels.NewBaseChannel(\n\t\t\"maixcam\",\n\t\tcfg,\n\t\tbus,\n\t\tcfg.AllowFrom,\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &MaixCamChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tclients:     make(map[net.Conn]bool),\n\t}, nil\n}\n\nfunc (c *MaixCamChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"maixcam\", \"Starting MaixCam channel server\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\taddr := fmt.Sprintf(\"%s:%d\", c.config.Host, c.config.Port)\n\tlistener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\tc.cancel()\n\t\treturn fmt.Errorf(\"failed to listen on %s: %w\", addr, err)\n\t}\n\n\tc.listener = listener\n\tc.SetRunning(true)\n\n\tlogger.InfoCF(\"maixcam\", \"MaixCam server listening\", map[string]any{\n\t\t\"host\": c.config.Host,\n\t\t\"port\": c.config.Port,\n\t})\n\n\tgo c.acceptConnections()\n\n\treturn nil\n}\n\nfunc (c *MaixCamChannel) acceptConnections() {\n\tlogger.DebugC(\"maixcam\", \"Starting connection acceptor\")\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\tlogger.InfoC(\"maixcam\", \"Stopping connection acceptor\")\n\t\t\treturn\n\t\tdefault:\n\t\t\tconn, err := c.listener.Accept()\n\t\t\tif err != nil {\n\t\t\t\tif c.IsRunning() {\n\t\t\t\t\tlogger.ErrorCF(\"maixcam\", \"Failed to accept connection\", map[string]any{\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tlogger.InfoCF(\"maixcam\", \"New connection from MaixCam device\", map[string]any{\n\t\t\t\t\"remote_addr\": conn.RemoteAddr().String(),\n\t\t\t})\n\n\t\t\tc.clientsMux.Lock()\n\t\t\tc.clients[conn] = true\n\t\t\tc.clientsMux.Unlock()\n\n\t\t\tgo c.handleConnection(conn)\n\t\t}\n\t}\n}\n\nfunc (c *MaixCamChannel) handleConnection(conn net.Conn) {\n\tlogger.DebugC(\"maixcam\", \"Handling MaixCam connection\")\n\n\tdefer func() {\n\t\tconn.Close()\n\t\tc.clientsMux.Lock()\n\t\tdelete(c.clients, conn)\n\t\tc.clientsMux.Unlock()\n\t\tlogger.DebugC(\"maixcam\", \"Connection closed\")\n\t}()\n\n\tdecoder := json.NewDecoder(conn)\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t\tvar msg MaixCamMessage\n\t\t\tif err := decoder.Decode(&msg); err != nil {\n\t\t\t\tif err.Error() != \"EOF\" {\n\t\t\t\t\tlogger.ErrorCF(\"maixcam\", \"Failed to decode message\", map[string]any{\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tc.processMessage(msg, conn)\n\t\t}\n\t}\n}\n\nfunc (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {\n\tswitch msg.Type {\n\tcase \"person_detected\":\n\t\tc.handlePersonDetection(msg)\n\tcase \"heartbeat\":\n\t\tlogger.DebugC(\"maixcam\", \"Received heartbeat\")\n\tcase \"status\":\n\t\tc.handleStatusUpdate(msg)\n\tdefault:\n\t\tlogger.WarnCF(\"maixcam\", \"Unknown message type\", map[string]any{\n\t\t\t\"type\": msg.Type,\n\t\t})\n\t}\n}\n\nfunc (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {\n\tlogger.InfoCF(\"maixcam\", \"\", map[string]any{\n\t\t\"timestamp\": msg.Timestamp,\n\t\t\"data\":      msg.Data,\n\t})\n\n\tsenderID := \"maixcam\"\n\tchatID := \"default\"\n\n\tclassInfo, ok := msg.Data[\"class_name\"].(string)\n\tif !ok {\n\t\tclassInfo = \"person\"\n\t}\n\n\tscore, _ := msg.Data[\"score\"].(float64)\n\tx, _ := msg.Data[\"x\"].(float64)\n\ty, _ := msg.Data[\"y\"].(float64)\n\tw, _ := msg.Data[\"w\"].(float64)\n\th, _ := msg.Data[\"h\"].(float64)\n\n\tcontent := fmt.Sprintf(\"📷 Person detected!\\nClass: %s\\nConfidence: %.2f%%\\nPosition: (%.0f, %.0f)\\nSize: %.0fx%.0f\",\n\t\tclassInfo, score*100, x, y, w, h)\n\n\tmetadata := map[string]string{\n\t\t\"timestamp\": fmt.Sprintf(\"%.0f\", msg.Timestamp),\n\t\t\"class_id\":  fmt.Sprintf(\"%.0f\", msg.Data[\"class_id\"]),\n\t\t\"score\":     fmt.Sprintf(\"%.2f\", score),\n\t\t\"x\":         fmt.Sprintf(\"%.0f\", x),\n\t\t\"y\":         fmt.Sprintf(\"%.0f\", y),\n\t\t\"w\":         fmt.Sprintf(\"%.0f\", w),\n\t\t\"h\":         fmt.Sprintf(\"%.0f\", h),\n\t}\n\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"maixcam\",\n\t\tPlatformID:  \"maixcam\",\n\t\tCanonicalID: identity.BuildCanonicalID(\"maixcam\", \"maixcam\"),\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\tc.HandleMessage(\n\t\tc.ctx,\n\t\tbus.Peer{Kind: \"channel\", ID: \"default\"},\n\t\t\"\",\n\t\tsenderID,\n\t\tchatID,\n\t\tcontent,\n\t\t[]string{},\n\t\tmetadata,\n\t\tsender,\n\t)\n}\n\nfunc (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {\n\tlogger.InfoCF(\"maixcam\", \"Status update from MaixCam\", map[string]any{\n\t\t\"status\": msg.Data,\n\t})\n}\n\nfunc (c *MaixCamChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"maixcam\", \"Stopping MaixCam channel\")\n\tc.SetRunning(false)\n\n\t// Cancel context first to signal goroutines to exit\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tif c.listener != nil {\n\t\tc.listener.Close()\n\t}\n\n\tc.clientsMux.Lock()\n\tdefer c.clientsMux.Unlock()\n\n\tfor conn := range c.clients {\n\t\tconn.Close()\n\t}\n\tc.clients = make(map[net.Conn]bool)\n\n\tlogger.InfoC(\"maixcam\", \"MaixCam channel stopped\")\n\treturn nil\n}\n\nfunc (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// Check ctx before entering write path\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tdefault:\n\t}\n\n\tc.clientsMux.RLock()\n\tdefer c.clientsMux.RUnlock()\n\n\tif len(c.clients) == 0 {\n\t\tlogger.WarnC(\"maixcam\", \"No MaixCam devices connected\")\n\t\treturn fmt.Errorf(\"no connected MaixCam devices\")\n\t}\n\n\tresponse := map[string]any{\n\t\t\"type\":      \"command\",\n\t\t\"timestamp\": float64(0),\n\t\t\"message\":   msg.Content,\n\t\t\"chat_id\":   msg.ChatID,\n\t}\n\n\tdata, err := json.Marshal(response)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal response: %w\", err)\n\t}\n\n\tvar sendErr error\n\tfor conn := range c.clients {\n\t\t_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))\n\t\tif _, err := conn.Write(data); err != nil {\n\t\t\tlogger.ErrorCF(\"maixcam\", \"Failed to send to client\", map[string]any{\n\t\t\t\t\"client\": conn.RemoteAddr().String(),\n\t\t\t\t\"error\":  err.Error(),\n\t\t\t})\n\t\t\tsendErr = fmt.Errorf(\"maixcam send: %w\", channels.ErrTemporary)\n\t\t}\n\t\t_ = conn.SetWriteDeadline(time.Time{})\n\t}\n\n\treturn sendErr\n}\n"
  },
  {
    "path": "pkg/channels/manager.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage channels\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/time/rate\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n\t\"github.com/sipeed/picoclaw/pkg/health\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nconst (\n\tdefaultChannelQueueSize = 16\n\tdefaultRateLimit        = 10 // default 10 msg/s\n\tmaxRetries              = 3\n\trateLimitDelay          = 1 * time.Second\n\tbaseBackoff             = 500 * time.Millisecond\n\tmaxBackoff              = 8 * time.Second\n\n\tjanitorInterval = 10 * time.Second\n\ttypingStopTTL   = 5 * time.Minute\n\tplaceholderTTL  = 10 * time.Minute\n)\n\n// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.\ntype typingEntry struct {\n\tstop      func()\n\tcreatedAt time.Time\n}\n\n// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction.\ntype reactionEntry struct {\n\tundo      func()\n\tcreatedAt time.Time\n}\n\n// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction.\ntype placeholderEntry struct {\n\tid        string\n\tcreatedAt time.Time\n}\n\n// channelRateConfig maps channel name to per-second rate limit.\nvar channelRateConfig = map[string]float64{\n\t\"telegram\": 20,\n\t\"discord\":  1,\n\t\"slack\":    1,\n\t\"matrix\":   2,\n\t\"line\":     10,\n\t\"qq\":       5,\n\t\"irc\":      2,\n}\n\ntype channelWorker struct {\n\tch         Channel\n\tqueue      chan bus.OutboundMessage\n\tmediaQueue chan bus.OutboundMediaMessage\n\tdone       chan struct{}\n\tmediaDone  chan struct{}\n\tlimiter    *rate.Limiter\n}\n\ntype Manager struct {\n\tchannels      map[string]Channel\n\tworkers       map[string]*channelWorker\n\tbus           *bus.MessageBus\n\tconfig        *config.Config\n\tmediaStore    media.MediaStore\n\tdispatchTask  *asyncTask\n\tmux           *http.ServeMux\n\thttpServer    *http.Server\n\tmu            sync.RWMutex\n\tplaceholders  sync.Map          // \"channel:chatID\" → placeholderID (string)\n\ttypingStops   sync.Map          // \"channel:chatID\" → func()\n\treactionUndos sync.Map          // \"channel:chatID\" → reactionEntry\n\tchannelHashes map[string]string // channel name → config hash\n}\n\ntype asyncTask struct {\n\tcancel context.CancelFunc\n}\n\n// RecordPlaceholder registers a placeholder message for later editing.\n// Implements PlaceholderRecorder.\nfunc (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {\n\tkey := channel + \":\" + chatID\n\tm.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})\n}\n\n// SendPlaceholder sends a \"Thinking…\" placeholder for the given channel/chatID\n// and records it for later editing. Returns true if a placeholder was sent.\nfunc (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {\n\tm.mu.RLock()\n\tch, ok := m.channels[channel]\n\tm.mu.RUnlock()\n\tif !ok {\n\t\treturn false\n\t}\n\tpc, ok := ch.(PlaceholderCapable)\n\tif !ok {\n\t\treturn false\n\t}\n\tphID, err := pc.SendPlaceholder(ctx, chatID)\n\tif err != nil || phID == \"\" {\n\t\treturn false\n\t}\n\tm.RecordPlaceholder(channel, chatID, phID)\n\treturn true\n}\n\n// RecordTypingStop registers a typing stop function for later invocation.\n// Implements PlaceholderRecorder.\nfunc (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {\n\tkey := channel + \":\" + chatID\n\tentry := typingEntry{stop: stop, createdAt: time.Now()}\n\tif previous, loaded := m.typingStops.Swap(key, entry); loaded {\n\t\tif oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {\n\t\t\toldEntry.stop()\n\t\t}\n\t}\n}\n\n// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID.\n// It is safe to call even when no typing indicator is active (no-op).\n// Used by the agent loop to stop typing when processing completes (success, error, or panic),\n// regardless of whether an outbound message is published.\nfunc (m *Manager) InvokeTypingStop(channel, chatID string) {\n\tkey := channel + \":\" + chatID\n\tif v, loaded := m.typingStops.LoadAndDelete(key); loaded {\n\t\tif entry, ok := v.(typingEntry); ok {\n\t\t\tentry.stop()\n\t\t}\n\t}\n}\n\n// RecordReactionUndo registers a reaction undo function for later invocation.\n// Implements PlaceholderRecorder.\nfunc (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {\n\tkey := channel + \":\" + chatID\n\tm.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})\n}\n\n// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.\n// Returns true if the message was edited into a placeholder (skip Send).\nfunc (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {\n\tkey := name + \":\" + msg.ChatID\n\n\t// 1. Stop typing\n\tif v, loaded := m.typingStops.LoadAndDelete(key); loaded {\n\t\tif entry, ok := v.(typingEntry); ok {\n\t\t\tentry.stop() // idempotent, safe\n\t\t}\n\t}\n\n\t// 2. Undo reaction\n\tif v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {\n\t\tif entry, ok := v.(reactionEntry); ok {\n\t\t\tentry.undo() // idempotent, safe\n\t\t}\n\t}\n\n\t// 3. Try editing placeholder\n\tif v, loaded := m.placeholders.LoadAndDelete(key); loaded {\n\t\tif entry, ok := v.(placeholderEntry); ok && entry.id != \"\" {\n\t\t\tif editor, ok := ch.(MessageEditor); ok {\n\t\t\t\tif err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {\n\t\t\t\t\treturn true // edited successfully, skip Send\n\t\t\t\t}\n\t\t\t\t// edit failed → fall through to normal Send\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {\n\tm := &Manager{\n\t\tchannels:      make(map[string]Channel),\n\t\tworkers:       make(map[string]*channelWorker),\n\t\tbus:           messageBus,\n\t\tconfig:        cfg,\n\t\tmediaStore:    store,\n\t\tchannelHashes: make(map[string]string),\n\t}\n\n\tif err := m.initChannels(&cfg.Channels); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Store initial config hashes for all channels\n\tm.channelHashes = toChannelHashes(cfg)\n\n\treturn m, nil\n}\n\n// initChannel is a helper that looks up a factory by name and creates the channel.\nfunc (m *Manager) initChannel(name, displayName string) {\n\tf, ok := getFactory(name)\n\tif !ok {\n\t\tlogger.WarnCF(\"channels\", \"Factory not registered\", map[string]any{\n\t\t\t\"channel\": displayName,\n\t\t})\n\t\treturn\n\t}\n\tlogger.DebugCF(\"channels\", \"Attempting to initialize channel\", map[string]any{\n\t\t\"channel\": displayName,\n\t})\n\tch, err := f(m.config, m.bus)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"channels\", \"Failed to initialize channel\", map[string]any{\n\t\t\t\"channel\": displayName,\n\t\t\t\"error\":   err.Error(),\n\t\t})\n\t} else {\n\t\t// Inject MediaStore if channel supports it\n\t\tif m.mediaStore != nil {\n\t\t\tif setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {\n\t\t\t\tsetter.SetMediaStore(m.mediaStore)\n\t\t\t}\n\t\t}\n\t\t// Inject PlaceholderRecorder if channel supports it\n\t\tif setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok {\n\t\t\tsetter.SetPlaceholderRecorder(m)\n\t\t}\n\t\t// Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction\n\t\tif setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {\n\t\t\tsetter.SetOwner(ch)\n\t\t}\n\t\tm.channels[name] = ch\n\t\tlogger.InfoCF(\"channels\", \"Channel enabled successfully\", map[string]any{\n\t\t\t\"channel\": displayName,\n\t\t})\n\t}\n}\n\nfunc (m *Manager) initChannels(channels *config.ChannelsConfig) error {\n\tlogger.InfoC(\"channels\", \"Initializing channel manager\")\n\n\tif channels.Telegram.Enabled && channels.Telegram.Token != \"\" {\n\t\tm.initChannel(\"telegram\", \"Telegram\")\n\t}\n\n\tif channels.WhatsApp.Enabled {\n\t\twaCfg := channels.WhatsApp\n\t\tif waCfg.UseNative {\n\t\t\tm.initChannel(\"whatsapp_native\", \"WhatsApp Native\")\n\t\t} else if waCfg.BridgeURL != \"\" {\n\t\t\tm.initChannel(\"whatsapp\", \"WhatsApp\")\n\t\t}\n\t}\n\n\tif channels.Feishu.Enabled {\n\t\tm.initChannel(\"feishu\", \"Feishu\")\n\t}\n\n\tif channels.Discord.Enabled && channels.Discord.Token != \"\" {\n\t\tm.initChannel(\"discord\", \"Discord\")\n\t}\n\n\tif channels.MaixCam.Enabled {\n\t\tm.initChannel(\"maixcam\", \"MaixCam\")\n\t}\n\n\tif channels.QQ.Enabled {\n\t\tm.initChannel(\"qq\", \"QQ\")\n\t}\n\n\tif channels.DingTalk.Enabled && channels.DingTalk.ClientID != \"\" {\n\t\tm.initChannel(\"dingtalk\", \"DingTalk\")\n\t}\n\n\tif channels.Slack.Enabled && channels.Slack.BotToken != \"\" {\n\t\tm.initChannel(\"slack\", \"Slack\")\n\t}\n\n\tif channels.Matrix.Enabled &&\n\t\tm.config.Channels.Matrix.Homeserver != \"\" &&\n\t\tm.config.Channels.Matrix.UserID != \"\" &&\n\t\tm.config.Channels.Matrix.AccessToken != \"\" {\n\t\tm.initChannel(\"matrix\", \"Matrix\")\n\t}\n\n\tif channels.LINE.Enabled && channels.LINE.ChannelAccessToken != \"\" {\n\t\tm.initChannel(\"line\", \"LINE\")\n\t}\n\n\tif channels.OneBot.Enabled && channels.OneBot.WSUrl != \"\" {\n\t\tm.initChannel(\"onebot\", \"OneBot\")\n\t}\n\n\tif channels.WeCom.Enabled && channels.WeCom.Token != \"\" {\n\t\tm.initChannel(\"wecom\", \"WeCom\")\n\t}\n\n\tif m.config.Channels.WeComAIBot.Enabled &&\n\t\t((m.config.Channels.WeComAIBot.BotID != \"\" && m.config.Channels.WeComAIBot.Secret != \"\") ||\n\t\t\tm.config.Channels.WeComAIBot.Token != \"\") {\n\t\tm.initChannel(\"wecom_aibot\", \"WeCom AI Bot\")\n\t}\n\n\tif channels.WeComApp.Enabled && channels.WeComApp.CorpID != \"\" {\n\t\tm.initChannel(\"wecom_app\", \"WeCom App\")\n\t}\n\n\tif channels.Pico.Enabled && channels.Pico.Token != \"\" {\n\t\tm.initChannel(\"pico\", \"Pico\")\n\t}\n\n\tif channels.IRC.Enabled && channels.IRC.Server != \"\" {\n\t\tm.initChannel(\"irc\", \"IRC\")\n\t}\n\n\tlogger.InfoCF(\"channels\", \"Channel initialization completed\", map[string]any{\n\t\t\"enabled_channels\": len(m.channels),\n\t})\n\n\treturn nil\n}\n\n// SetupHTTPServer creates a shared HTTP server with the given listen address.\n// It registers health endpoints from the health server and discovers channels\n// that implement WebhookHandler and/or HealthChecker to register their handlers.\nfunc (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {\n\tm.mux = http.NewServeMux()\n\n\t// Register health endpoints\n\tif healthServer != nil {\n\t\thealthServer.RegisterOnMux(m.mux)\n\t}\n\n\t// Discover and register webhook handlers and health checkers\n\tfor name, ch := range m.channels {\n\t\tif wh, ok := ch.(WebhookHandler); ok {\n\t\t\tm.mux.Handle(wh.WebhookPath(), wh)\n\t\t\tlogger.InfoCF(\"channels\", \"Webhook handler registered\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"path\":    wh.WebhookPath(),\n\t\t\t})\n\t\t}\n\t\tif hc, ok := ch.(HealthChecker); ok {\n\t\t\tm.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)\n\t\t\tlogger.InfoCF(\"channels\", \"Health endpoint registered\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"path\":    hc.HealthPath(),\n\t\t\t})\n\t\t}\n\t}\n\n\tm.httpServer = &http.Server{\n\t\tAddr:         addr,\n\t\tHandler:      m.mux,\n\t\tReadTimeout:  30 * time.Second,\n\t\tWriteTimeout: 30 * time.Second,\n\t}\n}\n\nfunc (m *Manager) StartAll(ctx context.Context) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif len(m.channels) == 0 {\n\t\tlogger.WarnC(\"channels\", \"No channels enabled\")\n\t}\n\n\tlogger.InfoC(\"channels\", \"Starting all channels\")\n\n\tdispatchCtx, cancel := context.WithCancel(ctx)\n\tm.dispatchTask = &asyncTask{cancel: cancel}\n\n\tfor name, channel := range m.channels {\n\t\tlogger.InfoCF(\"channels\", \"Starting channel\", map[string]any{\n\t\t\t\"channel\": name,\n\t\t})\n\t\tif err := channel.Start(ctx); err != nil {\n\t\t\tlogger.ErrorCF(\"channels\", \"Failed to start channel\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\t// Lazily create worker only after channel starts successfully\n\t\tw := newChannelWorker(name, channel)\n\t\tm.workers[name] = w\n\t\tgo m.runWorker(dispatchCtx, name, w)\n\t\tgo m.runMediaWorker(dispatchCtx, name, w)\n\t}\n\n\t// Start the dispatcher that reads from the bus and routes to workers\n\tgo m.dispatchOutbound(dispatchCtx)\n\tgo m.dispatchOutboundMedia(dispatchCtx)\n\n\t// Start the TTL janitor that cleans up stale typing/placeholder entries\n\tgo m.runTTLJanitor(dispatchCtx)\n\n\t// Start shared HTTP server if configured\n\tif m.httpServer != nil {\n\t\tgo func() {\n\t\t\tlogger.InfoCF(\"channels\", \"Shared HTTP server listening\", map[string]any{\n\t\t\t\t\"addr\": m.httpServer.Addr,\n\t\t\t})\n\t\t\tif err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\t\tlogger.FatalCF(\"channels\", \"Shared HTTP server error\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t}\n\t\t}()\n\t}\n\n\tlogger.InfoC(\"channels\", \"All channels started\")\n\treturn nil\n}\n\nfunc (m *Manager) StopAll(ctx context.Context) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tlogger.InfoC(\"channels\", \"Stopping all channels\")\n\n\t// Shutdown shared HTTP server first\n\tif m.httpServer != nil {\n\t\tshutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\t\tdefer cancel()\n\t\tif err := m.httpServer.Shutdown(shutdownCtx); err != nil {\n\t\t\tlogger.ErrorCF(\"channels\", \"Shared HTTP server shutdown error\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t\tm.httpServer = nil\n\t}\n\n\t// Cancel dispatcher\n\tif m.dispatchTask != nil {\n\t\tm.dispatchTask.cancel()\n\t\tm.dispatchTask = nil\n\t}\n\n\t// Close all worker queues and wait for them to drain\n\tfor _, w := range m.workers {\n\t\tif w != nil {\n\t\t\tclose(w.queue)\n\t\t}\n\t}\n\tfor _, w := range m.workers {\n\t\tif w != nil {\n\t\t\t<-w.done\n\t\t}\n\t}\n\t// Close all media worker queues and wait for them to drain\n\tfor _, w := range m.workers {\n\t\tif w != nil {\n\t\t\tclose(w.mediaQueue)\n\t\t}\n\t}\n\tfor _, w := range m.workers {\n\t\tif w != nil {\n\t\t\t<-w.mediaDone\n\t\t}\n\t}\n\n\t// Stop all channels\n\tfor name, channel := range m.channels {\n\t\tlogger.InfoCF(\"channels\", \"Stopping channel\", map[string]any{\n\t\t\t\"channel\": name,\n\t\t})\n\t\tif err := channel.Stop(ctx); err != nil {\n\t\t\tlogger.ErrorCF(\"channels\", \"Error stopping channel\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\tlogger.InfoC(\"channels\", \"All channels stopped\")\n\treturn nil\n}\n\n// newChannelWorker creates a channelWorker with a rate limiter configured\n// for the given channel name.\nfunc newChannelWorker(name string, ch Channel) *channelWorker {\n\trateVal := float64(defaultRateLimit)\n\tif r, ok := channelRateConfig[name]; ok {\n\t\trateVal = r\n\t}\n\tburst := int(math.Max(1, math.Ceil(rateVal/2)))\n\n\treturn &channelWorker{\n\t\tch:         ch,\n\t\tqueue:      make(chan bus.OutboundMessage, defaultChannelQueueSize),\n\t\tmediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize),\n\t\tdone:       make(chan struct{}),\n\t\tmediaDone:  make(chan struct{}),\n\t\tlimiter:    rate.NewLimiter(rate.Limit(rateVal), burst),\n\t}\n}\n\n// runWorker processes outbound messages for a single channel, splitting\n// messages that exceed the channel's maximum message length.\nfunc (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {\n\tdefer close(w.done)\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-w.queue:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tmaxLen := 0\n\t\t\tif mlp, ok := w.ch.(MessageLengthProvider); ok {\n\t\t\t\tmaxLen = mlp.MaxMessageLength()\n\t\t\t}\n\t\t\tif maxLen > 0 && len([]rune(msg.Content)) > maxLen {\n\t\t\t\tchunks := SplitMessage(msg.Content, maxLen)\n\t\t\t\tfor _, chunk := range chunks {\n\t\t\t\t\tchunkMsg := msg\n\t\t\t\t\tchunkMsg.Content = chunk\n\t\t\t\t\tm.sendWithRetry(ctx, name, w, chunkMsg)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tm.sendWithRetry(ctx, name, w, msg)\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// sendWithRetry sends a message through the channel with rate limiting and\n// retry logic. It classifies errors to determine the retry strategy:\n//   - ErrNotRunning / ErrSendFailed: permanent, no retry\n//   - ErrRateLimit: fixed delay retry\n//   - ErrTemporary / unknown: exponential backoff retry\nfunc (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {\n\t// Rate limit: wait for token\n\tif err := w.limiter.Wait(ctx); err != nil {\n\t\t// ctx canceled, shutting down\n\t\treturn\n\t}\n\n\t// Pre-send: stop typing and try to edit placeholder\n\tif m.preSend(ctx, name, msg, w.ch) {\n\t\treturn // placeholder was edited successfully, skip Send\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt <= maxRetries; attempt++ {\n\t\tlastErr = w.ch.Send(ctx, msg)\n\t\tif lastErr == nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Permanent failures — don't retry\n\t\tif errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {\n\t\t\tbreak\n\t\t}\n\n\t\t// Last attempt exhausted — don't sleep\n\t\tif attempt == maxRetries {\n\t\t\tbreak\n\t\t}\n\n\t\t// Rate limit error — fixed delay\n\t\tif errors.Is(lastErr, ErrRateLimit) {\n\t\t\tselect {\n\t\t\tcase <-time.After(rateLimitDelay):\n\t\t\t\tcontinue\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// ErrTemporary or unknown error — exponential backoff\n\t\tbackoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)\n\t\tselect {\n\t\tcase <-time.After(backoff):\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n\n\t// All retries exhausted or permanent failure\n\tlogger.ErrorCF(\"channels\", \"Send failed\", map[string]any{\n\t\t\"channel\": name,\n\t\t\"chat_id\": msg.ChatID,\n\t\t\"error\":   lastErr.Error(),\n\t\t\"retries\": maxRetries,\n\t})\n}\n\nfunc dispatchLoop[M any](\n\tctx context.Context,\n\tm *Manager,\n\tch <-chan M,\n\tgetChannel func(M) string,\n\tenqueue func(context.Context, *channelWorker, M) bool,\n\tstartMsg, stopMsg, unknownMsg, noWorkerMsg string,\n) {\n\tlogger.InfoC(\"channels\", startMsg)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlogger.InfoC(\"channels\", stopMsg)\n\t\t\treturn\n\n\t\tcase msg, ok := <-ch:\n\t\t\tif !ok {\n\t\t\t\tlogger.InfoC(\"channels\", stopMsg)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tchannel := getChannel(msg)\n\n\t\t\t// Silently skip internal channels\n\t\t\tif constants.IsInternalChannel(channel) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tm.mu.RLock()\n\t\t\t_, exists := m.channels[channel]\n\t\t\tw, wExists := m.workers[channel]\n\t\t\tm.mu.RUnlock()\n\n\t\t\tif !exists {\n\t\t\t\tlogger.WarnCF(\"channels\", unknownMsg, map[string]any{\"channel\": channel})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif wExists && w != nil {\n\t\t\t\tif !enqueue(ctx, w, msg) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else if exists {\n\t\t\t\tlogger.WarnCF(\"channels\", noWorkerMsg, map[string]any{\"channel\": channel})\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (m *Manager) dispatchOutbound(ctx context.Context) {\n\tdispatchLoop(\n\t\tctx, m,\n\t\tm.bus.OutboundChan(),\n\t\tfunc(msg bus.OutboundMessage) string { return msg.Channel },\n\t\tfunc(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {\n\t\t\tselect {\n\t\t\tcase w.queue <- msg:\n\t\t\t\treturn true\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\t}\n\t\t},\n\t\t\"Outbound dispatcher started\",\n\t\t\"Outbound dispatcher stopped\",\n\t\t\"Unknown channel for outbound message\",\n\t\t\"Channel has no active worker, skipping message\",\n\t)\n}\n\nfunc (m *Manager) dispatchOutboundMedia(ctx context.Context) {\n\tdispatchLoop(\n\t\tctx, m,\n\t\tm.bus.OutboundMediaChan(),\n\t\tfunc(msg bus.OutboundMediaMessage) string { return msg.Channel },\n\t\tfunc(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {\n\t\t\tselect {\n\t\t\tcase w.mediaQueue <- msg:\n\t\t\t\treturn true\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn false\n\t\t\t}\n\t\t},\n\t\t\"Outbound media dispatcher started\",\n\t\t\"Outbound media dispatcher stopped\",\n\t\t\"Unknown channel for outbound media message\",\n\t\t\"Channel has no active worker, skipping media message\",\n\t)\n}\n\n// runMediaWorker processes outbound media messages for a single channel.\nfunc (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) {\n\tdefer close(w.mediaDone)\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-w.mediaQueue:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tm.sendMediaWithRetry(ctx, name, w, msg)\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// sendMediaWithRetry sends a media message through the channel with rate limiting and\n// retry logic. If the channel does not implement MediaSender, it silently skips.\nfunc (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) {\n\tms, ok := w.ch.(MediaSender)\n\tif !ok {\n\t\tlogger.DebugCF(\"channels\", \"Channel does not support MediaSender, skipping media\", map[string]any{\n\t\t\t\"channel\": name,\n\t\t})\n\t\treturn\n\t}\n\n\t// Rate limit: wait for token\n\tif err := w.limiter.Wait(ctx); err != nil {\n\t\treturn\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt <= maxRetries; attempt++ {\n\t\tlastErr = ms.SendMedia(ctx, msg)\n\t\tif lastErr == nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Permanent failures — don't retry\n\t\tif errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {\n\t\t\tbreak\n\t\t}\n\n\t\t// Last attempt exhausted — don't sleep\n\t\tif attempt == maxRetries {\n\t\t\tbreak\n\t\t}\n\n\t\t// Rate limit error — fixed delay\n\t\tif errors.Is(lastErr, ErrRateLimit) {\n\t\t\tselect {\n\t\t\tcase <-time.After(rateLimitDelay):\n\t\t\t\tcontinue\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// ErrTemporary or unknown error — exponential backoff\n\t\tbackoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)\n\t\tselect {\n\t\tcase <-time.After(backoff):\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n\n\t// All retries exhausted or permanent failure\n\tlogger.ErrorCF(\"channels\", \"SendMedia failed\", map[string]any{\n\t\t\"channel\": name,\n\t\t\"chat_id\": msg.ChatID,\n\t\t\"error\":   lastErr.Error(),\n\t\t\"retries\": maxRetries,\n\t})\n}\n\n// runTTLJanitor periodically scans the typingStops and placeholders maps\n// and evicts entries that have exceeded their TTL. This prevents memory\n// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).\nfunc (m *Manager) runTTLJanitor(ctx context.Context) {\n\tticker := time.NewTicker(janitorInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase now := <-ticker.C:\n\t\t\tm.typingStops.Range(func(key, value any) bool {\n\t\t\t\tif entry, ok := value.(typingEntry); ok {\n\t\t\t\t\tif now.Sub(entry.createdAt) > typingStopTTL {\n\t\t\t\t\t\tif _, loaded := m.typingStops.LoadAndDelete(key); loaded {\n\t\t\t\t\t\t\tentry.stop() // idempotent, safe\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tm.reactionUndos.Range(func(key, value any) bool {\n\t\t\t\tif entry, ok := value.(reactionEntry); ok {\n\t\t\t\t\tif now.Sub(entry.createdAt) > typingStopTTL {\n\t\t\t\t\t\tif _, loaded := m.reactionUndos.LoadAndDelete(key); loaded {\n\t\t\t\t\t\t\tentry.undo() // idempotent, safe\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t\tm.placeholders.Range(func(key, value any) bool {\n\t\t\t\tif entry, ok := value.(placeholderEntry); ok {\n\t\t\t\t\tif now.Sub(entry.createdAt) > placeholderTTL {\n\t\t\t\t\t\tm.placeholders.Delete(key)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc (m *Manager) GetChannel(name string) (Channel, bool) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tchannel, ok := m.channels[name]\n\treturn channel, ok\n}\n\nfunc (m *Manager) GetStatus() map[string]any {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tstatus := make(map[string]any)\n\tfor name, channel := range m.channels {\n\t\tstatus[name] = map[string]any{\n\t\t\t\"enabled\": true,\n\t\t\t\"running\": channel.IsRunning(),\n\t\t}\n\t}\n\treturn status\n}\n\nfunc (m *Manager) GetEnabledChannels() []string {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnames := make([]string, 0, len(m.channels))\n\tfor name := range m.channels {\n\t\tnames = append(names, name)\n\t}\n\treturn names\n}\n\n// Reload updates the config reference without restarting channels.\n// This is used when channel config hasn't changed but other parts of the config have.\nfunc (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tlist := toChannelHashes(cfg)\n\tadded, removed := compareChannels(m.channelHashes, list)\n\tfor _, name := range removed {\n\t\t// Stop all channels\n\t\tchannel := m.channels[name]\n\t\tlogger.InfoCF(\"channels\", \"Stopping channel\", map[string]any{\n\t\t\t\"channel\": name,\n\t\t})\n\t\tif err := channel.Stop(ctx); err != nil {\n\t\t\tlogger.ErrorCF(\"channels\", \"Error stopping channel\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t}\n\t\tgo func() {\n\t\t\tm.UnregisterChannel(name)\n\t\t}()\n\t}\n\tdispatchCtx, cancel := context.WithCancel(ctx)\n\tm.dispatchTask = &asyncTask{cancel: cancel}\n\tcc, err := toChannelConfig(cfg, added)\n\tif err != nil {\n\t\tlogger.ErrorC(\"channels\", fmt.Sprintf(\"toChannelConfig error: %v\", err))\n\t\treturn err\n\t}\n\terr = m.initChannels(cc)\n\tif err != nil {\n\t\tlogger.ErrorC(\"channels\", fmt.Sprintf(\"initChannels error: %v\", err))\n\t\treturn err\n\t}\n\tfor _, name := range added {\n\t\tchannel := m.channels[name]\n\t\tlogger.InfoCF(\"channels\", \"Starting channel\", map[string]any{\n\t\t\t\"channel\": name,\n\t\t})\n\t\tif err := channel.Start(ctx); err != nil {\n\t\t\tlogger.ErrorCF(\"channels\", \"Failed to start channel\", map[string]any{\n\t\t\t\t\"channel\": name,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\t// Lazily create worker only after channel starts successfully\n\t\tw := newChannelWorker(name, channel)\n\t\tm.workers[name] = w\n\t\tgo m.runWorker(dispatchCtx, name, w)\n\t\tgo m.runMediaWorker(dispatchCtx, name, w)\n\t\tgo func() {\n\t\t\tm.RegisterChannel(name, channel)\n\t\t}()\n\t}\n\n\tm.config = cfg\n\tm.channelHashes = toChannelHashes(cfg)\n\treturn nil\n}\n\nfunc (m *Manager) RegisterChannel(name string, channel Channel) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.channels[name] = channel\n}\n\nfunc (m *Manager) UnregisterChannel(name string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tif w, ok := m.workers[name]; ok && w != nil {\n\t\tclose(w.queue)\n\t\t<-w.done\n\t\tclose(w.mediaQueue)\n\t\t<-w.mediaDone\n\t}\n\tdelete(m.workers, name)\n\tdelete(m.channels, name)\n}\n\n// SendMessage sends an outbound message synchronously through the channel\n// worker's rate limiter and retry logic. It blocks until the message is\n// delivered (or all retries are exhausted), which preserves ordering when\n// a subsequent operation depends on the message having been sent.\nfunc (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {\n\tm.mu.RLock()\n\t_, exists := m.channels[msg.Channel]\n\tw, wExists := m.workers[msg.Channel]\n\tm.mu.RUnlock()\n\n\tif !exists {\n\t\treturn fmt.Errorf(\"channel %s not found\", msg.Channel)\n\t}\n\tif !wExists || w == nil {\n\t\treturn fmt.Errorf(\"channel %s has no active worker\", msg.Channel)\n\t}\n\n\tmaxLen := 0\n\tif mlp, ok := w.ch.(MessageLengthProvider); ok {\n\t\tmaxLen = mlp.MaxMessageLength()\n\t}\n\tif maxLen > 0 && len([]rune(msg.Content)) > maxLen {\n\t\tfor _, chunk := range SplitMessage(msg.Content, maxLen) {\n\t\t\tchunkMsg := msg\n\t\t\tchunkMsg.Content = chunk\n\t\t\tm.sendWithRetry(ctx, msg.Channel, w, chunkMsg)\n\t\t}\n\t} else {\n\t\tm.sendWithRetry(ctx, msg.Channel, w, msg)\n\t}\n\treturn nil\n}\n\nfunc (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {\n\tm.mu.RLock()\n\t_, exists := m.channels[channelName]\n\tw, wExists := m.workers[channelName]\n\tm.mu.RUnlock()\n\n\tif !exists {\n\t\treturn fmt.Errorf(\"channel %s not found\", channelName)\n\t}\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel: channelName,\n\t\tChatID:  chatID,\n\t\tContent: content,\n\t}\n\n\tif wExists && w != nil {\n\t\tselect {\n\t\tcase w.queue <- msg:\n\t\t\treturn nil\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\t}\n\t}\n\n\t// Fallback: direct send (should not happen)\n\tchannel, _ := m.channels[channelName]\n\treturn channel.Send(ctx, msg)\n}\n"
  },
  {
    "path": "pkg/channels/manager_channel.go",
    "content": "package channels\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nfunc toChannelHashes(cfg *config.Config) map[string]string {\n\tresult := make(map[string]string)\n\tch := cfg.Channels\n\t// should not be error\n\tmarshal, _ := json.Marshal(ch)\n\tvar channelConfig map[string]map[string]any\n\t_ = json.Unmarshal(marshal, &channelConfig)\n\n\tfor key, value := range channelConfig {\n\t\tif !value[\"enabled\"].(bool) {\n\t\t\tcontinue\n\t\t}\n\t\tvalueBytes, _ := json.Marshal(value)\n\t\thash := md5.Sum(valueBytes)\n\t\tresult[key] = hex.EncodeToString(hash[:])\n\t}\n\n\treturn result\n}\n\nfunc compareChannels(old, news map[string]string) (added, removed []string) {\n\tfor key, newHash := range news {\n\t\tif oldHash, ok := old[key]; ok {\n\t\t\tif newHash != oldHash {\n\t\t\t\tremoved = append(removed, key)\n\t\t\t\tadded = append(added, key)\n\t\t\t}\n\t\t} else {\n\t\t\tadded = append(added, key)\n\t\t}\n\t}\n\tfor key := range old {\n\t\tif _, ok := news[key]; !ok {\n\t\t\tremoved = append(removed, key)\n\t\t}\n\t}\n\treturn added, removed\n}\n\nfunc toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) {\n\tresult := &config.ChannelsConfig{}\n\tch := cfg.Channels\n\t// should not be error\n\tmarshal, _ := json.Marshal(ch)\n\tvar channelConfig map[string]map[string]any\n\t_ = json.Unmarshal(marshal, &channelConfig)\n\ttemp := make(map[string]map[string]any, 0)\n\n\tfor key, value := range channelConfig {\n\t\tfound := false\n\t\tfor _, s := range list {\n\t\t\tif key == s {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found || !value[\"enabled\"].(bool) {\n\t\t\tcontinue\n\t\t}\n\t\ttemp[key] = value\n\t}\n\n\tmarshal, err := json.Marshal(temp)\n\tif err != nil {\n\t\tlogger.Errorf(\"marshal error: %v\", err)\n\t\treturn nil, err\n\t}\n\terr = json.Unmarshal(marshal, result)\n\tif err != nil {\n\t\tlogger.Errorf(\"unmarshal error: %v\", err)\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "pkg/channels/manager_channel_test.go",
    "content": "package channels\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nfunc TestToChannelHashes(t *testing.T) {\n\tlogger.SetLevel(logger.DEBUG)\n\tcfg := config.DefaultConfig()\n\tresults := toChannelHashes(cfg)\n\tassert.Equal(t, 0, len(results))\n\tlogger.Debugf(\"results: %v\", results)\n\tcfg2 := config.DefaultConfig()\n\tcfg2.Channels.DingTalk.Enabled = true\n\tresults2 := toChannelHashes(cfg2)\n\tassert.Equal(t, 1, len(results2))\n\tlogger.Debugf(\"results2: %v\", results2)\n\tadded, removed := compareChannels(results, results2)\n\tassert.EqualValues(t, []string{\"dingtalk\"}, added)\n\tassert.EqualValues(t, []string(nil), removed)\n\tcfg3 := config.DefaultConfig()\n\tcfg3.Channels.Telegram.Enabled = true\n\tresults3 := toChannelHashes(cfg3)\n\tassert.Equal(t, 1, len(results3))\n\tlogger.Debugf(\"results3: %v\", results3)\n\tadded, removed = compareChannels(results2, results3)\n\tassert.EqualValues(t, []string{\"dingtalk\"}, removed)\n\tassert.EqualValues(t, []string{\"telegram\"}, added)\n\tcfg3.Channels.Telegram.Token = \"114314\"\n\tresults4 := toChannelHashes(cfg3)\n\tassert.Equal(t, 1, len(results4))\n\tlogger.Debugf(\"results4: %v\", results4)\n\tadded, removed = compareChannels(results3, results4)\n\tassert.EqualValues(t, []string{\"telegram\"}, removed)\n\tassert.EqualValues(t, []string{\"telegram\"}, added)\n\tcc, err := toChannelConfig(cfg3, added)\n\tassert.NoError(t, err)\n\tlogger.Debugf(\"cc: %#v\", cc.Telegram)\n\tassert.Equal(t, \"114314\", cc.Telegram.Token)\n\tassert.Equal(t, true, cc.Telegram.Enabled)\n\tcc, err = toChannelConfig(cfg2, added)\n\tassert.NoError(t, err)\n\tlogger.Debugf(\"cc: %#v\", cc.Telegram)\n\tassert.Equal(t, \"\", cc.Telegram.Token)\n\tassert.Equal(t, false, cc.Telegram.Enabled)\n}\n"
  },
  {
    "path": "pkg/channels/manager_test.go",
    "content": "package channels\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"golang.org/x/time/rate\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n)\n\n// mockChannel is a test double that delegates Send to a configurable function.\ntype mockChannel struct {\n\tBaseChannel\n\tsendFn            func(ctx context.Context, msg bus.OutboundMessage) error\n\tsentMessages      []bus.OutboundMessage\n\tplaceholdersSent  int\n\teditedMessages    int\n\tlastPlaceholderID string\n}\n\nfunc (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tm.sentMessages = append(m.sentMessages, msg)\n\treturn m.sendFn(ctx, msg)\n}\n\nfunc (m *mockChannel) Start(ctx context.Context) error { return nil }\nfunc (m *mockChannel) Stop(ctx context.Context) error  { return nil }\n\nfunc (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tm.placeholdersSent++\n\tm.lastPlaceholderID = \"mock-ph-123\"\n\treturn m.lastPlaceholderID, nil\n}\n\nfunc (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n\tm.editedMessages++\n\treturn nil\n}\n\n// newTestManager creates a minimal Manager suitable for unit tests.\nfunc newTestManager() *Manager {\n\treturn &Manager{\n\t\tchannels: make(map[string]Channel),\n\t\tworkers:  make(map[string]*channelWorker),\n\t}\n}\n\nfunc TestSendWithRetry_Success(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\treturn nil\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\tif callCount != 1 {\n\t\tt.Fatalf(\"expected 1 Send call, got %d\", callCount)\n\t}\n}\n\nfunc TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\tif callCount <= 2 {\n\t\t\t\treturn fmt.Errorf(\"network error: %w\", ErrTemporary)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\tif callCount != 3 {\n\t\tt.Fatalf(\"expected 3 Send calls (2 failures + 1 success), got %d\", callCount)\n\t}\n}\n\nfunc TestSendWithRetry_PermanentFailure(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\treturn fmt.Errorf(\"bad chat ID: %w\", ErrSendFailed)\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\tif callCount != 1 {\n\t\tt.Fatalf(\"expected 1 Send call (no retry for permanent failure), got %d\", callCount)\n\t}\n}\n\nfunc TestSendWithRetry_NotRunning(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\treturn ErrNotRunning\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\tif callCount != 1 {\n\t\tt.Fatalf(\"expected 1 Send call (no retry for ErrNotRunning), got %d\", callCount)\n\t}\n}\n\nfunc TestSendWithRetry_RateLimitRetry(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\tif callCount == 1 {\n\t\t\t\treturn fmt.Errorf(\"429: %w\", ErrRateLimit)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tstart := time.Now()\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\telapsed := time.Since(start)\n\n\tif callCount != 2 {\n\t\tt.Fatalf(\"expected 2 Send calls (1 rate limit + 1 success), got %d\", callCount)\n\t}\n\t// Should have waited at least rateLimitDelay (1s) but allow some slack\n\tif elapsed < 900*time.Millisecond {\n\t\tt.Fatalf(\"expected at least ~1s delay for rate limit retry, got %v\", elapsed)\n\t}\n}\n\nfunc TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\treturn fmt.Errorf(\"timeout: %w\", ErrTemporary)\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\texpected := maxRetries + 1 // initial attempt + maxRetries retries\n\tif callCount != expected {\n\t\tt.Fatalf(\"expected %d Send calls, got %d\", expected, callCount)\n\t}\n}\n\nfunc TestSendWithRetry_UnknownError(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\tif callCount == 1 {\n\t\t\t\treturn errors.New(\"random unexpected error\")\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\tif callCount != 2 {\n\t\tt.Fatalf(\"expected 2 Send calls (unknown error treated as temporary), got %d\", callCount)\n\t}\n}\n\nfunc TestSendWithRetry_ContextCancelled(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\treturn fmt.Errorf(\"timeout: %w\", ErrTemporary)\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\t// Cancel context after first Send attempt returns\n\tch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {\n\t\tcallCount++\n\t\tcancel()\n\t\treturn fmt.Errorf(\"timeout: %w\", ErrTemporary)\n\t}\n\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\n\t// Should have called Send once, then noticed ctx canceled during backoff\n\tif callCount != 1 {\n\t\tt.Fatalf(\"expected 1 Send call before context cancellation, got %d\", callCount)\n\t}\n}\n\nfunc TestWorkerRateLimiter(t *testing.T) {\n\tm := newTestManager()\n\n\tvar mu sync.Mutex\n\tvar sendTimes []time.Time\n\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tmu.Lock()\n\t\t\tsendTimes = append(sendTimes, time.Now())\n\t\t\tmu.Unlock()\n\t\t\treturn nil\n\t\t},\n\t}\n\n\t// Create a worker with a low rate: 2 msg/s, burst 1\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tqueue:   make(chan bus.OutboundMessage, 10),\n\t\tdone:    make(chan struct{}),\n\t\tlimiter: rate.NewLimiter(2, 1),\n\t}\n\n\tctx := t.Context()\n\n\tgo m.runWorker(ctx, \"test\", w)\n\n\t// Enqueue 4 messages\n\tfor i := range 4 {\n\t\tw.queue <- bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: fmt.Sprintf(\"msg%d\", i)}\n\t}\n\n\t// Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)\n\ttime.Sleep(3 * time.Second)\n\n\tmu.Lock()\n\ttimes := make([]time.Time, len(sendTimes))\n\tcopy(times, sendTimes)\n\tmu.Unlock()\n\n\tif len(times) != 4 {\n\t\tt.Fatalf(\"expected 4 sends, got %d\", len(times))\n\t}\n\n\t// Verify rate limiting: total duration should be at least 1s\n\t// (first message immediate, then ~500ms between each subsequent one at 2/s)\n\ttotalDuration := times[len(times)-1].Sub(times[0])\n\tif totalDuration < 1*time.Second {\n\t\tt.Fatalf(\"expected total duration >= 1s for 4 msgs at 2/s rate, got %v\", totalDuration)\n\t}\n}\n\nfunc TestNewChannelWorker_DefaultRate(t *testing.T) {\n\tch := &mockChannel{}\n\tw := newChannelWorker(\"unknown_channel\", ch)\n\n\tif w.limiter == nil {\n\t\tt.Fatal(\"expected limiter to be non-nil\")\n\t}\n\tif w.limiter.Limit() != rate.Limit(defaultRateLimit) {\n\t\tt.Fatalf(\"expected rate limit %v, got %v\", rate.Limit(defaultRateLimit), w.limiter.Limit())\n\t}\n}\n\nfunc TestNewChannelWorker_ConfiguredRate(t *testing.T) {\n\tch := &mockChannel{}\n\n\tfor name, expectedRate := range channelRateConfig {\n\t\tw := newChannelWorker(name, ch)\n\t\tif w.limiter.Limit() != rate.Limit(expectedRate) {\n\t\t\tt.Fatalf(\"channel %s: expected rate %v, got %v\", name, expectedRate, w.limiter.Limit())\n\t\t}\n\t}\n}\n\nfunc TestRunWorker_MessageSplitting(t *testing.T) {\n\tm := newTestManager()\n\n\tvar mu sync.Mutex\n\tvar received []string\n\n\tch := &mockChannelWithLength{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, msg bus.OutboundMessage) error {\n\t\t\t\tmu.Lock()\n\t\t\t\treceived = append(received, msg.Content)\n\t\t\t\tmu.Unlock()\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\tmaxLen: 5,\n\t}\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tqueue:   make(chan bus.OutboundMessage, 10),\n\t\tdone:    make(chan struct{}),\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := t.Context()\n\n\tgo m.runWorker(ctx, \"test\", w)\n\n\t// Send a message that should be split\n\tw.queue <- bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello world\"}\n\n\ttime.Sleep(100 * time.Millisecond)\n\n\tmu.Lock()\n\tcount := len(received)\n\tmu.Unlock()\n\n\tif count < 2 {\n\t\tt.Fatalf(\"expected message to be split into at least 2 chunks, got %d\", count)\n\t}\n}\n\n// mockChannelWithLength implements MessageLengthProvider.\ntype mockChannelWithLength struct {\n\tmockChannel\n\tmaxLen int\n}\n\nfunc (m *mockChannelWithLength) MaxMessageLength() int {\n\treturn m.maxLen\n}\n\nfunc TestSendWithRetry_ExponentialBackoff(t *testing.T) {\n\tm := newTestManager()\n\n\tvar callTimes []time.Time\n\tvar callCount atomic.Int32\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallTimes = append(callTimes, time.Now())\n\t\t\tcallCount.Add(1)\n\t\t\treturn fmt.Errorf(\"timeout: %w\", ErrTemporary)\n\t\t},\n\t}\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tctx := context.Background()\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"1\", Content: \"hello\"}\n\n\tstart := time.Now()\n\tm.sendWithRetry(ctx, \"test\", w, msg)\n\ttotalElapsed := time.Since(start)\n\n\t// With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s\n\t// Total backoff: 500ms + 1s + 2s = 3.5s\n\t// Allow some margin\n\tif totalElapsed < 3*time.Second {\n\t\tt.Fatalf(\"expected total elapsed >= 3s for exponential backoff, got %v\", totalElapsed)\n\t}\n\n\tif int(callCount.Load()) != maxRetries+1 {\n\t\tt.Fatalf(\"expected %d calls, got %d\", maxRetries+1, callCount.Load())\n\t}\n}\n\n// --- Phase 10: preSend orchestration tests ---\n\n// mockMessageEditor is a channel that supports MessageEditor.\ntype mockMessageEditor struct {\n\tmockChannel\n\teditFn func(ctx context.Context, chatID, messageID, content string) error\n}\n\nfunc (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {\n\treturn m.editFn(ctx, chatID, messageID, content)\n}\n\nfunc TestPreSend_PlaceholderEditSuccess(t *testing.T) {\n\tm := newTestManager()\n\tvar sendCalled bool\n\tvar editCalled bool\n\n\tch := &mockMessageEditor{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\t\tsendCalled = true\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\teditFn: func(_ context.Context, chatID, messageID, content string) error {\n\t\t\teditCalled = true\n\t\t\tif chatID != \"123\" {\n\t\t\t\tt.Fatalf(\"expected chatID 123, got %s\", chatID)\n\t\t\t}\n\t\t\tif messageID != \"456\" {\n\t\t\t\tt.Fatalf(\"expected messageID 456, got %s\", messageID)\n\t\t\t}\n\t\t\tif content != \"hello\" {\n\t\t\t\tt.Fatalf(\"expected content 'hello', got %s\", content)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\t// Register placeholder\n\tm.RecordPlaceholder(\"test\", \"123\", \"456\")\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tedited := m.preSend(context.Background(), \"test\", msg, ch)\n\n\tif !edited {\n\t\tt.Fatal(\"expected preSend to return true (placeholder edited)\")\n\t}\n\tif !editCalled {\n\t\tt.Fatal(\"expected EditMessage to be called\")\n\t}\n\tif sendCalled {\n\t\tt.Fatal(\"expected Send to NOT be called when placeholder edited\")\n\t}\n}\n\nfunc TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {\n\tm := newTestManager()\n\n\tch := &mockMessageEditor{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\teditFn: func(_ context.Context, _, _, _ string) error {\n\t\t\treturn fmt.Errorf(\"edit failed\")\n\t\t},\n\t}\n\n\tm.RecordPlaceholder(\"test\", \"123\", \"456\")\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tedited := m.preSend(context.Background(), \"test\", msg, ch)\n\n\tif edited {\n\t\tt.Fatal(\"expected preSend to return false when edit fails\")\n\t}\n}\n\nfunc TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) {\n\tm := newTestManager()\n\tvar stopCalled bool\n\n\tm.RecordTypingStop(\"telegram\", \"chat123\", func() {\n\t\tstopCalled = true\n\t})\n\n\tm.InvokeTypingStop(\"telegram\", \"chat123\")\n\n\tif !stopCalled {\n\t\tt.Fatal(\"expected typing stop func to be called\")\n\t}\n}\n\nfunc TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) {\n\tm := newTestManager()\n\t// Should not panic\n\tm.InvokeTypingStop(\"telegram\", \"nonexistent\")\n}\n\nfunc TestInvokeTypingStop_Idempotent(t *testing.T) {\n\tm := newTestManager()\n\tvar callCount int\n\n\tm.RecordTypingStop(\"telegram\", \"chat123\", func() {\n\t\tcallCount++\n\t})\n\n\tm.InvokeTypingStop(\"telegram\", \"chat123\")\n\tm.InvokeTypingStop(\"telegram\", \"chat123\") // Second call: entry already removed, no-op\n\n\tif callCount != 1 {\n\t\tt.Fatalf(\"expected stop to be called once, got %d\", callCount)\n\t}\n}\n\nfunc TestPreSend_TypingStopCalled(t *testing.T) {\n\tm := newTestManager()\n\tvar stopCalled bool\n\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tm.RecordTypingStop(\"test\", \"123\", func() {\n\t\tstopCalled = true\n\t})\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tm.preSend(context.Background(), \"test\", msg, ch)\n\n\tif !stopCalled {\n\t\tt.Fatal(\"expected typing stop func to be called\")\n\t}\n}\n\nfunc TestPreSend_NoRegisteredState(t *testing.T) {\n\tm := newTestManager()\n\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tedited := m.preSend(context.Background(), \"test\", msg, ch)\n\n\tif edited {\n\t\tt.Fatal(\"expected preSend to return false with no registered state\")\n\t}\n}\n\nfunc TestPreSend_TypingAndPlaceholder(t *testing.T) {\n\tm := newTestManager()\n\tvar stopCalled bool\n\tvar editCalled bool\n\n\tch := &mockMessageEditor{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\teditFn: func(_ context.Context, _, _, _ string) error {\n\t\t\teditCalled = true\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tm.RecordTypingStop(\"test\", \"123\", func() {\n\t\tstopCalled = true\n\t})\n\tm.RecordPlaceholder(\"test\", \"123\", \"456\")\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tedited := m.preSend(context.Background(), \"test\", msg, ch)\n\n\tif !stopCalled {\n\t\tt.Fatal(\"expected typing stop to be called\")\n\t}\n\tif !editCalled {\n\t\tt.Fatal(\"expected EditMessage to be called\")\n\t}\n\tif !edited {\n\t\tt.Fatal(\"expected preSend to return true\")\n\t}\n}\n\nfunc TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {\n\tm := newTestManager()\n\n\tvar wg sync.WaitGroup\n\tfor i := range 100 {\n\t\twg.Add(1)\n\t\tgo func(i int) {\n\t\t\tdefer wg.Done()\n\t\t\tchatID := fmt.Sprintf(\"chat_%d\", i%10)\n\t\t\tm.RecordPlaceholder(\"test\", chatID, fmt.Sprintf(\"msg_%d\", i))\n\t\t}(i)\n\t}\n\twg.Wait()\n}\n\nfunc TestRecordTypingStop_ConcurrentSafe(t *testing.T) {\n\tm := newTestManager()\n\n\tvar wg sync.WaitGroup\n\tfor i := range 100 {\n\t\twg.Add(1)\n\t\tgo func(i int) {\n\t\t\tdefer wg.Done()\n\t\t\tchatID := fmt.Sprintf(\"chat_%d\", i%10)\n\t\t\tm.RecordTypingStop(\"test\", chatID, func() {})\n\t\t}(i)\n\t}\n\twg.Wait()\n}\n\nfunc TestRecordTypingStop_ReplacesExistingStop(t *testing.T) {\n\tm := newTestManager()\n\tvar oldStopCalls int\n\tvar newStopCalls int\n\n\tm.RecordTypingStop(\"test\", \"123\", func() {\n\t\toldStopCalls++\n\t})\n\n\tm.RecordTypingStop(\"test\", \"123\", func() {\n\t\tnewStopCalls++\n\t})\n\n\tif oldStopCalls != 1 {\n\t\tt.Fatalf(\"expected previous typing stop to be called once when replaced, got %d\", oldStopCalls)\n\t}\n\tif newStopCalls != 0 {\n\t\tt.Fatalf(\"expected replacement typing stop to stay active until preSend, got %d calls\", newStopCalls)\n\t}\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tm.preSend(context.Background(), \"test\", msg, &mockChannel{})\n\n\tif newStopCalls != 1 {\n\t\tt.Fatalf(\"expected replacement typing stop to be called by preSend, got %d\", newStopCalls)\n\t}\n\tif oldStopCalls != 1 {\n\t\tt.Fatalf(\"expected previous typing stop to not be called again, got %d\", oldStopCalls)\n\t}\n}\n\nfunc TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {\n\tm := newTestManager()\n\tvar sendCalled bool\n\n\tch := &mockMessageEditor{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\t\tsendCalled = true\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\teditFn: func(_ context.Context, _, _, _ string) error {\n\t\t\treturn nil // edit succeeds\n\t\t},\n\t}\n\n\tm.RecordPlaceholder(\"test\", \"123\", \"456\")\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"123\", Content: \"hello\"}\n\tm.sendWithRetry(context.Background(), \"test\", w, msg)\n\n\tif sendCalled {\n\t\tt.Fatal(\"expected Send to NOT be called when placeholder was edited\")\n\t}\n}\n\n// --- Dispatcher exit tests (Step 1) ---\n\nfunc TestDispatcherExitsOnCancel(t *testing.T) {\n\tmb := bus.NewMessageBus()\n\tdefer mb.Close()\n\n\tm := &Manager{\n\t\tchannels: make(map[string]Channel),\n\t\tworkers:  make(map[string]*channelWorker),\n\t\tbus:      mb,\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\tm.dispatchOutbound(ctx)\n\t\tclose(done)\n\t}()\n\n\t// Cancel context and verify the dispatcher exits quickly\n\tcancel()\n\n\tselect {\n\tcase <-done:\n\t\t// success\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"dispatchOutbound did not exit within 2s after context cancel\")\n\t}\n}\n\nfunc TestDispatcherMediaExitsOnCancel(t *testing.T) {\n\tmb := bus.NewMessageBus()\n\tdefer mb.Close()\n\n\tm := &Manager{\n\t\tchannels: make(map[string]Channel),\n\t\tworkers:  make(map[string]*channelWorker),\n\t\tbus:      mb,\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\tm.dispatchOutboundMedia(ctx)\n\t\tclose(done)\n\t}()\n\n\tcancel()\n\n\tselect {\n\tcase <-done:\n\t\t// success\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"dispatchOutboundMedia did not exit within 2s after context cancel\")\n\t}\n}\n\n// --- TTL Janitor tests (Step 2) ---\n\nfunc TestTypingStopJanitorEviction(t *testing.T) {\n\tm := newTestManager()\n\n\tvar stopCalled atomic.Bool\n\t// Store a typing entry with a creation time far in the past\n\tm.typingStops.Store(\"test:123\", typingEntry{\n\t\tstop:      func() { stopCalled.Store(true) },\n\t\tcreatedAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL\n\t})\n\n\t// Run janitor with a short-lived context\n\tctx, cancel := context.WithCancel(context.Background())\n\n\t// Manually trigger the janitor logic once by simulating a tick\n\tgo func() {\n\t\t// Override janitor to run immediately\n\t\tnow := time.Now()\n\t\tm.typingStops.Range(func(key, value any) bool {\n\t\t\tif entry, ok := value.(typingEntry); ok {\n\t\t\t\tif now.Sub(entry.createdAt) > typingStopTTL {\n\t\t\t\t\tif _, loaded := m.typingStops.LoadAndDelete(key); loaded {\n\t\t\t\t\t\tentry.stop()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t})\n\t\tcancel()\n\t}()\n\n\t<-ctx.Done()\n\n\tif !stopCalled.Load() {\n\t\tt.Fatal(\"expected typing stop function to be called by janitor eviction\")\n\t}\n\n\t// Verify entry was deleted\n\tif _, loaded := m.typingStops.Load(\"test:123\"); loaded {\n\t\tt.Fatal(\"expected typing entry to be deleted after eviction\")\n\t}\n}\n\nfunc TestPlaceholderJanitorEviction(t *testing.T) {\n\tm := newTestManager()\n\n\t// Store a placeholder entry with a creation time far in the past\n\tm.placeholders.Store(\"test:456\", placeholderEntry{\n\t\tid:        \"msg_old\",\n\t\tcreatedAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL\n\t})\n\n\t// Simulate janitor logic\n\tnow := time.Now()\n\tm.placeholders.Range(func(key, value any) bool {\n\t\tif entry, ok := value.(placeholderEntry); ok {\n\t\t\tif now.Sub(entry.createdAt) > placeholderTTL {\n\t\t\t\tm.placeholders.Delete(key)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\t// Verify entry was deleted\n\tif _, loaded := m.placeholders.Load(\"test:456\"); loaded {\n\t\tt.Fatal(\"expected placeholder entry to be deleted after eviction\")\n\t}\n}\n\nfunc TestPreSendStillWorksWithWrappedTypes(t *testing.T) {\n\tm := newTestManager()\n\tvar stopCalled bool\n\tvar editCalled bool\n\n\tch := &mockMessageEditor{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\teditFn: func(_ context.Context, chatID, messageID, content string) error {\n\t\t\teditCalled = true\n\t\t\tif messageID != \"ph_id\" {\n\t\t\t\tt.Fatalf(\"expected messageID ph_id, got %s\", messageID)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\t// Use the new wrapped types via the public API\n\tm.RecordTypingStop(\"test\", \"chat1\", func() {\n\t\tstopCalled = true\n\t})\n\tm.RecordPlaceholder(\"test\", \"chat1\", \"ph_id\")\n\n\tmsg := bus.OutboundMessage{Channel: \"test\", ChatID: \"chat1\", Content: \"response\"}\n\tedited := m.preSend(context.Background(), \"test\", msg, ch)\n\n\tif !stopCalled {\n\t\tt.Fatal(\"expected typing stop to be called via wrapped type\")\n\t}\n\tif !editCalled {\n\t\tt.Fatal(\"expected EditMessage to be called via wrapped type\")\n\t}\n\tif !edited {\n\t\tt.Fatal(\"expected preSend to return true\")\n\t}\n}\n\n// --- Lazy worker creation tests (Step 6) ---\n\nfunc TestLazyWorkerCreation(t *testing.T) {\n\tm := newTestManager()\n\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\n\t// RegisterChannel should NOT create a worker\n\tm.RegisterChannel(\"lazy\", ch)\n\n\tm.mu.RLock()\n\t_, chExists := m.channels[\"lazy\"]\n\t_, wExists := m.workers[\"lazy\"]\n\tm.mu.RUnlock()\n\n\tif !chExists {\n\t\tt.Fatal(\"expected channel to be registered\")\n\t}\n\tif wExists {\n\t\tt.Fatal(\"expected worker to NOT be created by RegisterChannel (lazy creation)\")\n\t}\n}\n\n// --- FastID uniqueness test (Step 5) ---\n\nfunc TestBuildMediaScope_FastIDUniqueness(t *testing.T) {\n\tseen := make(map[string]bool)\n\n\tfor range 1000 {\n\t\tscope := BuildMediaScope(\"test\", \"chat1\", \"\")\n\t\tif seen[scope] {\n\t\t\tt.Fatalf(\"duplicate scope generated: %s\", scope)\n\t\t}\n\t\tseen[scope] = true\n\t}\n\n\t// Verify format: \"channel:chatID:id\"\n\tscope := BuildMediaScope(\"telegram\", \"42\", \"\")\n\tparts := 0\n\tfor _, c := range scope {\n\t\tif c == ':' {\n\t\t\tparts++\n\t\t}\n\t}\n\tif parts != 2 {\n\t\tt.Fatalf(\"expected scope to have 2 colons (channel:chatID:id), got: %s\", scope)\n\t}\n}\n\nfunc TestBuildMediaScope_WithMessageID(t *testing.T) {\n\tscope := BuildMediaScope(\"discord\", \"chat99\", \"msg123\")\n\texpected := \"discord:chat99:msg123\"\n\tif scope != expected {\n\t\tt.Fatalf(\"expected %s, got %s\", expected, scope)\n\t}\n}\n\nfunc TestManager_PlaceholderConsumedByResponse(t *testing.T) {\n\tmgr := &Manager{\n\t\tchannels:     make(map[string]Channel),\n\t\tworkers:      make(map[string]*channelWorker),\n\t\tplaceholders: sync.Map{},\n\t}\n\n\tmockCh := &mockChannel{\n\t\tsendFn: func(ctx context.Context, msg bus.OutboundMessage) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\tworker := newChannelWorker(\"mock\", mockCh)\n\tmgr.channels[\"mock\"] = mockCh\n\tmgr.workers[\"mock\"] = worker\n\n\tctx := context.Background()\n\tkey := \"mock:chat-1\"\n\n\t// Simulate a placeholder recorded by base.go HandleMessage\n\tmgr.RecordPlaceholder(\"mock\", \"chat-1\", \"ph-123\")\n\n\tif _, ok := mgr.placeholders.Load(key); !ok {\n\t\tt.Fatal(\"expected placeholder to be recorded\")\n\t}\n\n\t// Transcription feedback arrives first — it should consume the placeholder\n\t// and be delivered via EditMessage, not Send.\n\tmsgTranscript := bus.OutboundMessage{\n\t\tChannel: \"mock\",\n\t\tChatID:  \"chat-1\",\n\t\tContent: \"Transcript: hello\",\n\t}\n\tmgr.sendWithRetry(ctx, \"mock\", worker, msgTranscript)\n\n\tif mockCh.editedMessages != 1 {\n\t\tt.Errorf(\"expected 1 edited message (placeholder consumed by transcript), got %d\", mockCh.editedMessages)\n\t}\n\tif len(mockCh.sentMessages) != 0 {\n\t\tt.Errorf(\"expected 0 normal messages (transcript used edit), got %d\", len(mockCh.sentMessages))\n\t}\n\n\t// Placeholder should be gone now\n\tif _, ok := mgr.placeholders.Load(key); ok {\n\t\tt.Error(\"expected placeholder to be removed after being consumed\")\n\t}\n\n\t// Final LLM response arrives — no placeholder left, so it goes through Send\n\tmsgFinal := bus.OutboundMessage{\n\t\tChannel: \"mock\",\n\t\tChatID:  \"chat-1\",\n\t\tContent: \"Final Answer\",\n\t}\n\tmgr.sendWithRetry(ctx, \"mock\", worker, msgFinal)\n\n\tif len(mockCh.sentMessages) != 1 {\n\t\tt.Errorf(\"expected 1 normal message sent, got %d\", len(mockCh.sentMessages))\n\t}\n}\n\nfunc TestSendMessage_Synchronous(t *testing.T) {\n\tm := newTestManager()\n\n\tvar received []bus.OutboundMessage\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, msg bus.OutboundMessage) error {\n\t\t\treceived = append(received, msg)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\tm.channels[\"test\"] = ch\n\tm.workers[\"test\"] = w\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel:          \"test\",\n\t\tChatID:           \"123\",\n\t\tContent:          \"hello world\",\n\t\tReplyToMessageID: \"msg-456\",\n\t}\n\n\terr := m.SendMessage(context.Background(), msg)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\n\t// SendMessage is synchronous — message should already be delivered\n\tif len(received) != 1 {\n\t\tt.Fatalf(\"expected 1 message sent, got %d\", len(received))\n\t}\n\tif received[0].ReplyToMessageID != \"msg-456\" {\n\t\tt.Fatalf(\"expected ReplyToMessageID msg-456, got %s\", received[0].ReplyToMessageID)\n\t}\n\tif received[0].Content != \"hello world\" {\n\t\tt.Fatalf(\"expected content 'hello world', got %s\", received[0].Content)\n\t}\n}\n\nfunc TestSendMessage_UnknownChannel(t *testing.T) {\n\tm := newTestManager()\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel: \"nonexistent\",\n\t\tChatID:  \"123\",\n\t\tContent: \"hello\",\n\t}\n\n\terr := m.SendMessage(context.Background(), msg)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for unknown channel\")\n\t}\n}\n\nfunc TestSendMessage_NoWorker(t *testing.T) {\n\tm := newTestManager()\n\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },\n\t}\n\tm.channels[\"test\"] = ch\n\t// No worker registered\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel: \"test\",\n\t\tChatID:  \"123\",\n\t\tContent: \"hello\",\n\t}\n\n\terr := m.SendMessage(context.Background(), msg)\n\tif err == nil {\n\t\tt.Fatal(\"expected error when no worker exists\")\n\t}\n}\n\nfunc TestSendMessage_WithRetry(t *testing.T) {\n\tm := newTestManager()\n\n\tvar callCount int\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, _ bus.OutboundMessage) error {\n\t\t\tcallCount++\n\t\t\tif callCount == 1 {\n\t\t\t\treturn fmt.Errorf(\"transient: %w\", ErrTemporary)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\tm.channels[\"test\"] = ch\n\tm.workers[\"test\"] = w\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel: \"test\",\n\t\tChatID:  \"123\",\n\t\tContent: \"retry me\",\n\t}\n\n\terr := m.SendMessage(context.Background(), msg)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\n\tif callCount != 2 {\n\t\tt.Fatalf(\"expected 2 Send calls (1 failure + 1 success), got %d\", callCount)\n\t}\n}\n\nfunc TestSendMessage_WithSplitting(t *testing.T) {\n\tm := newTestManager()\n\n\tvar received []string\n\tch := &mockChannelWithLength{\n\t\tmockChannel: mockChannel{\n\t\t\tsendFn: func(_ context.Context, msg bus.OutboundMessage) error {\n\t\t\t\treceived = append(received, msg.Content)\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t\tmaxLen: 5,\n\t}\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\tm.channels[\"test\"] = ch\n\tm.workers[\"test\"] = w\n\n\tmsg := bus.OutboundMessage{\n\t\tChannel: \"test\",\n\t\tChatID:  \"123\",\n\t\tContent: \"hello world\",\n\t}\n\n\terr := m.SendMessage(context.Background(), msg)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\n\tif len(received) < 2 {\n\t\tt.Fatalf(\"expected message to be split into at least 2 chunks, got %d\", len(received))\n\t}\n}\n\nfunc TestSendMessage_PreservesOrdering(t *testing.T) {\n\tm := newTestManager()\n\n\tvar order []string\n\tch := &mockChannel{\n\t\tsendFn: func(_ context.Context, msg bus.OutboundMessage) error {\n\t\t\torder = append(order, msg.Content)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tw := &channelWorker{\n\t\tch:      ch,\n\t\tlimiter: rate.NewLimiter(rate.Inf, 1),\n\t}\n\tm.channels[\"test\"] = ch\n\tm.workers[\"test\"] = w\n\n\t// Send two messages sequentially — they must arrive in order\n\t_ = m.SendMessage(context.Background(), bus.OutboundMessage{\n\t\tChannel: \"test\", ChatID: \"1\", Content: \"first\",\n\t})\n\t_ = m.SendMessage(context.Background(), bus.OutboundMessage{\n\t\tChannel: \"test\", ChatID: \"1\", Content: \"second\",\n\t})\n\n\tif len(order) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d\", len(order))\n\t}\n\tif order[0] != \"first\" || order[1] != \"second\" {\n\t\tt.Fatalf(\"expected [first, second], got %v\", order)\n\t}\n}\n\nfunc TestManager_SendPlaceholder(t *testing.T) {\n\tmgr := &Manager{\n\t\tchannels:     make(map[string]Channel),\n\t\tworkers:      make(map[string]*channelWorker),\n\t\tplaceholders: sync.Map{},\n\t}\n\n\tmockCh := &mockChannel{\n\t\tsendFn: func(ctx context.Context, msg bus.OutboundMessage) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\tmgr.channels[\"mock\"] = mockCh\n\n\tctx := context.Background()\n\n\t// SendPlaceholder should send a placeholder and record it\n\tok := mgr.SendPlaceholder(ctx, \"mock\", \"chat-1\")\n\tif !ok {\n\t\tt.Fatal(\"expected SendPlaceholder to succeed\")\n\t}\n\tif mockCh.placeholdersSent != 1 {\n\t\tt.Errorf(\"expected 1 placeholder sent, got %d\", mockCh.placeholdersSent)\n\t}\n\n\tkey := \"mock:chat-1\"\n\tif _, loaded := mgr.placeholders.Load(key); !loaded {\n\t\tt.Error(\"expected placeholder to be recorded in manager\")\n\t}\n\n\t// SendPlaceholder on unknown channel should return false\n\tok = mgr.SendPlaceholder(ctx, \"unknown\", \"chat-1\")\n\tif ok {\n\t\tt.Error(\"expected SendPlaceholder to fail for unknown channel\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/matrix/init.go",
    "content": "package matrix\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"matrix\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewMatrixChannel(cfg.Channels.Matrix, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/matrix/matrix.go",
    "content": "package matrix\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html\"\n\t\"io\"\n\t\"mime\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gomarkdown/markdown\"\n\tmdhtml \"github.com/gomarkdown/markdown/html\"\n\t\"github.com/gomarkdown/markdown/parser\"\n\t\"maunium.net/go/mautrix\"\n\t\"maunium.net/go/mautrix/event\"\n\t\"maunium.net/go/mautrix/id\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nconst (\n\ttypingRefreshInterval      = 20 * time.Second\n\ttypingServerTTL            = 30 * time.Second\n\troomKindCacheTTL           = 5 * time.Minute\n\troomKindCacheCleanupPeriod = 1 * time.Minute\n\troomKindCacheMaxEntries    = 2048\n)\n\nvar matrixMentionHrefRegexp = regexp.MustCompile(`(?i)<a[^>]+href=[\"']([^\"']+)[\"']`)\n\ntype roomKindCacheEntry struct {\n\tisGroup   bool\n\texpiresAt time.Time\n\ttouchedAt time.Time\n}\n\ntype roomKindCache struct {\n\tmu         sync.Mutex\n\tentries    map[string]roomKindCacheEntry\n\tmaxEntries int\n\tttl        time.Duration\n}\n\nfunc newRoomKindCache(maxEntries int, ttl time.Duration) *roomKindCache {\n\tif maxEntries <= 0 {\n\t\tmaxEntries = roomKindCacheMaxEntries\n\t}\n\tif ttl <= 0 {\n\t\tttl = roomKindCacheTTL\n\t}\n\n\treturn &roomKindCache{\n\t\tentries:    make(map[string]roomKindCacheEntry),\n\t\tmaxEntries: maxEntries,\n\t\tttl:        ttl,\n\t}\n}\n\nfunc (c *roomKindCache) get(roomID string, now time.Time) (bool, bool) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tentry, ok := c.entries[roomID]\n\tif !ok {\n\t\treturn false, false\n\t}\n\tif !entry.expiresAt.After(now) {\n\t\tdelete(c.entries, roomID)\n\t\treturn false, false\n\t}\n\n\treturn entry.isGroup, true\n}\n\nfunc (c *roomKindCache) set(roomID string, isGroup bool, now time.Time) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif entry, ok := c.entries[roomID]; ok {\n\t\tentry.isGroup = isGroup\n\t\tentry.expiresAt = now.Add(c.ttl)\n\t\tentry.touchedAt = now\n\t\tc.entries[roomID] = entry\n\t\treturn\n\t}\n\n\tc.cleanupExpiredLocked(now)\n\tfor len(c.entries) >= c.maxEntries {\n\t\tif !c.evictOldestLocked() {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tc.entries[roomID] = roomKindCacheEntry{\n\t\tisGroup:   isGroup,\n\t\texpiresAt: now.Add(c.ttl),\n\t\ttouchedAt: now,\n\t}\n}\n\nfunc (c *roomKindCache) cleanupExpired(now time.Time) int {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\treturn c.cleanupExpiredLocked(now)\n}\n\nfunc (c *roomKindCache) cleanupExpiredLocked(now time.Time) int {\n\tremoved := 0\n\tfor roomID, entry := range c.entries {\n\t\tif !entry.expiresAt.After(now) {\n\t\t\tdelete(c.entries, roomID)\n\t\t\tremoved++\n\t\t}\n\t}\n\treturn removed\n}\n\nfunc (c *roomKindCache) evictOldestLocked() bool {\n\tif len(c.entries) == 0 {\n\t\treturn false\n\t}\n\n\tvar (\n\t\toldestRoomID string\n\t\toldestAt     time.Time\n\t)\n\n\tfor roomID, entry := range c.entries {\n\t\tif oldestRoomID == \"\" || entry.touchedAt.Before(oldestAt) {\n\t\t\toldestRoomID = roomID\n\t\t\toldestAt = entry.touchedAt\n\t\t}\n\t}\n\n\tdelete(c.entries, oldestRoomID)\n\treturn true\n}\n\ntype typingSession struct {\n\tstopCh chan struct{}\n\tonce   sync.Once\n}\n\nfunc newTypingSession() *typingSession {\n\treturn &typingSession{\n\t\tstopCh: make(chan struct{}),\n\t}\n}\n\nfunc (s *typingSession) stop() {\n\ts.once.Do(func() {\n\t\tclose(s.stopCh)\n\t})\n}\n\n// MatrixChannel implements the Channel interface for Matrix.\ntype MatrixChannel struct {\n\t*channels.BaseChannel\n\n\tclient *mautrix.Client\n\tconfig config.MatrixConfig\n\tsyncer *mautrix.DefaultSyncer\n\n\tctx       context.Context\n\tcancel    context.CancelFunc\n\tstartTime time.Time\n\n\ttypingMu       sync.Mutex\n\ttypingSessions map[string]*typingSession // roomID -> session\n\n\troomKindCache     *roomKindCache\n\tlocalpartMentionR *regexp.Regexp\n}\n\nfunc NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {\n\thomeserver := strings.TrimSpace(cfg.Homeserver)\n\tuserID := strings.TrimSpace(cfg.UserID)\n\taccessToken := strings.TrimSpace(cfg.AccessToken)\n\tif homeserver == \"\" {\n\t\treturn nil, fmt.Errorf(\"matrix homeserver is required\")\n\t}\n\tif userID == \"\" {\n\t\treturn nil, fmt.Errorf(\"matrix user_id is required\")\n\t}\n\tif accessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"matrix access_token is required\")\n\t}\n\n\tclient, err := mautrix.NewClient(homeserver, id.UserID(userID), accessToken)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create matrix client: %w\", err)\n\t}\n\tif cfg.DeviceID != \"\" {\n\t\tclient.DeviceID = id.DeviceID(cfg.DeviceID)\n\t}\n\n\tsyncer, ok := client.Syncer.(*mautrix.DefaultSyncer)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"matrix syncer is not *mautrix.DefaultSyncer\")\n\t}\n\n\tbase := channels.NewBaseChannel(\n\t\t\"matrix\",\n\t\tcfg,\n\t\tmessageBus,\n\t\tcfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(65536),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &MatrixChannel{\n\t\tBaseChannel:       base,\n\t\tclient:            client,\n\t\tconfig:            cfg,\n\t\tsyncer:            syncer,\n\t\ttypingSessions:    make(map[string]*typingSession),\n\t\tstartTime:         time.Now(),\n\t\troomKindCache:     newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL),\n\t\tlocalpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)),\n\t\ttypingMu:          sync.Mutex{},\n\t}, nil\n}\n\nfunc (c *MatrixChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"matrix\", \"Starting Matrix channel\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\tc.startTime = time.Now()\n\n\tc.syncer.OnEventType(event.EventMessage, c.handleMessageEvent)\n\tc.syncer.OnEventType(event.StateMember, c.handleMemberEvent)\n\n\tc.SetRunning(true)\n\tgo c.runRoomKindCacheJanitor(c.ctx)\n\n\tgo func() {\n\t\tif err := c.client.SyncWithContext(c.ctx); err != nil && c.ctx.Err() == nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Matrix sync stopped unexpectedly\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}()\n\n\tlogger.InfoC(\"matrix\", \"Matrix channel started\")\n\treturn nil\n}\n\nfunc (c *MatrixChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"matrix\", \"Stopping Matrix channel\")\n\tc.SetRunning(false)\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tc.stopTypingSessions(ctx)\n\n\tlogger.InfoC(\"matrix\", \"Matrix channel stopped\")\n\treturn nil\n}\n\nfunc markdownToHTML(md string) string {\n\tp := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)\n\trenderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})\n\treturn strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))\n}\n\nfunc (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\troomID := id.RoomID(strings.TrimSpace(msg.ChatID))\n\tif roomID == \"\" {\n\t\treturn fmt.Errorf(\"matrix room ID is empty: %w\", channels.ErrSendFailed)\n\t}\n\n\tcontent := strings.TrimSpace(msg.Content)\n\tif content == \"\" {\n\t\treturn nil\n\t}\n\n\t_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"matrix send: %w\", channels.ErrTemporary)\n\t}\n\treturn nil\n}\n\nfunc (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {\n\tmc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}\n\tif c.config.MessageFormat != \"plain\" {\n\t\tmc.Format = event.FormatHTML\n\t\tmc.FormattedBody = markdownToHTML(text)\n\t}\n\treturn mc\n}\n\n// SendMedia implements channels.MediaSender.\nfunc (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\tsendCtx := ctx\n\tif sendCtx == nil {\n\t\tsendCtx = context.Background()\n\t}\n\n\troomID := id.RoomID(strings.TrimSpace(msg.ChatID))\n\tif roomID == \"\" {\n\t\treturn fmt.Errorf(\"matrix room ID is empty: %w\", channels.ErrSendFailed)\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tfor _, part := range msg.Parts {\n\t\tif err := sendCtx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tlocalPath, meta, err := store.ResolveWithMeta(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfileInfo, err := os.Stat(localPath)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Failed to stat media file\", map[string]any{\n\t\t\t\t\"path\":  localPath,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfile, err := os.Open(localPath)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Failed to open media file\", map[string]any{\n\t\t\t\t\"path\":  localPath,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfilename := strings.TrimSpace(part.Filename)\n\t\tif filename == \"\" {\n\t\t\tfilename = strings.TrimSpace(meta.Filename)\n\t\t}\n\t\tif filename == \"\" {\n\t\t\tfilename = filepath.Base(localPath)\n\t\t}\n\t\tif filename == \"\" {\n\t\t\tfilename = \"file\"\n\t\t}\n\n\t\tcontentType := strings.TrimSpace(part.ContentType)\n\t\tif contentType == \"\" {\n\t\t\tcontentType = strings.TrimSpace(meta.ContentType)\n\t\t}\n\t\tif contentType == \"\" {\n\t\t\tcontentType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))\n\t\t}\n\t\tif contentType == \"\" {\n\t\t\tcontentType = \"application/octet-stream\"\n\t\t}\n\n\t\tuploadResp, err := c.client.UploadMedia(sendCtx, mautrix.ReqUploadMedia{\n\t\t\tContent:       file,\n\t\t\tContentLength: fileInfo.Size(),\n\t\t\tContentType:   contentType,\n\t\t\tFileName:      filename,\n\t\t})\n\t\tfile.Close()\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Failed to upload media\", map[string]any{\n\t\t\t\t\"path\":  localPath,\n\t\t\t\t\"type\":  part.Type,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\treturn fmt.Errorf(\"matrix upload media: %w\", channels.ErrTemporary)\n\t\t}\n\n\t\tmsgType := matrixOutboundMsgType(part.Type, filename, contentType)\n\t\tcontent := matrixOutboundContent(\n\t\t\tpart.Caption,\n\t\t\tfilename,\n\t\t\tmsgType,\n\t\t\tcontentType,\n\t\t\tfileInfo.Size(),\n\t\t\tuploadResp.ContentURI.CUString(),\n\t\t)\n\n\t\tif _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil {\n\t\t\tlogger.ErrorCF(\"matrix\", \"Failed to send media message\", map[string]any{\n\t\t\t\t\"room_id\": roomID.String(),\n\t\t\t\t\"type\":    msgType,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t\treturn fmt.Errorf(\"matrix send media: %w\", channels.ErrTemporary)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// StartTyping implements channels.TypingCapable.\nfunc (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tif !c.IsRunning() {\n\t\treturn func() {}, nil\n\t}\n\n\troomID := id.RoomID(strings.TrimSpace(chatID))\n\tif roomID == \"\" {\n\t\treturn func() {}, fmt.Errorf(\"matrix room ID is empty\")\n\t}\n\n\tsession := newTypingSession()\n\n\tc.typingMu.Lock()\n\tif prev := c.typingSessions[chatID]; prev != nil {\n\t\tprev.stop()\n\t}\n\tc.typingSessions[chatID] = session\n\tc.typingMu.Unlock()\n\n\tparent := c.baseContext()\n\tgo c.typingLoop(parent, roomID, session)\n\n\tvar once sync.Once\n\tstop := func() {\n\t\tonce.Do(func() {\n\t\t\tsession.stop()\n\t\t\tc.typingMu.Lock()\n\t\t\tif current := c.typingSessions[chatID]; current == session {\n\t\t\t\tdelete(c.typingSessions, chatID)\n\t\t\t}\n\t\t\tc.typingMu.Unlock()\n\t\t\t_, _ = c.client.UserTyping(context.Background(), roomID, false, 0)\n\t\t})\n\t}\n\n\treturn stop, nil\n}\n\n// SendPlaceholder implements channels.PlaceholderCapable.\nfunc (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tif !c.config.Placeholder.Enabled {\n\t\treturn \"\", nil\n\t}\n\n\troomID := id.RoomID(strings.TrimSpace(chatID))\n\tif roomID == \"\" {\n\t\treturn \"\", fmt.Errorf(\"matrix room ID is empty\")\n\t}\n\n\ttext := strings.TrimSpace(c.config.Placeholder.Text)\n\tif text == \"\" {\n\t\ttext = \"Thinking... 💭\"\n\t}\n\n\tresp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{\n\t\tMsgType: event.MsgNotice,\n\t\tBody:    text,\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn resp.EventID.String(), nil\n}\n\n// EditMessage implements channels.MessageEditor.\nfunc (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {\n\troomID := id.RoomID(strings.TrimSpace(chatID))\n\tif roomID == \"\" {\n\t\treturn fmt.Errorf(\"matrix room ID is empty\")\n\t}\n\tif strings.TrimSpace(messageID) == \"\" {\n\t\treturn fmt.Errorf(\"matrix message ID is empty\")\n\t}\n\n\teditContent := c.messageContent(content)\n\teditContent.SetEdit(id.EventID(messageID))\n\n\t_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)\n\treturn err\n}\n\nfunc (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) {\n\tif !c.config.JoinOnInvite {\n\t\treturn\n\t}\n\tif evt == nil {\n\t\treturn\n\t}\n\n\tmember := evt.Content.AsMember()\n\tif member.Membership != event.MembershipInvite {\n\t\treturn\n\t}\n\tif evt.GetStateKey() != c.client.UserID.String() {\n\t\treturn\n\t}\n\n\t_, err := c.client.JoinRoomByID(c.baseContext(), evt.RoomID)\n\tif err != nil {\n\t\tlogger.WarnCF(\"matrix\", \"Failed to auto-join invited room\", map[string]any{\n\t\t\t\"room_id\": evt.RoomID.String(),\n\t\t\t\"error\":   err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tlogger.InfoCF(\"matrix\", \"Joined room after invite\", map[string]any{\n\t\t\"room_id\": evt.RoomID.String(),\n\t})\n}\n\nfunc (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event) {\n\tif evt == nil {\n\t\treturn\n\t}\n\n\t// Ignore our own messages.\n\tif evt.Sender == c.client.UserID {\n\t\treturn\n\t}\n\n\t// Ignore historical events on first sync.\n\tif time.UnixMilli(evt.Timestamp).Before(c.startTime) {\n\t\treturn\n\t}\n\n\tmsgEvt := evt.Content.AsMessage()\n\tif msgEvt == nil {\n\t\treturn\n\t}\n\n\t// Ignore edits.\n\tif msgEvt.RelatesTo != nil && msgEvt.RelatesTo.GetReplaceID() != \"\" {\n\t\treturn\n\t}\n\n\troomID := evt.RoomID.String()\n\tscope := channels.BuildMediaScope(\"matrix\", roomID, evt.ID.String())\n\n\tcontent, mediaPaths, ok := c.extractInboundContent(ctx, msgEvt, scope)\n\tif !ok {\n\t\treturn\n\t}\n\tcontent = strings.TrimSpace(content)\n\tif content == \"\" && len(mediaPaths) == 0 {\n\t\treturn\n\t}\n\n\tsenderID := evt.Sender.String()\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"matrix\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"matrix\", senderID),\n\t\tUsername:    senderID,\n\t\tDisplayName: senderID,\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\tlogger.DebugCF(\"matrix\", \"Message rejected by allowlist\", map[string]any{\n\t\t\t\"sender_id\": senderID,\n\t\t})\n\t\treturn\n\t}\n\n\tisGroup := c.isGroupRoom(ctx, evt.RoomID)\n\tif isGroup {\n\t\tisMentioned := c.isBotMentioned(msgEvt)\n\t\tif isMentioned {\n\t\t\tcontent = c.stripSelfMention(content)\n\t\t}\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\tlogger.DebugCF(\"matrix\", \"Ignoring group message by trigger rules\", map[string]any{\n\t\t\t\t\"room_id\":      roomID,\n\t\t\t\t\"is_mentioned\": isMentioned,\n\t\t\t\t\"mention_only\": c.config.GroupTrigger.MentionOnly,\n\t\t\t\t\"prefixes\":     c.config.GroupTrigger.Prefixes,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t} else {\n\t\tcontent = c.stripSelfMention(content)\n\t}\n\n\tcontent = strings.TrimSpace(content)\n\tif content == \"\" {\n\t\treturn\n\t}\n\n\tpeerKind := \"direct\"\n\tpeerID := senderID\n\tif isGroup {\n\t\tpeerKind = \"group\"\n\t\tpeerID = roomID\n\t}\n\n\tmetadata := map[string]string{\n\t\t\"room_id\":    roomID,\n\t\t\"timestamp\":  fmt.Sprintf(\"%d\", evt.Timestamp),\n\t\t\"is_group\":   fmt.Sprintf(\"%t\", isGroup),\n\t\t\"sender_raw\": senderID,\n\t}\n\tif replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != \"\" {\n\t\tmetadata[\"reply_to_msg_id\"] = replyTo.String()\n\t}\n\n\tc.HandleMessage(\n\t\tc.baseContext(),\n\t\tbus.Peer{Kind: peerKind, ID: peerID},\n\t\tevt.ID.String(),\n\t\tsenderID,\n\t\troomID,\n\t\tcontent,\n\t\tmediaPaths,\n\t\tmetadata,\n\t\tsender,\n\t)\n}\n\nfunc (c *MatrixChannel) extractInboundContent(\n\tctx context.Context,\n\tmsgEvt *event.MessageEventContent,\n\tscope string,\n) (string, []string, bool) {\n\tswitch msgEvt.MsgType {\n\tcase event.MsgText, event.MsgNotice:\n\t\treturn msgEvt.Body, nil, true\n\tcase event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile:\n\t\treturn c.extractInboundMedia(ctx, msgEvt, scope)\n\tdefault:\n\t\tlogger.DebugCF(\"matrix\", \"Ignoring unsupported matrix msgtype\", map[string]any{\n\t\t\t\"msgtype\": msgEvt.MsgType,\n\t\t})\n\t\treturn \"\", nil, false\n\t}\n}\n\nfunc (c *MatrixChannel) extractInboundMedia(\n\tctx context.Context,\n\tmsgEvt *event.MessageEventContent,\n\tscope string,\n) (string, []string, bool) {\n\tmediaKind := matrixMediaKind(msgEvt.MsgType)\n\tlabel := matrixMediaLabel(msgEvt, mediaKind)\n\tcontent := fmt.Sprintf(\"[%s: %s]\", mediaKind, label)\n\tif caption := strings.TrimSpace(msgEvt.GetCaption()); caption != \"\" {\n\t\tcontent = caption + \"\\n\" + content\n\t}\n\n\tlocalPath, err := c.downloadMedia(ctx, msgEvt, mediaKind)\n\tif err != nil {\n\t\tlogger.WarnCF(\"matrix\", \"Failed to download media; forwarding as text-only marker\", map[string]any{\n\t\t\t\"msgtype\": msgEvt.MsgType,\n\t\t\t\"error\":   err.Error(),\n\t\t})\n\t\treturn content, nil, true\n\t}\n\n\tfilename := matrixMediaFilename(label, mediaKind, matrixContentType(msgEvt))\n\tref := c.storeMedia(localPath, media.MediaMeta{\n\t\tFilename:    filename,\n\t\tContentType: matrixContentType(msgEvt),\n\t\tSource:      \"matrix\",\n\t}, scope)\n\treturn content, []string{ref}, true\n}\n\nfunc (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string {\n\tif store := c.GetMediaStore(); store != nil {\n\t\tref, err := store.Store(localPath, meta, scope)\n\t\tif err == nil {\n\t\t\treturn ref\n\t\t}\n\t\tlogger.WarnCF(\"matrix\", \"Failed to store media in MediaStore, falling back to local path\", map[string]any{\n\t\t\t\"path\":  localPath,\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t}\n\treturn localPath\n}\n\nfunc (c *MatrixChannel) downloadMedia(\n\tctx context.Context,\n\tmsgEvt *event.MessageEventContent,\n\tmediaKind string,\n) (string, error) {\n\turi := matrixMediaURI(msgEvt)\n\tif uri == \"\" {\n\t\treturn \"\", fmt.Errorf(\"empty matrix media URL\")\n\t}\n\tparsed := uri.ParseOrIgnore()\n\tif parsed.IsEmpty() {\n\t\treturn \"\", fmt.Errorf(\"invalid matrix media URL: %s\", uri)\n\t}\n\n\tdlCtx := c.baseContext()\n\tif ctx != nil {\n\t\tdlCtx = ctx\n\t}\n\treqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)\n\tdefer cancel()\n\n\tresp, err := c.client.Download(reqCtx, parsed)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\treader := resp.Body\n\treaderClose := func() error { return nil }\n\n\t// Encrypted attachments put URL in msgEvt.File and require client-side decryption.\n\tif msgEvt != nil && msgEvt.File != nil && msgEvt.URL == \"\" {\n\t\tif err = msgEvt.File.PrepareForDecryption(); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"decrypt matrix media: %w\", err)\n\t\t}\n\t\tdecryptReader := msgEvt.File.DecryptStream(resp.Body)\n\t\treader = decryptReader\n\t\treaderClose = decryptReader.Close\n\t}\n\n\tlabel := matrixMediaLabel(msgEvt, mediaKind)\n\text := matrixMediaExt(label, matrixContentType(msgEvt), mediaKind)\n\tmediaDir, err := matrixMediaTempDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create matrix media directory: %w\", err)\n\t}\n\ttmp, err := os.CreateTemp(mediaDir, \"matrix-media-*\"+ext)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\ttmpPath := tmp.Name()\n\tcleanup := true\n\tdefer func() {\n\t\t_ = tmp.Close()\n\t\tif cleanup {\n\t\t\t_ = os.Remove(tmpPath)\n\t\t}\n\t}()\n\n\t_, err = io.Copy(tmp, reader)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err = readerClose(); err != nil {\n\t\treturn \"\", fmt.Errorf(\"decrypt matrix media: %w\", err)\n\t}\n\tif err = tmp.Close(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcleanup = false\n\treturn tmpPath, nil\n}\n\nfunc matrixContentType(msgEvt *event.MessageEventContent) string {\n\tif msgEvt != nil && msgEvt.Info != nil {\n\t\treturn strings.TrimSpace(msgEvt.Info.MimeType)\n\t}\n\treturn \"\"\n}\n\nfunc matrixMediaURI(msgEvt *event.MessageEventContent) id.ContentURIString {\n\tif msgEvt == nil {\n\t\treturn \"\"\n\t}\n\tif msgEvt.URL != \"\" {\n\t\treturn msgEvt.URL\n\t}\n\tif msgEvt.File != nil {\n\t\treturn msgEvt.File.URL\n\t}\n\treturn \"\"\n}\n\nfunc matrixMediaKind(msgType event.MessageType) string {\n\tswitch msgType {\n\tcase event.MsgAudio:\n\t\treturn \"audio\"\n\tcase event.MsgVideo:\n\t\treturn \"video\"\n\tcase event.MsgFile:\n\t\treturn \"file\"\n\tdefault:\n\t\treturn \"image\"\n\t}\n}\n\nfunc matrixOutboundMsgType(partType, filename, contentType string) event.MessageType {\n\tswitch strings.ToLower(strings.TrimSpace(partType)) {\n\tcase \"image\":\n\t\treturn event.MsgImage\n\tcase \"audio\", \"voice\":\n\t\treturn event.MsgAudio\n\tcase \"video\":\n\t\treturn event.MsgVideo\n\tcase \"file\", \"document\":\n\t\treturn event.MsgFile\n\t}\n\n\tct := strings.ToLower(strings.TrimSpace(contentType))\n\tswitch {\n\tcase strings.HasPrefix(ct, \"image/\"):\n\t\treturn event.MsgImage\n\tcase strings.HasPrefix(ct, \"audio/\"), ct == \"application/ogg\", ct == \"application/x-ogg\":\n\t\treturn event.MsgAudio\n\tcase strings.HasPrefix(ct, \"video/\"):\n\t\treturn event.MsgVideo\n\t}\n\n\tswitch strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) {\n\tcase \".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\", \".svg\":\n\t\treturn event.MsgImage\n\tcase \".mp3\", \".wav\", \".ogg\", \".m4a\", \".flac\", \".aac\", \".wma\", \".opus\":\n\t\treturn event.MsgAudio\n\tcase \".mp4\", \".avi\", \".mov\", \".webm\", \".mkv\":\n\t\treturn event.MsgVideo\n\tdefault:\n\t\treturn event.MsgFile\n\t}\n}\n\nfunc matrixOutboundContent(\n\tcaption, filename string,\n\tmsgType event.MessageType,\n\tcontentType string,\n\tsize int64,\n\turi id.ContentURIString,\n) *event.MessageEventContent {\n\tbody := strings.TrimSpace(caption)\n\tif body == \"\" {\n\t\tbody = filename\n\t}\n\tif body == \"\" {\n\t\tbody = matrixMediaKind(msgType)\n\t}\n\n\tinfo := &event.FileInfo{MimeType: strings.TrimSpace(contentType)}\n\tif size > 0 && size <= int64(int(^uint(0)>>1)) {\n\t\tinfo.Size = int(size)\n\t}\n\n\tcontent := &event.MessageEventContent{\n\t\tMsgType:  msgType,\n\t\tBody:     body,\n\t\tURL:      uri,\n\t\tFileName: filename,\n\t\tInfo:     info,\n\t}\n\treturn content\n}\n\nfunc matrixMediaLabel(msgEvt *event.MessageEventContent, fallback string) string {\n\tif msgEvt == nil {\n\t\treturn fallback\n\t}\n\tif v := strings.TrimSpace(msgEvt.FileName); v != \"\" {\n\t\treturn v\n\t}\n\tif v := strings.TrimSpace(msgEvt.Body); v != \"\" {\n\t\treturn v\n\t}\n\treturn fallback\n}\n\nfunc matrixMediaFilename(label, mediaKind, contentType string) string {\n\tfilename := strings.TrimSpace(label)\n\tif filename == \"\" {\n\t\tfilename = mediaKind\n\t}\n\tif filepath.Ext(filename) == \"\" {\n\t\tfilename += matrixMediaExt(\"\", contentType, mediaKind)\n\t}\n\treturn filename\n}\n\nfunc matrixMediaExt(filename, contentType, mediaKind string) string {\n\tif ext := strings.TrimSpace(filepath.Ext(filename)); ext != \"\" {\n\t\treturn ext\n\t}\n\tif contentType != \"\" {\n\t\tif exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {\n\t\t\treturn exts[0]\n\t\t}\n\t}\n\tswitch mediaKind {\n\tcase \"audio\":\n\t\treturn \".ogg\"\n\tcase \"video\":\n\t\treturn \".mp4\"\n\tcase \"file\":\n\t\treturn \".bin\"\n\tdefault:\n\t\treturn \".jpg\"\n\t}\n}\n\nfunc (c *MatrixChannel) isGroupRoom(ctx context.Context, roomID id.RoomID) bool {\n\tnow := time.Now()\n\tif isGroup, ok := c.roomKindCache.get(roomID.String(), now); ok {\n\t\treturn isGroup\n\t}\n\n\tqctx := c.baseContext()\n\tif ctx != nil {\n\t\tqctx = ctx\n\t}\n\treqCtx, cancel := context.WithTimeout(qctx, 5*time.Second)\n\tdefer cancel()\n\n\tresp, err := c.client.JoinedMembers(reqCtx, roomID)\n\tif err != nil {\n\t\tlogger.DebugCF(\"matrix\", \"Failed to query room members; assume direct\", map[string]any{\n\t\t\t\"room_id\": roomID.String(),\n\t\t\t\"error\":   err.Error(),\n\t\t})\n\t\treturn false\n\t}\n\n\tisGroup := len(resp.Joined) > 2\n\tc.roomKindCache.set(roomID.String(), isGroup, now)\n\treturn isGroup\n}\n\nfunc (c *MatrixChannel) isBotMentioned(msgEvt *event.MessageEventContent) bool {\n\tif msgEvt == nil {\n\t\treturn false\n\t}\n\n\tif msgEvt.Mentions != nil && msgEvt.Mentions.Has(c.client.UserID) {\n\t\treturn true\n\t}\n\n\tuserID := c.client.UserID.String()\n\tif userID != \"\" && strings.Contains(msgEvt.Body, userID) {\n\t\treturn true\n\t}\n\tif mentionsUserInFormattedBody(msgEvt.FormattedBody, c.client.UserID) {\n\t\treturn true\n\t}\n\n\tmentionR := c.localpartMentionR\n\tif mentionR == nil {\n\t\tmentionR = localpartMentionRegexp(matrixLocalpart(c.client.UserID))\n\t}\n\tif mentionR == nil {\n\t\treturn false\n\t}\n\n\t// Matrix users are addressed as MXID \"@localpart:server\", but many clients\n\t// emit plain-text mentions as \"@localpart\". Both forms are handled here.\n\treturn mentionR.MatchString(msgEvt.Body) || mentionR.MatchString(msgEvt.FormattedBody)\n}\n\nfunc mentionsUserInFormattedBody(formattedBody string, userID id.UserID) bool {\n\ttarget := strings.ToLower(strings.TrimSpace(userID.String()))\n\tif target == \"\" {\n\t\treturn false\n\t}\n\n\tformattedBody = strings.TrimSpace(formattedBody)\n\tif formattedBody == \"\" {\n\t\treturn false\n\t}\n\n\tif strings.Contains(strings.ToLower(formattedBody), target) {\n\t\treturn true\n\t}\n\n\tmatches := matrixMentionHrefRegexp.FindAllStringSubmatch(formattedBody, -1)\n\tfor _, match := range matches {\n\t\tif len(match) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\tdecoded := decodeMatrixMentionHref(match[1])\n\t\tif strings.Contains(strings.ToLower(decoded), target) {\n\t\t\treturn true\n\t\t}\n\n\t\tu, err := url.Parse(decoded)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.Contains(strings.ToLower(u.Path), target) || strings.Contains(strings.ToLower(u.Fragment), target) {\n\t\t\treturn true\n\t\t}\n\t\tif strings.Contains(strings.ToLower(decodeMatrixMentionHref(u.Fragment)), target) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc decodeMatrixMentionHref(v string) string {\n\tdecoded := html.UnescapeString(strings.TrimSpace(v))\n\tif decoded == \"\" {\n\t\treturn \"\"\n\t}\n\n\tfor i := 0; i < 2; i++ {\n\t\tnext, err := url.QueryUnescape(decoded)\n\t\tif err != nil || next == decoded {\n\t\t\tbreak\n\t\t}\n\t\tdecoded = next\n\t}\n\treturn decoded\n}\n\nfunc (c *MatrixChannel) typingLoop(ctx context.Context, roomID id.RoomID, session *typingSession) {\n\tsendTyping := func() {\n\t\t_, err := c.client.UserTyping(ctx, roomID, true, typingServerTTL)\n\t\tif err != nil {\n\t\t\tlogger.DebugCF(\"matrix\", \"Failed to send typing status\", map[string]any{\n\t\t\t\t\"room_id\": roomID.String(),\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\tsendTyping()\n\tticker := time.NewTicker(typingRefreshInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-session.stopCh:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tsendTyping()\n\t\t}\n\t}\n}\n\nfunc (c *MatrixChannel) stopTypingSessions(ctx context.Context) {\n\tc.typingMu.Lock()\n\tsessions := c.typingSessions\n\tc.typingSessions = make(map[string]*typingSession)\n\tc.typingMu.Unlock()\n\n\tstopCtx := ctx\n\tif stopCtx == nil {\n\t\tstopCtx = context.Background()\n\t}\n\tfor roomID, session := range sessions {\n\t\tsession.stop()\n\t\t_, _ = c.client.UserTyping(stopCtx, id.RoomID(roomID), false, 0)\n\t}\n}\n\nfunc (c *MatrixChannel) baseContext() context.Context {\n\tif c.ctx != nil {\n\t\treturn c.ctx\n\t}\n\treturn context.Background()\n}\n\nfunc (c *MatrixChannel) runRoomKindCacheJanitor(ctx context.Context) {\n\tticker := time.NewTicker(roomKindCacheCleanupPeriod)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase now := <-ticker.C:\n\t\t\tc.roomKindCache.cleanupExpired(now)\n\t\t}\n\t}\n}\n\nfunc (c *MatrixChannel) stripSelfMention(text string) string {\n\treturn stripUserMentionWithRegexp(text, c.client.UserID, c.localpartMentionR)\n}\n\nfunc matrixMediaTempDir() (string, error) {\n\tmediaDir := media.TempDir()\n\tif err := os.MkdirAll(mediaDir, 0o700); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn mediaDir, nil\n}\n\nfunc matrixLocalpart(userID id.UserID) string {\n\ts := strings.TrimPrefix(userID.String(), \"@\")\n\tlocalpart, _, _ := strings.Cut(s, \":\")\n\treturn strings.TrimSpace(localpart)\n}\n\nfunc localpartMentionRegexp(localpart string) *regexp.Regexp {\n\tlocalpart = strings.TrimSpace(localpart)\n\tif localpart == \"\" {\n\t\treturn nil\n\t}\n\n\t// Match Matrix mentions in plain text while avoiding false positives:\n\t//   \"@picoclaw\" and \"@picoclaw:matrix.org\" should match,\n\t//   \"test@example.com\" and \"hellopicoclawworld\" should not.\n\tpattern := `(?i)(^|[^[:alnum:]_])@` + regexp.QuoteMeta(localpart) + `(?::[A-Za-z0-9._:-]+)?([^[:alnum:]_]|$)`\n\treturn regexp.MustCompile(pattern)\n}\n\nfunc stripUserMention(text string, userID id.UserID) string {\n\treturn stripUserMentionWithRegexp(text, userID, localpartMentionRegexp(matrixLocalpart(userID)))\n}\n\nfunc stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp.Regexp) string {\n\tcleaned := strings.ReplaceAll(text, userID.String(), \"\")\n\n\tif mentionR != nil {\n\t\tcleaned = mentionR.ReplaceAllString(cleaned, \"$1$2\")\n\t}\n\n\tcleaned = strings.TrimSpace(cleaned)\n\tcleaned = strings.TrimLeft(cleaned, \",:; \")\n\treturn strings.TrimSpace(cleaned)\n}\n"
  },
  {
    "path": "pkg/channels/matrix/matrix_test.go",
    "content": "package matrix\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"maunium.net/go/mautrix\"\n\t\"maunium.net/go/mautrix/event\"\n\t\"maunium.net/go/mautrix/id\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nfunc TestMatrixLocalpartMentionRegexp(t *testing.T) {\n\tre := localpartMentionRegexp(\"picoclaw\")\n\n\tcases := []struct {\n\t\ttext string\n\t\twant bool\n\t}{\n\t\t{text: \"@picoclaw hello\", want: true},\n\t\t{text: \"hi @picoclaw:matrix.org\", want: true},\n\t\t{\n\t\t\ttext: \"\\u6b22\\u8fce\\u4e00\\u4e0bpicoclaw\\u5c0f\\u9f99\\u867e\",\n\t\t\twant: false, // historical false-positive case in PR #356\n\t\t},\n\t\t{text: \"mail test@example.com\", want: false},\n\t}\n\n\tfor _, tc := range cases {\n\t\tif got := re.MatchString(tc.text); got != tc.want {\n\t\t\tt.Fatalf(\"text=%q match=%v want=%v\", tc.text, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestStripUserMention(t *testing.T) {\n\tuserID := id.UserID(\"@picoclaw:matrix.org\")\n\n\tcases := []struct {\n\t\tin   string\n\t\twant string\n\t}{\n\t\t{in: \"@picoclaw:matrix.org hello\", want: \"hello\"},\n\t\t{in: \"@picoclaw, hello\", want: \"hello\"},\n\t\t{in: \"no mention here\", want: \"no mention here\"},\n\t}\n\n\tfor _, tc := range cases {\n\t\tif got := stripUserMention(tc.in, userID); got != tc.want {\n\t\t\tt.Fatalf(\"stripUserMention(%q)=%q want=%q\", tc.in, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestIsBotMentioned(t *testing.T) {\n\tch := &MatrixChannel{\n\t\tclient: &mautrix.Client{\n\t\t\tUserID: id.UserID(\"@picoclaw:matrix.org\"),\n\t\t},\n\t}\n\n\tcases := []struct {\n\t\tname string\n\t\tmsg  event.MessageEventContent\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tname: \"mentions field\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody: \"hello\",\n\t\t\t\tMentions: &event.Mentions{\n\t\t\t\t\tUserIDs: []id.UserID{id.UserID(\"@picoclaw:matrix.org\")},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"full user id in body\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody: \"@picoclaw:matrix.org hello\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"localpart with at sign\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody: \"@picoclaw hello\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"localpart without at sign should not match\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody: \"\\u6b22\\u8fce\\u4e00\\u4e0bpicoclaw\\u5c0f\\u9f99\\u867e\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"formatted mention href matrix.to plain\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody:          \"hello bot\",\n\t\t\t\tFormattedBody: `<a href=\"https://matrix.to/#/@picoclaw:matrix.org\">PicoClaw</a> hello`,\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"formatted mention href matrix.to encoded\",\n\t\t\tmsg: event.MessageEventContent{\n\t\t\t\tBody:          \"hello bot\",\n\t\t\t\tFormattedBody: `<a href=\"https://matrix.to/#/%40picoclaw%3Amatrix.org\">PicoClaw</a> hello`,\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tif got := ch.isBotMentioned(&tc.msg); got != tc.want {\n\t\t\tt.Fatalf(\"%s: got=%v want=%v\", tc.name, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestRoomKindCache_ExpiresEntries(t *testing.T) {\n\tcache := newRoomKindCache(4, 5*time.Second)\n\tnow := time.Unix(100, 0)\n\tcache.set(\"!room:matrix.org\", true, now)\n\n\tif got, ok := cache.get(\"!room:matrix.org\", now.Add(2*time.Second)); !ok || !got {\n\t\tt.Fatalf(\"expected cached group room before ttl, got ok=%v group=%v\", ok, got)\n\t}\n\n\tif _, ok := cache.get(\"!room:matrix.org\", now.Add(6*time.Second)); ok {\n\t\tt.Fatal(\"expected cache miss after ttl expiry\")\n\t}\n}\n\nfunc TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {\n\tcache := newRoomKindCache(2, time.Minute)\n\tnow := time.Unix(200, 0)\n\n\tcache.set(\"!room1:matrix.org\", false, now)\n\tcache.set(\"!room2:matrix.org\", false, now.Add(1*time.Second))\n\tcache.set(\"!room3:matrix.org\", true, now.Add(2*time.Second))\n\n\tif _, ok := cache.get(\"!room1:matrix.org\", now.Add(2*time.Second)); ok {\n\t\tt.Fatal(\"expected oldest cache entry to be evicted\")\n\t}\n\tif got, ok := cache.get(\"!room2:matrix.org\", now.Add(2*time.Second)); !ok || got {\n\t\tt.Fatalf(\"expected room2 to remain and be direct, got ok=%v group=%v\", ok, got)\n\t}\n\tif got, ok := cache.get(\"!room3:matrix.org\", now.Add(2*time.Second)); !ok || !got {\n\t\tt.Fatalf(\"expected room3 to remain and be group, got ok=%v group=%v\", ok, got)\n\t}\n}\n\nfunc TestMatrixMediaTempDir(t *testing.T) {\n\tdir, err := matrixMediaTempDir()\n\tif err != nil {\n\t\tt.Fatalf(\"matrixMediaTempDir failed: %v\", err)\n\t}\n\tif filepath.Base(dir) != media.TempDirName {\n\t\tt.Fatalf(\"unexpected media dir base: %q\", filepath.Base(dir))\n\t}\n\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"media dir not created: %v\", err)\n\t}\n\tif !info.IsDir() {\n\t\tt.Fatalf(\"expected directory, got mode=%v\", info.Mode())\n\t}\n}\n\nfunc TestMatrixMediaExt(t *testing.T) {\n\tif got := matrixMediaExt(\"photo.png\", \"\", \"image\"); got != \".png\" {\n\t\tt.Fatalf(\"filename extension mismatch: got=%q\", got)\n\t}\n\tif got := matrixMediaExt(\"\", \"image/webp\", \"image\"); got != \".webp\" {\n\t\tt.Fatalf(\"content-type extension mismatch: got=%q\", got)\n\t}\n\tif got := matrixMediaExt(\"\", \"\", \"image\"); got != \".jpg\" {\n\t\tt.Fatalf(\"default image extension mismatch: got=%q\", got)\n\t}\n\tif got := matrixMediaExt(\"\", \"\", \"audio\"); got != \".ogg\" {\n\t\tt.Fatalf(\"default audio extension mismatch: got=%q\", got)\n\t}\n\tif got := matrixMediaExt(\"\", \"\", \"video\"); got != \".mp4\" {\n\t\tt.Fatalf(\"default video extension mismatch: got=%q\", got)\n\t}\n\tif got := matrixMediaExt(\"\", \"\", \"file\"); got != \".bin\" {\n\t\tt.Fatalf(\"default file extension mismatch: got=%q\", got)\n\t}\n}\n\nfunc TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {\n\tconst wantBody = \"matrix-media-payload\"\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !strings.HasSuffix(r.URL.Path, \"/_matrix/client/v1/media/download/matrix.test/abc123\") {\n\t\t\tt.Fatalf(\"unexpected download path: %s\", r.URL.Path)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"image/png\")\n\t\t_, _ = w.Write([]byte(wantBody))\n\t}))\n\tdefer server.Close()\n\n\tclient, err := mautrix.NewClient(server.URL, id.UserID(\"@picoclaw:matrix.test\"), \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewClient: %v\", err)\n\t}\n\n\tch := &MatrixChannel{client: client}\n\tmsg := &event.MessageEventContent{\n\t\tMsgType: event.MsgImage,\n\t\tBody:    \"image.png\",\n\t\tURL:     id.ContentURIString(\"mxc://matrix.test/abc123\"),\n\t\tInfo:    &event.FileInfo{MimeType: \"image/png\"},\n\t}\n\n\tpath, err := ch.downloadMedia(context.Background(), msg, \"image\")\n\tif err != nil {\n\t\tt.Fatalf(\"downloadMedia: %v\", err)\n\t}\n\tdefer os.Remove(path)\n\n\tif ext := filepath.Ext(path); ext != \".png\" {\n\t\tt.Fatalf(\"temp file extension=%q want=.png\", ext)\n\t}\n\n\tgot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadFile: %v\", err)\n\t}\n\tif string(got) != wantBody {\n\t\tt.Fatalf(\"file contents=%q want=%q\", string(got), wantBody)\n\t}\n}\n\nfunc TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {\n\tch := &MatrixChannel{}\n\tmsg := &event.MessageEventContent{\n\t\tMsgType: event.MsgImage,\n\t\tBody:    \"test.png\",\n\t}\n\n\tcontent, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, \"matrix:room:event\")\n\tif !ok {\n\t\tt.Fatal(\"expected ok for image fallback\")\n\t}\n\tif content != \"[image: test.png]\" {\n\t\tt.Fatalf(\"unexpected content: %q\", content)\n\t}\n\tif len(mediaRefs) != 0 {\n\t\tt.Fatalf(\"expected no media refs, got %d\", len(mediaRefs))\n\t}\n}\n\nfunc TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {\n\tch := &MatrixChannel{}\n\tmsg := &event.MessageEventContent{\n\t\tMsgType:  event.MsgAudio,\n\t\tFileName: \"voice.ogg\",\n\t\tBody:     \"please transcribe\",\n\t}\n\n\tcontent, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, \"matrix:room:event\")\n\tif !ok {\n\t\tt.Fatal(\"expected ok for audio fallback\")\n\t}\n\tif content != \"please transcribe\\n[audio: voice.ogg]\" {\n\t\tt.Fatalf(\"unexpected content: %q\", content)\n\t}\n\tif len(mediaRefs) != 0 {\n\t\tt.Fatalf(\"expected no media refs, got %d\", len(mediaRefs))\n\t}\n}\n\nfunc TestMatrixOutboundMsgType(t *testing.T) {\n\tcases := []struct {\n\t\tname        string\n\t\tpartType    string\n\t\tfilename    string\n\t\tcontentType string\n\t\twant        event.MessageType\n\t}{\n\t\t{name: \"explicit image\", partType: \"image\", want: event.MsgImage},\n\t\t{name: \"explicit audio\", partType: \"audio\", want: event.MsgAudio},\n\t\t{name: \"mime fallback video\", contentType: \"video/mp4\", want: event.MsgVideo},\n\t\t{name: \"extension fallback audio\", filename: \"voice.ogg\", want: event.MsgAudio},\n\t\t{name: \"unknown defaults file\", filename: \"report.txt\", want: event.MsgFile},\n\t}\n\n\tfor _, tc := range cases {\n\t\tif got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {\n\t\t\tt.Fatalf(\"%s: got=%q want=%q\", tc.name, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestMatrixOutboundContent(t *testing.T) {\n\tcontent := matrixOutboundContent(\n\t\t\"please review\",\n\t\t\"voice.ogg\",\n\t\tevent.MsgAudio,\n\t\t\"audio/ogg\",\n\t\t1234,\n\t\tid.ContentURIString(\"mxc://matrix.org/abc\"),\n\t)\n\tif content.Body != \"please review\" {\n\t\tt.Fatalf(\"unexpected body: %q\", content.Body)\n\t}\n\tif content.FileName != \"voice.ogg\" {\n\t\tt.Fatalf(\"unexpected filename: %q\", content.FileName)\n\t}\n\tif content.Info == nil || content.Info.MimeType != \"audio/ogg\" {\n\t\tt.Fatalf(\"unexpected content type: %+v\", content.Info)\n\t}\n\tif content.Info == nil || content.Info.Size != 1234 {\n\t\tt.Fatalf(\"unexpected size: %+v\", content.Info)\n\t}\n\n\tnoCaption := matrixOutboundContent(\n\t\t\"\",\n\t\t\"image.png\",\n\t\tevent.MsgImage,\n\t\t\"image/png\",\n\t\t0,\n\t\tid.ContentURIString(\"mxc://matrix.org/def\"),\n\t)\n\tif noCaption.Body != \"image.png\" {\n\t\tt.Fatalf(\"unexpected fallback body: %q\", noCaption.Body)\n\t}\n}\n\nfunc TestMarkdownToHTML(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\tcontains string\n\t}{\n\t\t{\"bold\", \"**hello**\", \"<strong>hello</strong>\"},\n\t\t{\"italic\", \"_world_\", \"<em>world</em>\"},\n\t\t{\"header\", \"### Title\", \"<h3\"},\n\t\t{\"code block\", \"```\\nfoo()\\n```\", \"<code>\"},\n\t\t{\"inline code\", \"`x`\", \"<code>x</code>\"},\n\t\t{\"plain text\", \"just text\", \"just text\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := markdownToHTML(tt.input)\n\t\t\tif !strings.Contains(got, tt.contains) {\n\t\t\t\tt.Fatalf(\"markdownToHTML(%q) = %q, want it to contain %q\", tt.input, got, tt.contains)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMessageContent(t *testing.T) {\n\trichtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: \"richtext\"}}\n\tplain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: \"plain\"}}\n\tdefaultt := &MatrixChannel{config: config.MatrixConfig{}}\n\n\tfor _, c := range []*MatrixChannel{richtext, defaultt} {\n\t\tmc := c.messageContent(\"**hi**\")\n\t\tif mc.Format != event.FormatHTML {\n\t\t\tt.Errorf(\"format %q: expected FormatHTML, got %q\", c.config.MessageFormat, mc.Format)\n\t\t}\n\t\tif !strings.Contains(mc.FormattedBody, \"<strong>hi</strong>\") {\n\t\t\tt.Errorf(\"format %q: FormattedBody %q missing <strong>\", c.config.MessageFormat, mc.FormattedBody)\n\t\t}\n\t\tif mc.Body != \"**hi**\" {\n\t\t\tt.Errorf(\"format %q: Body should remain plain, got %q\", c.config.MessageFormat, mc.Body)\n\t\t}\n\t}\n\n\tmc := plain.messageContent(\"**hi**\")\n\tif mc.Format != \"\" || mc.FormattedBody != \"\" {\n\t\tt.Errorf(\"plain: expected no formatting, got format=%q formattedBody=%q\", mc.Format, mc.FormattedBody)\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/media.go",
    "content": "package channels\n\nimport (\n\t\"context\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n)\n\n// MediaSender is an optional interface for channels that can send\n// media attachments (images, files, audio, video).\n// Manager discovers channels implementing this interface via type\n// assertion and routes OutboundMediaMessage to them.\ntype MediaSender interface {\n\tSendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error\n}\n"
  },
  {
    "path": "pkg/channels/onebot/init.go",
    "content": "package onebot\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"onebot\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewOneBotChannel(cfg.Channels.OneBot, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/onebot/onebot.go",
    "content": "package onebot\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\ntype OneBotChannel struct {\n\t*channels.BaseChannel\n\tconfig        config.OneBotConfig\n\tconn          *websocket.Conn\n\tctx           context.Context\n\tcancel        context.CancelFunc\n\tdedup         map[string]struct{}\n\tdedupRing     []string\n\tdedupIdx      int\n\tmu            sync.Mutex\n\twriteMu       sync.Mutex\n\techoCounter   int64\n\tselfID        int64\n\tpending       map[string]chan json.RawMessage\n\tpendingMu     sync.Mutex\n\tlastMessageID sync.Map\n}\n\ntype oneBotRawEvent struct {\n\tPostType      string          `json:\"post_type\"`\n\tMessageType   string          `json:\"message_type\"`\n\tSubType       string          `json:\"sub_type\"`\n\tMessageID     json.RawMessage `json:\"message_id\"`\n\tUserID        json.RawMessage `json:\"user_id\"`\n\tGroupID       json.RawMessage `json:\"group_id\"`\n\tRawMessage    string          `json:\"raw_message\"`\n\tMessage       json.RawMessage `json:\"message\"`\n\tSender        json.RawMessage `json:\"sender\"`\n\tSelfID        json.RawMessage `json:\"self_id\"`\n\tTime          json.RawMessage `json:\"time\"`\n\tMetaEventType string          `json:\"meta_event_type\"`\n\tNoticeType    string          `json:\"notice_type\"`\n\tEcho          string          `json:\"echo\"`\n\tRetCode       json.RawMessage `json:\"retcode\"`\n\tStatus        json.RawMessage `json:\"status\"`\n\tData          json.RawMessage `json:\"data\"`\n}\n\ntype BotStatus struct {\n\tOnline bool `json:\"online\"`\n\tGood   bool `json:\"good\"`\n}\n\nfunc isAPIResponse(raw json.RawMessage) bool {\n\tif len(raw) == 0 {\n\t\treturn false\n\t}\n\tvar s string\n\tif json.Unmarshal(raw, &s) == nil {\n\t\treturn s == \"ok\" || s == \"failed\"\n\t}\n\tvar bs BotStatus\n\tif json.Unmarshal(raw, &bs) == nil {\n\t\treturn bs.Online || bs.Good\n\t}\n\treturn false\n}\n\ntype oneBotSender struct {\n\tUserID   json.RawMessage `json:\"user_id\"`\n\tNickname string          `json:\"nickname\"`\n\tCard     string          `json:\"card\"`\n}\n\ntype oneBotAPIRequest struct {\n\tAction string `json:\"action\"`\n\tParams any    `json:\"params\"`\n\tEcho   string `json:\"echo,omitempty\"`\n}\n\ntype oneBotMessageSegment struct {\n\tType string         `json:\"type\"`\n\tData map[string]any `json:\"data\"`\n}\n\nfunc NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {\n\tbase := channels.NewBaseChannel(\"onebot\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\tconst dedupSize = 1024\n\treturn &OneBotChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tdedup:       make(map[string]struct{}, dedupSize),\n\t\tdedupRing:   make([]string, dedupSize),\n\t\tdedupIdx:    0,\n\t\tpending:     make(map[string]chan json.RawMessage),\n\t}, nil\n}\n\nfunc (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {\n\tgo func() {\n\t\t_, err := c.sendAPIRequest(\"set_msg_emoji_like\", map[string]any{\n\t\t\t\"message_id\": messageID,\n\t\t\t\"emoji_id\":   emojiID,\n\t\t\t\"set\":        set,\n\t\t}, 5*time.Second)\n\t\tif err != nil {\n\t\t\tlogger.DebugCF(\"onebot\", \"Failed to set emoji like\", map[string]any{\n\t\t\t\t\"message_id\": messageID,\n\t\t\t\t\"error\":      err.Error(),\n\t\t\t})\n\t\t}\n\t}()\n}\n\n// ReactToMessage implements channels.ReactionCapable.\n// It adds an emoji reaction (ID 289) to group messages and returns an undo function.\n// Private messages return a no-op since reactions are only meaningful in groups.\nfunc (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {\n\t// Only react in group chats\n\tif !strings.HasPrefix(chatID, \"group:\") {\n\t\treturn func() {}, nil\n\t}\n\n\tc.setMsgEmojiLike(messageID, 289, true)\n\n\treturn func() {\n\t\tc.setMsgEmojiLike(messageID, 289, false)\n\t}, nil\n}\n\nfunc (c *OneBotChannel) Start(ctx context.Context) error {\n\tif c.config.WSUrl == \"\" {\n\t\treturn fmt.Errorf(\"OneBot ws_url not configured\")\n\t}\n\n\tlogger.InfoCF(\"onebot\", \"Starting OneBot channel\", map[string]any{\n\t\t\"ws_url\": c.config.WSUrl,\n\t})\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tif err := c.connect(); err != nil {\n\t\tlogger.WarnCF(\"onebot\", \"Initial connection failed, will retry in background\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t} else {\n\t\tgo c.listen()\n\t\tc.fetchSelfID()\n\t}\n\n\tif c.config.ReconnectInterval > 0 {\n\t\tgo c.reconnectLoop()\n\t} else {\n\t\tif c.conn == nil {\n\t\t\treturn fmt.Errorf(\"failed to connect to OneBot and reconnect is disabled\")\n\t\t}\n\t}\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"onebot\", \"OneBot channel started successfully\")\n\n\treturn nil\n}\n\nfunc (c *OneBotChannel) connect() error {\n\tdialer := websocket.DefaultDialer\n\tdialer.HandshakeTimeout = 10 * time.Second\n\n\theader := make(map[string][]string)\n\tif c.config.AccessToken != \"\" {\n\t\theader[\"Authorization\"] = []string{\"Bearer \" + c.config.AccessToken}\n\t}\n\n\tconn, resp, err := dialer.Dial(c.config.WSUrl, header)\n\tif resp != nil {\n\t\tresp.Body.Close()\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconn.SetPongHandler(func(appData string) error {\n\t\t_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))\n\t\treturn nil\n\t})\n\t_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))\n\n\tc.mu.Lock()\n\tc.conn = conn\n\tc.mu.Unlock()\n\n\tgo c.pinger(conn)\n\n\tlogger.InfoC(\"onebot\", \"WebSocket connected\")\n\treturn nil\n}\n\nfunc (c *OneBotChannel) pinger(conn *websocket.Conn) {\n\tticker := time.NewTicker(30 * time.Second)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tc.writeMu.Lock()\n\t\t\terr := conn.WriteMessage(websocket.PingMessage, nil)\n\t\t\tc.writeMu.Unlock()\n\t\t\tif err != nil {\n\t\t\t\tlogger.DebugCF(\"onebot\", \"Ping write failed, stopping pinger\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *OneBotChannel) fetchSelfID() {\n\tresp, err := c.sendAPIRequest(\"get_login_info\", nil, 5*time.Second)\n\tif err != nil {\n\t\tlogger.WarnCF(\"onebot\", \"Failed to get_login_info\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\ttype loginInfo struct {\n\t\tUserID   json.RawMessage `json:\"user_id\"`\n\t\tNickname string          `json:\"nickname\"`\n\t}\n\tfor _, extract := range []func() (*loginInfo, error){\n\t\tfunc() (*loginInfo, error) {\n\t\t\tvar w struct {\n\t\t\t\tData loginInfo `json:\"data\"`\n\t\t\t}\n\t\t\terr := json.Unmarshal(resp, &w)\n\t\t\treturn &w.Data, err\n\t\t},\n\t\tfunc() (*loginInfo, error) {\n\t\t\tvar f loginInfo\n\t\t\terr := json.Unmarshal(resp, &f)\n\t\t\treturn &f, err\n\t\t},\n\t} {\n\t\tinfo, err := extract()\n\t\tif err != nil || len(info.UserID) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {\n\t\t\tatomic.StoreInt64(&c.selfID, uid)\n\t\t\tlogger.InfoCF(\"onebot\", \"Bot self ID retrieved\", map[string]any{\n\t\t\t\t\"self_id\":  uid,\n\t\t\t\t\"nickname\": info.Nickname,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tlogger.WarnCF(\"onebot\", \"Could not parse self ID from get_login_info response\", map[string]any{\n\t\t\"response\": string(resp),\n\t})\n}\n\nfunc (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {\n\tc.mu.Lock()\n\tconn := c.conn\n\tc.mu.Unlock()\n\n\tif conn == nil {\n\t\treturn nil, fmt.Errorf(\"WebSocket not connected\")\n\t}\n\n\techo := fmt.Sprintf(\"api_%d_%d\", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1))\n\n\tch := make(chan json.RawMessage, 1)\n\tc.pendingMu.Lock()\n\tc.pending[echo] = ch\n\tc.pendingMu.Unlock()\n\n\tdefer func() {\n\t\tc.pendingMu.Lock()\n\t\tdelete(c.pending, echo)\n\t\tc.pendingMu.Unlock()\n\t}()\n\n\treq := oneBotAPIRequest{\n\t\tAction: action,\n\t\tParams: params,\n\t\tEcho:   echo,\n\t}\n\n\tdata, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal API request: %w\", err)\n\t}\n\n\tc.writeMu.Lock()\n\t_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))\n\terr = conn.WriteMessage(websocket.TextMessage, data)\n\t_ = conn.SetWriteDeadline(time.Time{})\n\tc.writeMu.Unlock()\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to write API request: %w\", err)\n\t}\n\n\tselect {\n\tcase resp := <-ch:\n\t\tif resp == nil {\n\t\t\treturn nil, fmt.Errorf(\"API request %s: channel stopped\", action)\n\t\t}\n\t\treturn resp, nil\n\tcase <-time.After(timeout):\n\t\treturn nil, fmt.Errorf(\"API request %s timed out after %v\", action, timeout)\n\tcase <-c.ctx.Done():\n\t\treturn nil, fmt.Errorf(\"context canceled\")\n\t}\n}\n\nfunc (c *OneBotChannel) reconnectLoop() {\n\tinterval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second)\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(interval):\n\t\t\tc.mu.Lock()\n\t\t\tconn := c.conn\n\t\t\tc.mu.Unlock()\n\n\t\t\tif conn == nil {\n\t\t\t\tlogger.InfoC(\"onebot\", \"Attempting to reconnect...\")\n\t\t\t\tif err := c.connect(); err != nil {\n\t\t\t\t\tlogger.ErrorCF(\"onebot\", \"Reconnect failed\", map[string]any{\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\tgo c.listen()\n\t\t\t\t\tc.fetchSelfID()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *OneBotChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"onebot\", \"Stopping OneBot channel\")\n\tc.SetRunning(false)\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.pendingMu.Lock()\n\tfor echo, ch := range c.pending {\n\t\tselect {\n\t\tcase ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines\n\t\tdefault:\n\t\t}\n\t\tdelete(c.pending, echo)\n\t}\n\tc.pendingMu.Unlock()\n\n\tc.mu.Lock()\n\tif c.conn != nil {\n\t\tc.conn.Close()\n\t\tc.conn = nil\n\t}\n\tc.mu.Unlock()\n\n\treturn nil\n}\n\nfunc (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// Check ctx before entering write path\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tdefault:\n\t}\n\n\tc.mu.Lock()\n\tconn := c.conn\n\tc.mu.Unlock()\n\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"OneBot WebSocket not connected\")\n\t}\n\n\taction, params, err := c.buildSendRequest(msg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\techo := fmt.Sprintf(\"send_%d\", atomic.AddInt64(&c.echoCounter, 1))\n\n\treq := oneBotAPIRequest{\n\t\tAction: action,\n\t\tParams: params,\n\t\tEcho:   echo,\n\t}\n\n\tdata, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal OneBot request: %w\", err)\n\t}\n\n\tc.writeMu.Lock()\n\t_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))\n\terr = conn.WriteMessage(websocket.TextMessage, data)\n\t_ = conn.SetWriteDeadline(time.Time{})\n\tc.writeMu.Unlock()\n\n\tif err != nil {\n\t\tlogger.ErrorCF(\"onebot\", \"Failed to send message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn fmt.Errorf(\"onebot send: %w\", channels.ErrTemporary)\n\t}\n\n\treturn nil\n}\n\n// SendMedia implements the channels.MediaSender interface.\nfunc (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tdefault:\n\t}\n\n\tc.mu.Lock()\n\tconn := c.conn\n\tc.mu.Unlock()\n\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"OneBot WebSocket not connected\")\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\t// Build media segments\n\tvar segments []oneBotMessageSegment\n\tfor _, part := range msg.Parts {\n\t\tlocalPath, err := store.Resolve(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"onebot\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tvar segType string\n\t\tswitch part.Type {\n\t\tcase \"image\":\n\t\t\tsegType = \"image\"\n\t\tcase \"video\":\n\t\t\tsegType = \"video\"\n\t\tcase \"audio\":\n\t\t\tsegType = \"record\"\n\t\tdefault:\n\t\t\tsegType = \"file\"\n\t\t}\n\n\t\tsegments = append(segments, oneBotMessageSegment{\n\t\t\tType: segType,\n\t\t\tData: map[string]any{\"file\": \"file://\" + localPath},\n\t\t})\n\n\t\tif part.Caption != \"\" {\n\t\t\tsegments = append(segments, oneBotMessageSegment{\n\t\t\t\tType: \"text\",\n\t\t\t\tData: map[string]any{\"text\": part.Caption},\n\t\t\t})\n\t\t}\n\t}\n\n\tif len(segments) == 0 {\n\t\treturn nil\n\t}\n\n\tchatID := msg.ChatID\n\tvar action, idKey string\n\tvar rawID string\n\tif rest, ok := strings.CutPrefix(chatID, \"group:\"); ok {\n\t\taction, idKey, rawID = \"send_group_msg\", \"group_id\", rest\n\t} else if rest, ok := strings.CutPrefix(chatID, \"private:\"); ok {\n\t\taction, idKey, rawID = \"send_private_msg\", \"user_id\", rest\n\t} else {\n\t\taction, idKey, rawID = \"send_private_msg\", \"user_id\", chatID\n\t}\n\n\tid, err := strconv.ParseInt(rawID, 10, 64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid %s in chatID: %s: %w\", idKey, chatID, channels.ErrSendFailed)\n\t}\n\n\techo := fmt.Sprintf(\"send_%d\", atomic.AddInt64(&c.echoCounter, 1))\n\n\treq := oneBotAPIRequest{\n\t\tAction: action,\n\t\tParams: map[string]any{idKey: id, \"message\": segments},\n\t\tEcho:   echo,\n\t}\n\n\tdata, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal OneBot request: %w\", err)\n\t}\n\n\tc.writeMu.Lock()\n\t_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))\n\terr = conn.WriteMessage(websocket.TextMessage, data)\n\t_ = conn.SetWriteDeadline(time.Time{})\n\tc.writeMu.Unlock()\n\n\tif err != nil {\n\t\tlogger.ErrorCF(\"onebot\", \"Failed to send media message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn fmt.Errorf(\"onebot send media: %w\", channels.ErrTemporary)\n\t}\n\n\treturn nil\n}\n\nfunc (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {\n\tvar segments []oneBotMessageSegment\n\n\tif lastMsgID, ok := c.lastMessageID.Load(chatID); ok {\n\t\tif msgID, ok := lastMsgID.(string); ok && msgID != \"\" {\n\t\t\tsegments = append(segments, oneBotMessageSegment{\n\t\t\t\tType: \"reply\",\n\t\t\t\tData: map[string]any{\"id\": msgID},\n\t\t\t})\n\t\t}\n\t}\n\n\tsegments = append(segments, oneBotMessageSegment{\n\t\tType: \"text\",\n\t\tData: map[string]any{\"text\": content},\n\t})\n\n\treturn segments\n}\n\nfunc (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {\n\tchatID := msg.ChatID\n\tsegments := c.buildMessageSegments(chatID, msg.Content)\n\n\tvar action, idKey string\n\tvar rawID string\n\tif rest, ok := strings.CutPrefix(chatID, \"group:\"); ok {\n\t\taction, idKey, rawID = \"send_group_msg\", \"group_id\", rest\n\t} else if rest, ok := strings.CutPrefix(chatID, \"private:\"); ok {\n\t\taction, idKey, rawID = \"send_private_msg\", \"user_id\", rest\n\t} else {\n\t\taction, idKey, rawID = \"send_private_msg\", \"user_id\", chatID\n\t}\n\n\tid, err := strconv.ParseInt(rawID, 10, 64)\n\tif err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid %s in chatID: %s\", idKey, chatID)\n\t}\n\treturn action, map[string]any{idKey: id, \"message\": segments}, nil\n}\n\nfunc (c *OneBotChannel) listen() {\n\tc.mu.Lock()\n\tconn := c.conn\n\tc.mu.Unlock()\n\n\tif conn == nil {\n\t\tlogger.WarnC(\"onebot\", \"WebSocket connection is nil, listener exiting\")\n\t\treturn\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t\t_, message, err := conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorCF(\"onebot\", \"WebSocket read error\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tc.mu.Lock()\n\t\t\t\tif c.conn == conn {\n\t\t\t\t\tc.conn.Close()\n\t\t\t\t\tc.conn = nil\n\t\t\t\t}\n\t\t\t\tc.mu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))\n\n\t\t\tvar raw oneBotRawEvent\n\t\t\tif err := json.Unmarshal(message, &raw); err != nil {\n\t\t\t\tlogger.WarnCF(\"onebot\", \"Failed to unmarshal raw event\", map[string]any{\n\t\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t\t\"payload\": string(message),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.DebugCF(\"onebot\", \"WebSocket event\", map[string]any{\n\t\t\t\t\"length\":    len(message),\n\t\t\t\t\"post_type\": raw.PostType,\n\t\t\t\t\"sub_type\":  raw.SubType,\n\t\t\t})\n\n\t\t\tif raw.Echo != \"\" {\n\t\t\t\tc.pendingMu.Lock()\n\t\t\t\tch, ok := c.pending[raw.Echo]\n\t\t\t\tc.pendingMu.Unlock()\n\n\t\t\t\tif ok {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase ch <- message:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tlogger.DebugCF(\"onebot\", \"Received API response (no waiter)\", map[string]any{\n\t\t\t\t\t\t\"echo\":   raw.Echo,\n\t\t\t\t\t\t\"status\": string(raw.Status),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif isAPIResponse(raw.Status) {\n\t\t\t\tlogger.DebugCF(\"onebot\", \"Received API response without echo, skipping\", map[string]any{\n\t\t\t\t\t\"status\": string(raw.Status),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tc.handleRawEvent(&raw)\n\t\t}\n\t}\n}\n\nfunc parseJSONInt64(raw json.RawMessage) (int64, error) {\n\tif len(raw) == 0 {\n\t\treturn 0, nil\n\t}\n\n\tvar n int64\n\tif err := json.Unmarshal(raw, &n); err == nil {\n\t\treturn n, nil\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\treturn strconv.ParseInt(s, 10, 64)\n\t}\n\treturn 0, fmt.Errorf(\"cannot parse as int64: %s\", string(raw))\n}\n\nfunc parseJSONString(raw json.RawMessage) string {\n\tif len(raw) == 0 {\n\t\treturn \"\"\n\t}\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\treturn s\n\t}\n\n\treturn string(raw)\n}\n\ntype parseMessageResult struct {\n\tText           string\n\tIsBotMentioned bool\n\tMedia          []string\n\tReplyTo        string\n}\n\nfunc (c *OneBotChannel) parseMessageSegments(\n\traw json.RawMessage,\n\tselfID int64,\n\tstore media.MediaStore,\n\tscope string,\n) parseMessageResult {\n\tif len(raw) == 0 {\n\t\treturn parseMessageResult{}\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\tmentioned := false\n\t\tif selfID > 0 {\n\t\t\tcqAt := fmt.Sprintf(\"[CQ:at,qq=%d]\", selfID)\n\t\t\tif strings.Contains(s, cqAt) {\n\t\t\t\tmentioned = true\n\t\t\t\ts = strings.ReplaceAll(s, cqAt, \"\")\n\t\t\t\ts = strings.TrimSpace(s)\n\t\t\t}\n\t\t}\n\t\treturn parseMessageResult{Text: s, IsBotMentioned: mentioned}\n\t}\n\n\tvar segments []map[string]any\n\tif err := json.Unmarshal(raw, &segments); err != nil {\n\t\treturn parseMessageResult{}\n\t}\n\n\tvar textParts []string\n\tmentioned := false\n\tselfIDStr := strconv.FormatInt(selfID, 10)\n\tvar mediaRefs []string\n\tvar replyTo string\n\n\t// Helper to register a local file with the media store\n\tstoreFile := func(localPath, filename string) string {\n\t\tif store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename: filename,\n\t\t\t\tSource:   \"onebot\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath // fallback\n\t}\n\n\tfor _, seg := range segments {\n\t\tsegType, _ := seg[\"type\"].(string)\n\t\tdata, _ := seg[\"data\"].(map[string]any)\n\n\t\tswitch segType {\n\t\tcase \"text\":\n\t\t\tif data != nil {\n\t\t\t\tif t, ok := data[\"text\"].(string); ok {\n\t\t\t\t\ttextParts = append(textParts, t)\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"at\":\n\t\t\tif data != nil && selfID > 0 {\n\t\t\t\tqqVal := fmt.Sprintf(\"%v\", data[\"qq\"])\n\t\t\t\tif qqVal == selfIDStr || qqVal == \"all\" {\n\t\t\t\t\tmentioned = true\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"image\", \"video\", \"file\":\n\t\t\tif data != nil {\n\t\t\t\turl, _ := data[\"url\"].(string)\n\t\t\t\tif url != \"\" {\n\t\t\t\t\tdefaults := map[string]string{\"image\": \"image.jpg\", \"video\": \"video.mp4\", \"file\": \"file\"}\n\t\t\t\t\tfilename := defaults[segType]\n\t\t\t\t\tif f, ok := data[\"file\"].(string); ok && f != \"\" {\n\t\t\t\t\t\tfilename = f\n\t\t\t\t\t} else if n, ok := data[\"name\"].(string); ok && n != \"\" {\n\t\t\t\t\t\tfilename = n\n\t\t\t\t\t}\n\t\t\t\t\tlocalPath := utils.DownloadFile(url, filename, utils.DownloadOptions{\n\t\t\t\t\t\tLoggerPrefix: \"onebot\",\n\t\t\t\t\t})\n\t\t\t\t\tif localPath != \"\" {\n\t\t\t\t\t\tmediaRefs = append(mediaRefs, storeFile(localPath, filename))\n\t\t\t\t\t\ttextParts = append(textParts, fmt.Sprintf(\"[%s]\", segType))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"record\":\n\t\t\tif data != nil {\n\t\t\t\turl, _ := data[\"url\"].(string)\n\t\t\t\tif url != \"\" {\n\t\t\t\t\tlocalPath := utils.DownloadFile(url, \"voice.amr\", utils.DownloadOptions{\n\t\t\t\t\t\tLoggerPrefix: \"onebot\",\n\t\t\t\t\t})\n\t\t\t\t\tif localPath != \"\" {\n\t\t\t\t\t\ttextParts = append(textParts, \"[voice]\")\n\t\t\t\t\t\tmediaRefs = append(mediaRefs, storeFile(localPath, \"voice.amr\"))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"reply\":\n\t\t\tif data != nil {\n\t\t\t\tif id, ok := data[\"id\"]; ok {\n\t\t\t\t\treplyTo = fmt.Sprintf(\"%v\", id)\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"face\":\n\t\t\tif data != nil {\n\t\t\t\tfaceID, _ := data[\"id\"]\n\t\t\t\ttextParts = append(textParts, fmt.Sprintf(\"[face:%v]\", faceID))\n\t\t\t}\n\n\t\tcase \"forward\":\n\t\t\ttextParts = append(textParts, \"[forward message]\")\n\n\t\tdefault:\n\t\t}\n\t}\n\n\treturn parseMessageResult{\n\t\tText:           strings.TrimSpace(strings.Join(textParts, \"\")),\n\t\tIsBotMentioned: mentioned,\n\t\tMedia:          mediaRefs,\n\t\tReplyTo:        replyTo,\n\t}\n}\n\nfunc (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {\n\tswitch raw.PostType {\n\tcase \"message\":\n\t\tif userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {\n\t\t\t// Build minimal sender for allowlist check\n\t\t\tsender := bus.SenderInfo{\n\t\t\t\tPlatform:    \"onebot\",\n\t\t\t\tPlatformID:  strconv.FormatInt(userID, 10),\n\t\t\t\tCanonicalID: identity.BuildCanonicalID(\"onebot\", strconv.FormatInt(userID, 10)),\n\t\t\t}\n\t\t\tif !c.IsAllowedSender(sender) {\n\t\t\t\tlogger.DebugCF(\"onebot\", \"Message rejected by allowlist\", map[string]any{\n\t\t\t\t\t\"user_id\": userID,\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tc.handleMessage(raw)\n\n\tcase \"message_sent\":\n\t\tlogger.DebugCF(\"onebot\", \"Bot sent message event\", map[string]any{\n\t\t\t\"message_type\": raw.MessageType,\n\t\t\t\"message_id\":   parseJSONString(raw.MessageID),\n\t\t})\n\n\tcase \"meta_event\":\n\t\tc.handleMetaEvent(raw)\n\n\tcase \"notice\":\n\t\tc.handleNoticeEvent(raw)\n\n\tcase \"request\":\n\t\tlogger.DebugCF(\"onebot\", \"Request event received\", map[string]any{\n\t\t\t\"sub_type\": raw.SubType,\n\t\t})\n\n\tcase \"\":\n\t\tlogger.DebugCF(\"onebot\", \"Event with empty post_type (possibly API response)\", map[string]any{\n\t\t\t\"echo\":   raw.Echo,\n\t\t\t\"status\": raw.Status,\n\t\t})\n\n\tdefault:\n\t\tlogger.DebugCF(\"onebot\", \"Unknown post_type\", map[string]any{\n\t\t\t\"post_type\": raw.PostType,\n\t\t})\n\t}\n}\n\nfunc (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {\n\tif raw.MetaEventType == \"lifecycle\" {\n\t\tlogger.InfoCF(\"onebot\", \"Lifecycle event\", map[string]any{\"sub_type\": raw.SubType})\n\t} else if raw.MetaEventType != \"heartbeat\" {\n\t\tlogger.DebugCF(\"onebot\", \"Meta event: \"+raw.MetaEventType, nil)\n\t}\n}\n\nfunc (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {\n\tfields := map[string]any{\n\t\t\"notice_type\": raw.NoticeType,\n\t\t\"sub_type\":    raw.SubType,\n\t\t\"group_id\":    parseJSONString(raw.GroupID),\n\t\t\"user_id\":     parseJSONString(raw.UserID),\n\t\t\"message_id\":  parseJSONString(raw.MessageID),\n\t}\n\tswitch raw.NoticeType {\n\tcase \"group_recall\", \"group_increase\", \"group_decrease\",\n\t\t\"friend_add\", \"group_admin\", \"group_ban\":\n\t\tlogger.InfoCF(\"onebot\", \"Notice: \"+raw.NoticeType, fields)\n\tdefault:\n\t\tlogger.DebugCF(\"onebot\", \"Notice: \"+raw.NoticeType, fields)\n\t}\n}\n\nfunc (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {\n\t// Parse fields from raw event\n\tuserID, err := parseJSONInt64(raw.UserID)\n\tif err != nil {\n\t\tlogger.WarnCF(\"onebot\", \"Failed to parse user_id\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t\t\"raw\":   string(raw.UserID),\n\t\t})\n\t\treturn\n\t}\n\n\tgroupID, _ := parseJSONInt64(raw.GroupID)\n\tselfID, _ := parseJSONInt64(raw.SelfID)\n\tmessageID := parseJSONString(raw.MessageID)\n\n\tif selfID == 0 {\n\t\tselfID = atomic.LoadInt64(&c.selfID)\n\t}\n\n\t// Compute scope for media store before parsing (parsing may download files)\n\tvar chatIDForScope string\n\tswitch raw.MessageType {\n\tcase \"group\":\n\t\tchatIDForScope = \"group:\" + strconv.FormatInt(groupID, 10)\n\tdefault:\n\t\tchatIDForScope = \"private:\" + strconv.FormatInt(userID, 10)\n\t}\n\tscope := channels.BuildMediaScope(\"onebot\", chatIDForScope, messageID)\n\n\tparsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope)\n\tisBotMentioned := parsed.IsBotMentioned\n\n\tcontent := raw.RawMessage\n\tif content == \"\" {\n\t\tcontent = parsed.Text\n\t} else if selfID > 0 {\n\t\tcqAt := fmt.Sprintf(\"[CQ:at,qq=%d]\", selfID)\n\t\tif strings.Contains(content, cqAt) {\n\t\t\tisBotMentioned = true\n\t\t\tcontent = strings.ReplaceAll(content, cqAt, \"\")\n\t\t\tcontent = strings.TrimSpace(content)\n\t\t}\n\t}\n\n\tif parsed.Text != \"\" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != \"\") {\n\t\tcontent = parsed.Text\n\t}\n\n\tvar sender oneBotSender\n\tif len(raw.Sender) > 0 {\n\t\tif err := json.Unmarshal(raw.Sender, &sender); err != nil {\n\t\t\tlogger.WarnCF(\"onebot\", \"Failed to parse sender\", map[string]any{\n\t\t\t\t\"error\":  err.Error(),\n\t\t\t\t\"sender\": string(raw.Sender),\n\t\t\t})\n\t\t}\n\t}\n\n\tif c.isDuplicate(messageID) {\n\t\tlogger.DebugCF(\"onebot\", \"Duplicate message, skipping\", map[string]any{\n\t\t\t\"message_id\": messageID,\n\t\t})\n\t\treturn\n\t}\n\n\tif content == \"\" {\n\t\tlogger.DebugCF(\"onebot\", \"Received empty message, ignoring\", map[string]any{\n\t\t\t\"message_id\": messageID,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := strconv.FormatInt(userID, 10)\n\tvar chatID string\n\n\tvar peer bus.Peer\n\n\tmetadata := map[string]string{}\n\n\tif parsed.ReplyTo != \"\" {\n\t\tmetadata[\"reply_to_message_id\"] = parsed.ReplyTo\n\t}\n\n\tswitch raw.MessageType {\n\tcase \"private\":\n\t\tchatID = \"private:\" + senderID\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: senderID}\n\n\tcase \"group\":\n\t\tgroupIDStr := strconv.FormatInt(groupID, 10)\n\t\tchatID = \"group:\" + groupIDStr\n\t\tpeer = bus.Peer{Kind: \"group\", ID: groupIDStr}\n\t\tmetadata[\"group_id\"] = groupIDStr\n\n\t\tsenderUserID, _ := parseJSONInt64(sender.UserID)\n\t\tif senderUserID > 0 {\n\t\t\tmetadata[\"sender_user_id\"] = strconv.FormatInt(senderUserID, 10)\n\t\t}\n\n\t\tif sender.Card != \"\" {\n\t\t\tmetadata[\"sender_name\"] = sender.Card\n\t\t} else if sender.Nickname != \"\" {\n\t\t\tmetadata[\"sender_name\"] = sender.Nickname\n\t\t}\n\n\t\trespond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)\n\t\tif !respond {\n\t\t\tlogger.DebugCF(\"onebot\", \"Group message ignored (no trigger)\", map[string]any{\n\t\t\t\t\"sender\":       senderID,\n\t\t\t\t\"group\":        groupIDStr,\n\t\t\t\t\"is_mentioned\": isBotMentioned,\n\t\t\t\t\"content\":      truncate(content, 100),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tcontent = strippedContent\n\n\tdefault:\n\t\tlogger.WarnCF(\"onebot\", \"Unknown message type, cannot route\", map[string]any{\n\t\t\t\"type\":       raw.MessageType,\n\t\t\t\"message_id\": messageID,\n\t\t\t\"user_id\":    userID,\n\t\t})\n\t\treturn\n\t}\n\n\tlogger.InfoCF(\"onebot\", \"Received \"+raw.MessageType+\" message\", map[string]any{\n\t\t\"sender\":      senderID,\n\t\t\"chat_id\":     chatID,\n\t\t\"message_id\":  messageID,\n\t\t\"length\":      len(content),\n\t\t\"content\":     truncate(content, 100),\n\t\t\"media_count\": len(parsed.Media),\n\t})\n\n\tif sender.Nickname != \"\" {\n\t\tmetadata[\"nickname\"] = sender.Nickname\n\t}\n\n\tc.lastMessageID.Store(chatID, messageID)\n\n\tsenderInfo := bus.SenderInfo{\n\t\tPlatform:    \"onebot\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"onebot\", senderID),\n\t\tDisplayName: sender.Nickname,\n\t}\n\n\tif !c.IsAllowedSender(senderInfo) {\n\t\tlogger.DebugCF(\"onebot\", \"Message rejected by allowlist (senderInfo)\", map[string]any{\n\t\t\t\"sender\": senderID,\n\t\t})\n\t\treturn\n\t}\n\n\tc.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)\n}\n\nfunc (c *OneBotChannel) isDuplicate(messageID string) bool {\n\tif messageID == \"\" || messageID == \"0\" {\n\t\treturn false\n\t}\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif _, exists := c.dedup[messageID]; exists {\n\t\treturn true\n\t}\n\n\tif old := c.dedupRing[c.dedupIdx]; old != \"\" {\n\t\tdelete(c.dedup, old)\n\t}\n\tc.dedupRing[c.dedupIdx] = messageID\n\tc.dedup[messageID] = struct{}{}\n\tc.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)\n\n\treturn false\n}\n\nfunc truncate(s string, n int) string {\n\trunes := []rune(s)\n\tif len(runes) <= n {\n\t\treturn s\n\t}\n\treturn string(runes[:n]) + \"...\"\n}\n"
  },
  {
    "path": "pkg/channels/pico/init.go",
    "content": "package pico\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"pico\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewPicoChannel(cfg.Channels.Pico, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/pico/pico.go",
    "content": "package pico\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// picoConn represents a single WebSocket connection.\ntype picoConn struct {\n\tid        string\n\tconn      *websocket.Conn\n\tsessionID string\n\twriteMu   sync.Mutex\n\tclosed    atomic.Bool\n}\n\n// writeJSON sends a JSON message to the connection with write locking.\nfunc (pc *picoConn) writeJSON(v any) error {\n\tif pc.closed.Load() {\n\t\treturn fmt.Errorf(\"connection closed\")\n\t}\n\tpc.writeMu.Lock()\n\tdefer pc.writeMu.Unlock()\n\treturn pc.conn.WriteJSON(v)\n}\n\n// close closes the connection.\nfunc (pc *picoConn) close() {\n\tif pc.closed.CompareAndSwap(false, true) {\n\t\tpc.conn.Close()\n\t}\n}\n\n// PicoChannel implements the native Pico Protocol WebSocket channel.\n// It serves as the reference implementation for all optional capability interfaces.\ntype PicoChannel struct {\n\t*channels.BaseChannel\n\tconfig      config.PicoConfig\n\tupgrader    websocket.Upgrader\n\tconnections sync.Map // connID → *picoConn\n\tconnCount   atomic.Int32\n\tctx         context.Context\n\tcancel      context.CancelFunc\n}\n\n// NewPicoChannel creates a new Pico Protocol channel.\nfunc NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {\n\tif cfg.Token == \"\" {\n\t\treturn nil, fmt.Errorf(\"pico token is required\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"pico\", cfg, messageBus, cfg.AllowFrom)\n\n\tallowOrigins := cfg.AllowOrigins\n\tcheckOrigin := func(r *http.Request) bool {\n\t\tif len(allowOrigins) == 0 {\n\t\t\treturn true // allow all if not configured\n\t\t}\n\t\torigin := r.Header.Get(\"Origin\")\n\t\tfor _, allowed := range allowOrigins {\n\t\t\tif allowed == \"*\" || allowed == origin {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\treturn &PicoChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tupgrader: websocket.Upgrader{\n\t\t\tCheckOrigin:     checkOrigin,\n\t\t\tReadBufferSize:  1024,\n\t\t\tWriteBufferSize: 1024,\n\t\t},\n\t}, nil\n}\n\n// Start implements Channel.\nfunc (c *PicoChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"pico\", \"Starting Pico Protocol channel\")\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\tc.SetRunning(true)\n\tlogger.InfoC(\"pico\", \"Pico Protocol channel started\")\n\treturn nil\n}\n\n// Stop implements Channel.\nfunc (c *PicoChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"pico\", \"Stopping Pico Protocol channel\")\n\tc.SetRunning(false)\n\n\t// Close all connections\n\tc.connections.Range(func(key, value any) bool {\n\t\tif pc, ok := value.(*picoConn); ok {\n\t\t\tpc.close()\n\t\t}\n\t\tc.connections.Delete(key)\n\t\treturn true\n\t})\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tlogger.InfoC(\"pico\", \"Pico Protocol channel stopped\")\n\treturn nil\n}\n\n// WebhookPath implements channels.WebhookHandler.\nfunc (c *PicoChannel) WebhookPath() string { return \"/pico/\" }\n\n// ServeHTTP implements http.Handler for the shared HTTP server.\nfunc (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tpath := strings.TrimPrefix(r.URL.Path, \"/pico\")\n\n\tswitch {\n\tcase path == \"/ws\" || path == \"/ws/\":\n\t\tc.handleWebSocket(w, r)\n\tdefault:\n\t\thttp.NotFound(w, r)\n\t}\n}\n\n// Send implements Channel — sends a message to the appropriate WebSocket connection.\nfunc (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\toutMsg := newMessage(TypeMessageCreate, map[string]any{\n\t\t\"content\": msg.Content,\n\t})\n\n\treturn c.broadcastToSession(msg.ChatID, outMsg)\n}\n\n// EditMessage implements channels.MessageEditor.\nfunc (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {\n\toutMsg := newMessage(TypeMessageUpdate, map[string]any{\n\t\t\"message_id\": messageID,\n\t\t\"content\":    content,\n\t})\n\treturn c.broadcastToSession(chatID, outMsg)\n}\n\n// StartTyping implements channels.TypingCapable.\nfunc (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tstartMsg := newMessage(TypeTypingStart, nil)\n\tif err := c.broadcastToSession(chatID, startMsg); err != nil {\n\t\treturn func() {}, err\n\t}\n\treturn func() {\n\t\tstopMsg := newMessage(TypeTypingStop, nil)\n\t\tc.broadcastToSession(chatID, stopMsg)\n\t}, nil\n}\n\n// SendPlaceholder implements channels.PlaceholderCapable.\n// It sends a placeholder message via the Pico Protocol that will later be\n// edited to the actual response via EditMessage (channels.MessageEditor).\nfunc (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tif !c.config.Placeholder.Enabled {\n\t\treturn \"\", nil\n\t}\n\n\ttext := c.config.Placeholder.Text\n\tif text == \"\" {\n\t\ttext = \"Thinking... 💭\"\n\t}\n\n\tmsgID := uuid.New().String()\n\toutMsg := newMessage(TypeMessageCreate, map[string]any{\n\t\t\"content\":    text,\n\t\t\"message_id\": msgID,\n\t})\n\n\tif err := c.broadcastToSession(chatID, outMsg); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn msgID, nil\n}\n\n// broadcastToSession sends a message to all connections with a matching session.\nfunc (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {\n\t// chatID format: \"pico:<sessionID>\"\n\tsessionID := strings.TrimPrefix(chatID, \"pico:\")\n\tmsg.SessionID = sessionID\n\n\tvar sent bool\n\tc.connections.Range(func(key, value any) bool {\n\t\tpc, ok := value.(*picoConn)\n\t\tif !ok {\n\t\t\treturn true\n\t\t}\n\t\tif pc.sessionID == sessionID {\n\t\t\tif err := pc.writeJSON(msg); err != nil {\n\t\t\t\tlogger.DebugCF(\"pico\", \"Write to connection failed\", map[string]any{\n\t\t\t\t\t\"conn_id\": pc.id,\n\t\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tsent = true\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\tif !sent {\n\t\treturn fmt.Errorf(\"no active connections for session %s: %w\", sessionID, channels.ErrSendFailed)\n\t}\n\treturn nil\n}\n\n// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.\nfunc (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {\n\tif !c.IsRunning() {\n\t\thttp.Error(w, \"channel not running\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\t// Authenticate\n\tif !c.authenticate(r) {\n\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\t// Check connection limit\n\tmaxConns := c.config.MaxConnections\n\tif maxConns <= 0 {\n\t\tmaxConns = 100\n\t}\n\tif int(c.connCount.Load()) >= maxConns {\n\t\thttp.Error(w, \"too many connections\", http.StatusServiceUnavailable)\n\t\treturn\n\t}\n\n\t// Echo the matched subprotocol back so the browser accepts the upgrade.\n\tvar responseHeader http.Header\n\tif proto := c.matchedSubprotocol(r); proto != \"\" {\n\t\tresponseHeader = http.Header{\"Sec-WebSocket-Protocol\": {proto}}\n\t}\n\n\tconn, err := c.upgrader.Upgrade(w, r, responseHeader)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"pico\", \"WebSocket upgrade failed\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// Determine session ID from query param or generate one\n\tsessionID := r.URL.Query().Get(\"session_id\")\n\tif sessionID == \"\" {\n\t\tsessionID = uuid.New().String()\n\t}\n\n\tpc := &picoConn{\n\t\tid:        uuid.New().String(),\n\t\tconn:      conn,\n\t\tsessionID: sessionID,\n\t}\n\n\tc.connections.Store(pc.id, pc)\n\tc.connCount.Add(1)\n\n\tlogger.InfoCF(\"pico\", \"WebSocket client connected\", map[string]any{\n\t\t\"conn_id\":    pc.id,\n\t\t\"session_id\": sessionID,\n\t})\n\n\tgo c.readLoop(pc)\n}\n\n// authenticate checks the request for a valid token:\n//  1. Authorization: Bearer <token> header\n//  2. Sec-WebSocket-Protocol \"token.<value>\" (for browsers that can't set headers)\n//  3. Query parameter \"token\" (only when AllowTokenQuery is on)\nfunc (c *PicoChannel) authenticate(r *http.Request) bool {\n\ttoken := c.config.Token\n\tif token == \"\" {\n\t\treturn false\n\t}\n\n\t// Check Authorization header\n\tauth := r.Header.Get(\"Authorization\")\n\tif after, ok := strings.CutPrefix(auth, \"Bearer \"); ok {\n\t\tif after == token {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Check Sec-WebSocket-Protocol subprotocol (\"token.<value>\")\n\tif c.matchedSubprotocol(r) != \"\" {\n\t\treturn true\n\t}\n\n\t// Check query parameter only when explicitly allowed\n\tif c.config.AllowTokenQuery {\n\t\tif r.URL.Query().Get(\"token\") == token {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// matchedSubprotocol returns the \"token.<value>\" subprotocol that matches\n// the configured token, or \"\" if none do.\nfunc (c *PicoChannel) matchedSubprotocol(r *http.Request) string {\n\ttoken := c.config.Token\n\tfor _, proto := range websocket.Subprotocols(r) {\n\t\tif after, ok := strings.CutPrefix(proto, \"token.\"); ok && after == token {\n\t\t\treturn proto\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// readLoop reads messages from a WebSocket connection.\nfunc (c *PicoChannel) readLoop(pc *picoConn) {\n\tdefer func() {\n\t\tpc.close()\n\t\tc.connections.Delete(pc.id)\n\t\tc.connCount.Add(-1)\n\t\tlogger.InfoCF(\"pico\", \"WebSocket client disconnected\", map[string]any{\n\t\t\t\"conn_id\":    pc.id,\n\t\t\t\"session_id\": pc.sessionID,\n\t\t})\n\t}()\n\n\treadTimeout := time.Duration(c.config.ReadTimeout) * time.Second\n\tif readTimeout <= 0 {\n\t\treadTimeout = 60 * time.Second\n\t}\n\n\t_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))\n\tpc.conn.SetPongHandler(func(appData string) error {\n\t\t_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))\n\t\treturn nil\n\t})\n\n\t// Start ping ticker\n\tpingInterval := time.Duration(c.config.PingInterval) * time.Second\n\tif pingInterval <= 0 {\n\t\tpingInterval = 30 * time.Second\n\t}\n\tgo c.pingLoop(pc, pingInterval)\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\t_, rawMsg, err := pc.conn.ReadMessage()\n\t\tif err != nil {\n\t\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {\n\t\t\t\tlogger.DebugCF(\"pico\", \"WebSocket read error\", map[string]any{\n\t\t\t\t\t\"conn_id\": pc.id,\n\t\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))\n\n\t\tvar msg PicoMessage\n\t\tif err := json.Unmarshal(rawMsg, &msg); err != nil {\n\t\t\terrMsg := newError(\"invalid_message\", \"failed to parse message\")\n\t\t\tpc.writeJSON(errMsg)\n\t\t\tcontinue\n\t\t}\n\n\t\tc.handleMessage(pc, msg)\n\t}\n}\n\n// pingLoop sends periodic ping frames to keep the connection alive.\nfunc (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {\n\tticker := time.NewTicker(interval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tif pc.closed.Load() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tpc.writeMu.Lock()\n\t\t\terr := pc.conn.WriteMessage(websocket.PingMessage, nil)\n\t\t\tpc.writeMu.Unlock()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\n// handleMessage processes an inbound Pico Protocol message.\nfunc (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {\n\tswitch msg.Type {\n\tcase TypePing:\n\t\tpong := newMessage(TypePong, nil)\n\t\tpong.ID = msg.ID\n\t\tpc.writeJSON(pong)\n\n\tcase TypeMessageSend:\n\t\tc.handleMessageSend(pc, msg)\n\n\tdefault:\n\t\terrMsg := newError(\"unknown_type\", fmt.Sprintf(\"unknown message type: %s\", msg.Type))\n\t\tpc.writeJSON(errMsg)\n\t}\n}\n\n// handleMessageSend processes an inbound message.send from a client.\nfunc (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {\n\tcontent, _ := msg.Payload[\"content\"].(string)\n\tif strings.TrimSpace(content) == \"\" {\n\t\terrMsg := newError(\"empty_content\", \"message content is empty\")\n\t\tpc.writeJSON(errMsg)\n\t\treturn\n\t}\n\n\tsessionID := msg.SessionID\n\tif sessionID == \"\" {\n\t\tsessionID = pc.sessionID\n\t}\n\n\tchatID := \"pico:\" + sessionID\n\tsenderID := \"pico-user\"\n\n\tpeer := bus.Peer{Kind: \"direct\", ID: \"pico:\" + sessionID}\n\n\tmetadata := map[string]string{\n\t\t\"platform\":   \"pico\",\n\t\t\"session_id\": sessionID,\n\t\t\"conn_id\":    pc.id,\n\t}\n\n\tlogger.DebugCF(\"pico\", \"Received message\", map[string]any{\n\t\t\"session_id\": sessionID,\n\t\t\"preview\":    truncate(content, 50),\n\t})\n\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"pico\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"pico\", senderID),\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\tc.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)\n}\n\n// truncate truncates a string to maxLen runes.\nfunc truncate(s string, maxLen int) string {\n\trunes := []rune(s)\n\tif len(runes) <= maxLen {\n\t\treturn s\n\t}\n\treturn string(runes[:maxLen]) + \"...\"\n}\n"
  },
  {
    "path": "pkg/channels/pico/protocol.go",
    "content": "package pico\n\nimport \"time\"\n\n// Protocol message types.\nconst (\n\t// TypeMessageSend is sent from client to server.\n\tTypeMessageSend = \"message.send\"\n\tTypeMediaSend   = \"media.send\"\n\tTypePing        = \"ping\"\n\n\t// TypeMessageCreate is sent from server to client.\n\tTypeMessageCreate = \"message.create\"\n\tTypeMessageUpdate = \"message.update\"\n\tTypeMediaCreate   = \"media.create\"\n\tTypeTypingStart   = \"typing.start\"\n\tTypeTypingStop    = \"typing.stop\"\n\tTypeError         = \"error\"\n\tTypePong          = \"pong\"\n)\n\n// PicoMessage is the wire format for all Pico Protocol messages.\ntype PicoMessage struct {\n\tType      string         `json:\"type\"`\n\tID        string         `json:\"id,omitempty\"`\n\tSessionID string         `json:\"session_id,omitempty\"`\n\tTimestamp int64          `json:\"timestamp,omitempty\"`\n\tPayload   map[string]any `json:\"payload,omitempty\"`\n}\n\n// newMessage creates a PicoMessage with the given type and payload.\nfunc newMessage(msgType string, payload map[string]any) PicoMessage {\n\treturn PicoMessage{\n\t\tType:      msgType,\n\t\tTimestamp: time.Now().UnixMilli(),\n\t\tPayload:   payload,\n\t}\n}\n\n// newError creates an error PicoMessage.\nfunc newError(code, message string) PicoMessage {\n\treturn newMessage(TypeError, map[string]any{\n\t\t\"code\":    code,\n\t\t\"message\": message,\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/qq/botgo_logger.go",
    "content": "package qq\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// botGoLogger preserves useful SDK info logs while demoting noisy heartbeat\n// traffic to DEBUG so long-running QQ sessions do not spam the console.\ntype botGoLogger struct {\n\t*logger.Logger\n}\n\nfunc newBotGoLogger(component string) *botGoLogger {\n\treturn &botGoLogger{Logger: logger.NewLogger(component)}\n}\n\nfunc (b *botGoLogger) Info(v ...any) {\n\tmessage := fmt.Sprint(v...)\n\tif shouldDemoteBotGoInfo(message) {\n\t\tb.Logger.Debug(message)\n\t\treturn\n\t}\n\tb.Logger.Info(message)\n}\n\nfunc (b *botGoLogger) Infof(format string, v ...any) {\n\tmessage := fmt.Sprintf(format, v...)\n\tif shouldDemoteBotGoInfo(message) {\n\t\tb.Logger.Debug(message)\n\t\treturn\n\t}\n\tb.Logger.Info(message)\n}\n\nfunc shouldDemoteBotGoInfo(message string) bool {\n\treturn strings.Contains(message, \" write Heartbeat message\") ||\n\t\tstrings.Contains(message, \" receive HeartbeatAck message\")\n}\n"
  },
  {
    "path": "pkg/channels/qq/init.go",
    "content": "package qq\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"qq\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewQQChannel(cfg.Channels.QQ, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/qq/qq.go",
    "content": "package qq\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/tencent-connect/botgo\"\n\t\"github.com/tencent-connect/botgo/constant\"\n\t\"github.com/tencent-connect/botgo/dto\"\n\t\"github.com/tencent-connect/botgo/event\"\n\t\"github.com/tencent-connect/botgo/openapi/options\"\n\t\"github.com/tencent-connect/botgo/token\"\n\t\"golang.org/x/oauth2\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tdedupTTL      = 5 * time.Minute\n\tdedupInterval = 60 * time.Second\n\tdedupMaxSize  = 10000 // hard cap on dedup map entries\n\ttypingResend  = 8 * time.Second\n\ttypingSeconds = 10\n\tbytesPerMiB   = 1024 * 1024\n)\n\ntype qqAPI interface {\n\tWS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error)\n\tPostGroupMessage(\n\t\tctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option,\n\t) (*dto.Message, error)\n\tPostC2CMessage(\n\t\tctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option,\n\t) (*dto.Message, error)\n\tTransport(ctx context.Context, method, url string, body any) ([]byte, error)\n}\n\ntype QQChannel struct {\n\t*channels.BaseChannel\n\tconfig         config.QQConfig\n\tapi            qqAPI\n\ttokenSource    oauth2.TokenSource\n\tctx            context.Context\n\tcancel         context.CancelFunc\n\tsessionManager botgo.SessionManager\n\tdownloadFn     func(urlStr, filename string) string\n\n\t// Chat routing: track whether a chatID is group or direct.\n\tchatType sync.Map // chatID → \"group\" | \"direct\"\n\n\t// Passive reply: store last inbound message ID per chat.\n\tlastMsgID sync.Map // chatID → string\n\n\t// msg_seq: per-chat atomic counter for multi-part replies.\n\tmsgSeqCounters sync.Map // chatID → *atomic.Uint64\n\n\t// Time-based dedup replacing the unbounded map.\n\tdedup   map[string]time.Time\n\tmuDedup sync.Mutex\n\n\t// done is closed on Stop to shut down the dedup janitor.\n\tdone     chan struct{}\n\tstopOnce sync.Once\n}\n\nfunc NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {\n\tbase := channels.NewBaseChannel(\"qq\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(cfg.MaxMessageLength),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &QQChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t}, nil\n}\n\nfunc (c *QQChannel) Start(ctx context.Context) error {\n\tif c.config.AppID == \"\" || c.config.AppSecret == \"\" {\n\t\treturn fmt.Errorf(\"QQ app_id and app_secret not configured\")\n\t}\n\n\tbotgo.SetLogger(newBotGoLogger(\"botgo\"))\n\tlogger.InfoC(\"qq\", \"Starting QQ bot (WebSocket mode)\")\n\n\t// Reinitialize shutdown signal for clean restart.\n\tc.done = make(chan struct{})\n\tc.stopOnce = sync.Once{}\n\n\t// create token source\n\tcredentials := &token.QQBotCredentials{\n\t\tAppID:     c.config.AppID,\n\t\tAppSecret: c.config.AppSecret,\n\t}\n\tc.tokenSource = token.NewQQBotTokenSource(credentials)\n\n\t// create child context\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// start auto-refresh token goroutine\n\tif err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {\n\t\treturn fmt.Errorf(\"failed to start token refresh: %w\", err)\n\t}\n\n\t// initialize OpenAPI client\n\tc.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)\n\n\t// register event handlers\n\tintent := event.RegisterHandlers(\n\t\tc.handleC2CMessage(),\n\t\tc.handleGroupATMessage(),\n\t)\n\n\t// get WebSocket endpoint\n\twsInfo, err := c.api.WS(c.ctx, nil, \"\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get websocket info: %w\", err)\n\t}\n\n\tlogger.InfoCF(\"qq\", \"Got WebSocket info\", map[string]any{\n\t\t\"shards\": wsInfo.Shards,\n\t})\n\n\t// create and save sessionManager\n\tc.sessionManager = botgo.NewSessionManager()\n\n\t// start WebSocket connection in goroutine to avoid blocking\n\tgo func() {\n\t\tif err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {\n\t\t\tlogger.ErrorCF(\"qq\", \"WebSocket session error\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tc.SetRunning(false)\n\t\t}\n\t}()\n\n\t// start dedup janitor goroutine\n\tgo c.dedupJanitor()\n\n\t// Pre-register reasoning_channel_id as group chat if configured,\n\t// so outbound-only destinations are routed correctly.\n\tif c.config.ReasoningChannelID != \"\" {\n\t\tc.chatType.Store(c.config.ReasoningChannelID, \"group\")\n\t}\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"qq\", \"QQ bot started successfully\")\n\n\treturn nil\n}\n\nfunc (c *QQChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"qq\", \"Stopping QQ bot\")\n\tc.SetRunning(false)\n\n\t// Signal the dedup janitor to stop (idempotent).\n\tc.stopOnce.Do(func() { close(c.done) })\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\treturn nil\n}\n\n// getChatKind returns the chat type for a given chatID (\"group\" or \"direct\").\n// Unknown chatIDs default to \"group\" and log a warning, since QQ group IDs are\n// more common as outbound-only destinations (e.g. reasoning_channel_id).\nfunc (c *QQChannel) getChatKind(chatID string) string {\n\tif v, ok := c.chatType.Load(chatID); ok {\n\t\tif k, ok := v.(string); ok {\n\t\t\treturn k\n\t\t}\n\t}\n\tlogger.DebugCF(\"qq\", \"Unknown chat type for chatID, defaulting to group\", map[string]any{\n\t\t\"chat_id\": chatID,\n\t})\n\treturn \"group\"\n}\n\nfunc (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchatKind := c.getChatKind(msg.ChatID)\n\n\t// Build message with content.\n\tmsgToCreate := &dto.MessageToCreate{\n\t\tContent: msg.Content,\n\t\tMsgType: dto.TextMsg,\n\t}\n\n\t// Use Markdown message type if enabled in config.\n\tif c.config.SendMarkdown {\n\t\tmsgToCreate.MsgType = dto.MarkdownMsg\n\t\tmsgToCreate.Markdown = &dto.Markdown{\n\t\t\tContent: msg.Content,\n\t\t}\n\t\t// Clear plain content to avoid sending duplicate text.\n\t\tmsgToCreate.Content = \"\"\n\t}\n\n\tc.applyPassiveReplyMetadata(msg.ChatID, msgToCreate)\n\n\t// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.\n\tif chatKind == \"group\" {\n\t\tif msgToCreate.Content != \"\" {\n\t\t\tmsgToCreate.Content = sanitizeURLs(msgToCreate.Content)\n\t\t}\n\t\tif msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != \"\" {\n\t\t\tmsgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)\n\t\t}\n\t}\n\n\t// Route to group or C2C.\n\tvar err error\n\tif chatKind == \"group\" {\n\t\t_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)\n\t} else {\n\t\t_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)\n\t}\n\n\tif err != nil {\n\t\tlogger.ErrorCF(\"qq\", \"Failed to send message\", map[string]any{\n\t\t\t\"chat_id\":   msg.ChatID,\n\t\t\t\"chat_kind\": chatKind,\n\t\t\t\"error\":     err.Error(),\n\t\t})\n\t\treturn fmt.Errorf(\"qq send: %w\", channels.ErrTemporary)\n\t}\n\n\treturn nil\n}\n\n// StartTyping implements channels.TypingCapable.\n// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.\n// The returned stop function is idempotent and cancels the goroutine.\nfunc (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\t// We need a stored msg_id for passive InputNotify; skip if none available.\n\tv, ok := c.lastMsgID.Load(chatID)\n\tif !ok {\n\t\treturn func() {}, nil\n\t}\n\tmsgID, ok := v.(string)\n\tif !ok || msgID == \"\" {\n\t\treturn func() {}, nil\n\t}\n\n\tchatKind := c.getChatKind(chatID)\n\n\tsendTyping := func(sendCtx context.Context) {\n\t\ttypingMsg := &dto.MessageToCreate{\n\t\t\tMsgType: dto.InputNotifyMsg,\n\t\t\tMsgID:   msgID,\n\t\t\tInputNotify: &dto.InputNotify{\n\t\t\t\tInputType:   1,\n\t\t\t\tInputSecond: typingSeconds,\n\t\t\t},\n\t\t}\n\n\t\tvar err error\n\t\tif chatKind == \"group\" {\n\t\t\t_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)\n\t\t} else {\n\t\t\t_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)\n\t\t}\n\t\tif err != nil {\n\t\t\tlogger.DebugCF(\"qq\", \"Failed to send typing indicator\", map[string]any{\n\t\t\t\t\"chat_id\": chatID,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\t// Send immediately.\n\tsendTyping(c.ctx)\n\n\ttypingCtx, cancel := context.WithCancel(c.ctx)\n\tgo func() {\n\t\tticker := time.NewTicker(typingResend)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-typingCtx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\tsendTyping(typingCtx)\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn cancel, nil\n}\n\n// SendMedia implements the channels.MediaSender interface.\n// QQ group/C2C media sending is a two-step flow:\n// 1. Upload media to /files using a remote URL or base64-encoded local bytes.\n// 2. Send a msg_type=7 message using the returned file_info.\nfunc (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchatKind := c.getChatKind(msg.ChatID)\n\n\tfor _, part := range msg.Parts {\n\t\tfileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"qq\", \"Failed to upload media\", map[string]any{\n\t\t\t\t\"type\":    part.Type,\n\t\t\t\t\"chat_id\": msg.ChatID,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t\tif errors.Is(err, channels.ErrSendFailed) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"qq send media: %w\", channels.ErrTemporary)\n\t\t}\n\n\t\tif err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil {\n\t\t\tlogger.ErrorCF(\"qq\", \"Failed to send media\", map[string]any{\n\t\t\t\t\"type\":    part.Type,\n\t\t\t\t\"chat_id\": msg.ChatID,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t})\n\t\t\treturn fmt.Errorf(\"qq send media: %w\", channels.ErrTemporary)\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype qqMediaUpload struct {\n\tFileType   uint64 `json:\"file_type\"`\n\tURL        string `json:\"url,omitempty\"`\n\tFileData   string `json:\"file_data,omitempty\"`\n\tSrvSendMsg bool   `json:\"srv_send_msg,omitempty\"`\n}\n\nfunc (c *QQChannel) uploadMedia(\n\tctx context.Context,\n\tchatKind, chatID string,\n\tpart bus.MediaPart,\n) ([]byte, error) {\n\tpayload, err := c.buildMediaUpload(part)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbody, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar uploaded dto.Message\n\tif err := json.Unmarshal(body, &uploaded); err != nil {\n\t\treturn nil, fmt.Errorf(\"qq decode media upload response: %w\", err)\n\t}\n\tif len(uploaded.FileInfo) == 0 {\n\t\treturn nil, fmt.Errorf(\"qq upload media: missing file_info\")\n\t}\n\n\treturn uploaded.FileInfo, nil\n}\n\nfunc (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {\n\tpayload := &qqMediaUpload{\n\t\tFileType: qqFileType(part.Type),\n\t}\n\n\tmediaRef := part.Ref\n\tif isHTTPURL(mediaRef) {\n\t\tpayload.URL = mediaRef\n\t\treturn payload, nil\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn nil, fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tresolved, err := store.Resolve(part.Ref)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"qq resolve media ref %q: %v: %w\", part.Ref, err, channels.ErrSendFailed)\n\t}\n\n\tif isHTTPURL(resolved) {\n\t\tpayload.URL = resolved\n\t\treturn payload, nil\n\t}\n\n\tif limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {\n\t\tinfo, statErr := os.Stat(resolved)\n\t\tif statErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"qq stat local media %q: %v: %w\", resolved, statErr, channels.ErrSendFailed)\n\t\t}\n\t\tif info.Size() > limitBytes {\n\t\t\treturn nil, fmt.Errorf(\n\t\t\t\t\"qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w\",\n\t\t\t\tresolved,\n\t\t\t\tinfo.Size(),\n\t\t\t\tlimitBytes,\n\t\t\t\tchannels.ErrSendFailed,\n\t\t\t)\n\t\t}\n\t}\n\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"qq read local media %q: %v: %w\", resolved, err, channels.ErrSendFailed)\n\t}\n\n\tpayload.FileData = base64.StdEncoding.EncodeToString(data)\n\treturn payload, nil\n}\n\nfunc (c *QQChannel) sendUploadedMedia(\n\tctx context.Context,\n\tchatKind, chatID string,\n\tpart bus.MediaPart,\n\tfileInfo []byte,\n) error {\n\tmsg := &dto.MessageToCreate{\n\t\tContent: part.Caption,\n\t\tMsgType: dto.RichMediaMsg,\n\t\tMedia: &dto.MediaInfo{\n\t\t\tFileInfo: fileInfo,\n\t\t},\n\t}\n\tc.applyPassiveReplyMetadata(chatID, msg)\n\n\tif chatKind == \"group\" && msg.Content != \"\" {\n\t\tmsg.Content = sanitizeURLs(msg.Content)\n\t}\n\n\tif chatKind == \"group\" {\n\t\t_, err := c.api.PostGroupMessage(ctx, chatID, msg)\n\t\treturn err\n\t}\n\t_, err := c.api.PostC2CMessage(ctx, chatID, msg)\n\treturn err\n}\n\nfunc (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) {\n\tif v, ok := c.lastMsgID.Load(chatID); ok {\n\t\tif msgID, ok := v.(string); ok && msgID != \"\" {\n\t\t\tmsg.MsgID = msgID\n\n\t\t\t// Increment msg_seq atomically for multi-part replies.\n\t\t\tif counterVal, ok := c.msgSeqCounters.Load(chatID); ok {\n\t\t\t\tif counter, ok := counterVal.(*atomic.Uint64); ok {\n\t\t\t\t\tseq := counter.Add(1)\n\t\t\t\t\tmsg.MsgSeq = uint32(seq)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *QQChannel) mediaUploadURL(chatKind, chatID string) string {\n\tbase := constant.APIDomain\n\tif chatKind == \"group\" {\n\t\treturn fmt.Sprintf(\"%s/v2/groups/%s/files\", base, chatID)\n\t}\n\treturn fmt.Sprintf(\"%s/v2/users/%s/files\", base, chatID)\n}\n\nfunc qqFileType(partType string) uint64 {\n\tswitch partType {\n\tcase \"image\":\n\t\treturn 1\n\tcase \"video\":\n\t\treturn 2\n\tcase \"audio\":\n\t\treturn 3\n\tdefault:\n\t\treturn 4\n\t}\n}\n\nfunc (c *QQChannel) maxBase64FileSizeBytes() int64 {\n\tif c.config.MaxBase64FileSizeMiB <= 0 {\n\t\treturn 0\n\t}\n\treturn c.config.MaxBase64FileSizeMiB * bytesPerMiB\n}\n\n// handleC2CMessage handles QQ private messages.\nfunc (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {\n\treturn func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {\n\t\t// deduplication check\n\t\tif c.isDuplicate(data.ID) {\n\t\t\treturn nil\n\t\t}\n\n\t\t// extract user info\n\t\tvar senderID string\n\t\tif data.Author != nil && data.Author.ID != \"\" {\n\t\t\tsenderID = data.Author.ID\n\t\t} else {\n\t\t\tlogger.WarnC(\"qq\", \"Received message with no sender ID\")\n\t\t\treturn nil\n\t\t}\n\n\t\tsender := bus.SenderInfo{\n\t\t\tPlatform:    \"qq\",\n\t\t\tPlatformID:  data.Author.ID,\n\t\t\tCanonicalID: identity.BuildCanonicalID(\"qq\", data.Author.ID),\n\t\t}\n\n\t\tif !c.IsAllowedSender(sender) {\n\t\t\treturn nil\n\t\t}\n\n\t\tcontent := strings.TrimSpace(data.Content)\n\t\tmediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments)\n\t\tfor _, note := range attachmentNotes {\n\t\t\tcontent = appendContent(content, note)\n\t\t}\n\t\tif content == \"\" && len(mediaPaths) == 0 {\n\t\t\tlogger.DebugC(\"qq\", \"Received empty C2C message with no attachments, ignoring\")\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.InfoCF(\"qq\", \"Received C2C message\", map[string]any{\n\t\t\t\"sender\":      senderID,\n\t\t\t\"length\":      len(content),\n\t\t\t\"media_count\": len(mediaPaths),\n\t\t})\n\n\t\t// Store chat routing context.\n\t\tc.chatType.Store(senderID, \"direct\")\n\t\tc.lastMsgID.Store(senderID, data.ID)\n\n\t\t// Reset msg_seq counter for new inbound message.\n\t\tc.msgSeqCounters.Store(senderID, new(atomic.Uint64))\n\n\t\tmetadata := map[string]string{\n\t\t\t\"account_id\": senderID,\n\t\t}\n\n\t\tc.HandleMessage(c.ctx,\n\t\t\tbus.Peer{Kind: \"direct\", ID: senderID},\n\t\t\tdata.ID,\n\t\t\tsenderID,\n\t\t\tsenderID,\n\t\t\tcontent,\n\t\t\tmediaPaths,\n\t\t\tmetadata,\n\t\t\tsender,\n\t\t)\n\n\t\treturn nil\n\t}\n}\n\n// handleGroupATMessage handles QQ group @ messages.\nfunc (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {\n\treturn func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {\n\t\t// deduplication check\n\t\tif c.isDuplicate(data.ID) {\n\t\t\treturn nil\n\t\t}\n\n\t\t// extract user info\n\t\tvar senderID string\n\t\tif data.Author != nil && data.Author.ID != \"\" {\n\t\t\tsenderID = data.Author.ID\n\t\t} else {\n\t\t\tlogger.WarnC(\"qq\", \"Received group message with no sender ID\")\n\t\t\treturn nil\n\t\t}\n\n\t\tsender := bus.SenderInfo{\n\t\t\tPlatform:    \"qq\",\n\t\t\tPlatformID:  data.Author.ID,\n\t\t\tCanonicalID: identity.BuildCanonicalID(\"qq\", data.Author.ID),\n\t\t}\n\n\t\tif !c.IsAllowedSender(sender) {\n\t\t\treturn nil\n\t\t}\n\n\t\tcontent := strings.TrimSpace(data.Content)\n\t\tmediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments)\n\t\tfor _, note := range attachmentNotes {\n\t\t\tcontent = appendContent(content, note)\n\t\t}\n\n\t\t// GroupAT event means bot is always mentioned; apply group trigger filtering.\n\t\trespond, cleaned := c.ShouldRespondInGroup(true, content)\n\t\tif !respond {\n\t\t\treturn nil\n\t\t}\n\t\tcontent = cleaned\n\t\tif content == \"\" && len(mediaPaths) == 0 {\n\t\t\tlogger.DebugC(\"qq\", \"Received empty group message with no attachments, ignoring\")\n\t\t\treturn nil\n\t\t}\n\n\t\tlogger.InfoCF(\"qq\", \"Received group AT message\", map[string]any{\n\t\t\t\"sender\":      senderID,\n\t\t\t\"group\":       data.GroupID,\n\t\t\t\"length\":      len(content),\n\t\t\t\"media_count\": len(mediaPaths),\n\t\t})\n\n\t\t// Store chat routing context using GroupID as chatID.\n\t\tc.chatType.Store(data.GroupID, \"group\")\n\t\tc.lastMsgID.Store(data.GroupID, data.ID)\n\n\t\t// Reset msg_seq counter for new inbound message.\n\t\tc.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))\n\n\t\tmetadata := map[string]string{\n\t\t\t\"account_id\": senderID,\n\t\t\t\"group_id\":   data.GroupID,\n\t\t}\n\n\t\tc.HandleMessage(c.ctx,\n\t\t\tbus.Peer{Kind: \"group\", ID: data.GroupID},\n\t\t\tdata.ID,\n\t\t\tsenderID,\n\t\t\tdata.GroupID,\n\t\t\tcontent,\n\t\t\tmediaPaths,\n\t\t\tmetadata,\n\t\t\tsender,\n\t\t)\n\n\t\treturn nil\n\t}\n}\n\nfunc (c *QQChannel) extractInboundAttachments(\n\tchatID, messageID string,\n\tattachments []*dto.MessageAttachment,\n) ([]string, []string) {\n\tif len(attachments) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tscope := channels.BuildMediaScope(\"qq\", chatID, messageID)\n\tmediaPaths := make([]string, 0, len(attachments))\n\tnotes := make([]string, 0, len(attachments))\n\n\tstoreMedia := func(localPath string, attachment *dto.MessageAttachment) string {\n\t\tif store := c.GetMediaStore(); store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename:    qqAttachmentFilename(attachment),\n\t\t\t\tContentType: attachment.ContentType,\n\t\t\t\tSource:      \"qq\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath\n\t}\n\n\tfor _, attachment := range attachments {\n\t\tif attachment == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tfilename := qqAttachmentFilename(attachment)\n\t\tif localPath := c.downloadAttachment(attachment.URL, filename); localPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, attachment))\n\t\t} else if attachment.URL != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, attachment.URL)\n\t\t}\n\n\t\tnotes = append(notes, qqAttachmentNote(attachment))\n\t}\n\n\treturn mediaPaths, notes\n}\n\nfunc (c *QQChannel) downloadAttachment(urlStr, filename string) string {\n\tif urlStr == \"\" {\n\t\treturn \"\"\n\t}\n\tif c.downloadFn != nil {\n\t\treturn c.downloadFn(urlStr, filename)\n\t}\n\n\treturn utils.DownloadFile(urlStr, filename, utils.DownloadOptions{\n\t\tLoggerPrefix: \"qq\",\n\t\tExtraHeaders: c.downloadHeaders(),\n\t})\n}\n\nfunc (c *QQChannel) downloadHeaders() map[string]string {\n\theaders := map[string]string{}\n\n\tif c.config.AppID != \"\" {\n\t\theaders[\"X-Union-Appid\"] = c.config.AppID\n\t}\n\n\tif c.tokenSource != nil {\n\t\tif tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != \"\" {\n\t\t\tauth := strings.TrimSpace(tk.TokenType + \" \" + tk.AccessToken)\n\t\t\tif auth != \"\" {\n\t\t\t\theaders[\"Authorization\"] = auth\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(headers) == 0 {\n\t\treturn nil\n\t}\n\treturn headers\n}\n\nfunc qqAttachmentFilename(attachment *dto.MessageAttachment) string {\n\tif attachment == nil {\n\t\treturn \"attachment\"\n\t}\n\tif attachment.FileName != \"\" {\n\t\treturn attachment.FileName\n\t}\n\tif attachment.URL != \"\" {\n\t\tif parsed, err := url.Parse(attachment.URL); err == nil {\n\t\t\tif base := path.Base(parsed.Path); base != \"\" && base != \".\" && base != \"/\" {\n\t\t\t\treturn base\n\t\t\t}\n\t\t}\n\t}\n\n\tswitch qqAttachmentKind(attachment) {\n\tcase \"image\":\n\t\treturn \"image\"\n\tcase \"audio\":\n\t\treturn \"audio\"\n\tcase \"video\":\n\t\treturn \"video\"\n\tdefault:\n\t\treturn \"attachment\"\n\t}\n}\n\nfunc qqAttachmentKind(attachment *dto.MessageAttachment) string {\n\tif attachment == nil {\n\t\treturn \"file\"\n\t}\n\n\tcontentType := strings.ToLower(attachment.ContentType)\n\tfilename := strings.ToLower(attachment.FileName)\n\n\tswitch {\n\tcase strings.HasPrefix(contentType, \"image/\"):\n\t\treturn \"image\"\n\tcase strings.HasPrefix(contentType, \"video/\"):\n\t\treturn \"video\"\n\tcase strings.HasPrefix(contentType, \"audio/\"), contentType == \"application/ogg\", contentType == \"application/x-ogg\":\n\t\treturn \"audio\"\n\t}\n\n\tswitch filepath.Ext(filename) {\n\tcase \".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\", \".svg\":\n\t\treturn \"image\"\n\tcase \".mp4\", \".avi\", \".mov\", \".webm\", \".mkv\":\n\t\treturn \"video\"\n\tcase \".mp3\", \".wav\", \".ogg\", \".m4a\", \".flac\", \".aac\", \".wma\", \".opus\", \".silk\":\n\t\treturn \"audio\"\n\tdefault:\n\t\treturn \"file\"\n\t}\n}\n\nfunc qqAttachmentNote(attachment *dto.MessageAttachment) string {\n\tfilename := qqAttachmentFilename(attachment)\n\n\tswitch qqAttachmentKind(attachment) {\n\tcase \"image\":\n\t\treturn fmt.Sprintf(\"[image: %s]\", filename)\n\tcase \"audio\":\n\t\treturn fmt.Sprintf(\"[audio: %s]\", filename)\n\tcase \"video\":\n\t\treturn fmt.Sprintf(\"[video: %s]\", filename)\n\tdefault:\n\t\treturn fmt.Sprintf(\"[file: %s]\", filename)\n\t}\n}\n\n// isDuplicate checks whether a message has been seen within the TTL window.\n// It also enforces a hard cap on map size by evicting oldest entries.\nfunc (c *QQChannel) isDuplicate(messageID string) bool {\n\tc.muDedup.Lock()\n\tdefer c.muDedup.Unlock()\n\n\tif ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {\n\t\treturn true\n\t}\n\n\t// Enforce hard cap: evict oldest entries when at capacity.\n\tif len(c.dedup) >= dedupMaxSize {\n\t\tvar oldestID string\n\t\tvar oldestTS time.Time\n\t\tfor id, ts := range c.dedup {\n\t\t\tif oldestID == \"\" || ts.Before(oldestTS) {\n\t\t\t\toldestID = id\n\t\t\t\toldestTS = ts\n\t\t\t}\n\t\t}\n\t\tif oldestID != \"\" {\n\t\t\tdelete(c.dedup, oldestID)\n\t\t}\n\t}\n\n\tc.dedup[messageID] = time.Now()\n\treturn false\n}\n\n// dedupJanitor periodically evicts expired entries from the dedup map.\nfunc (c *QQChannel) dedupJanitor() {\n\tticker := time.NewTicker(dedupInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.done:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\t// Collect expired keys under read-like scan.\n\t\t\tc.muDedup.Lock()\n\t\t\tnow := time.Now()\n\t\t\tvar expired []string\n\t\t\tfor id, ts := range c.dedup {\n\t\t\t\tif now.Sub(ts) >= dedupTTL {\n\t\t\t\t\texpired = append(expired, id)\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, id := range expired {\n\t\t\t\tdelete(c.dedup, id)\n\t\t\t}\n\t\t\tc.muDedup.Unlock()\n\t\t}\n\t}\n}\n\n// isHTTPURL returns true if s starts with http:// or https://.\nfunc isHTTPURL(s string) bool {\n\treturn strings.HasPrefix(s, \"http://\") || strings.HasPrefix(s, \"https://\")\n}\n\nfunc appendContent(content, suffix string) string {\n\tif suffix == \"\" {\n\t\treturn content\n\t}\n\tif content == \"\" {\n\t\treturn suffix\n\t}\n\treturn content + \"\\n\" + suffix\n}\n\n// urlPattern matches URLs with explicit http(s):// scheme.\n// Only scheme-prefixed URLs are matched to avoid false positives on bare text\n// like version numbers (e.g., \"1.2.3\") or domain-like fragments.\nvar urlPattern = regexp.MustCompile(\n\t`(?i)` +\n\t\t`https?://` + // required scheme\n\t\t`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+` + // domain parts\n\t\t`[a-zA-Z]{2,}` + // TLD\n\t\t`(?:[/?#]\\S*)?`, // optional path/query/fragment\n)\n\n// sanitizeURLs replaces dots in URL domains with \"。\" (fullwidth period)\n// to prevent QQ's URL blacklist from rejecting the message.\nfunc sanitizeURLs(text string) string {\n\treturn urlPattern.ReplaceAllStringFunc(text, func(match string) string {\n\t\t// Split into scheme + rest (scheme is always present).\n\t\tidx := strings.Index(match, \"://\")\n\t\tscheme := match[:idx+3]\n\t\trest := match[idx+3:]\n\n\t\t// Find where the domain ends (first / ? or #).\n\t\tdomainEnd := len(rest)\n\t\tfor i, ch := range rest {\n\t\t\tif ch == '/' || ch == '?' || ch == '#' {\n\t\t\t\tdomainEnd = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tdomain := rest[:domainEnd]\n\t\tpath := rest[domainEnd:]\n\n\t\t// Replace dots in domain only.\n\t\tdomain = strings.ReplaceAll(domain, \".\", \"。\")\n\n\t\treturn scheme + domain + path\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/qq/qq_test.go",
    "content": "package qq\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/tencent-connect/botgo/dto\"\n\t\"github.com/tencent-connect/botgo/openapi/options\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nfunc TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t}\n\n\terr := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{\n\t\tID:      \"msg-1\",\n\t\tContent: \"hello\",\n\t\tAuthor: &dto.User{\n\t\t\tID: \"7750283E123456\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"handleC2CMessage() error = %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), time.Second)\n\tdefer cancel()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tt.Fatal(\"timeout waiting for inbound message\")\n\t\t\treturn\n\t\tcase inbound, ok := <-messageBus.InboundChan():\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"expected inbound message\")\n\t\t\t}\n\t\t\tif inbound.Metadata[\"account_id\"] != \"7750283E123456\" {\n\t\t\t\tt.Fatalf(\"account_id metadata = %q, want %q\", inbound.Metadata[\"account_id\"], \"7750283E123456\")\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tstore := media.NewFileMediaStore()\n\tlocalPath := writeTempFile(t, t.TempDir(), \"image.png\", []byte(\"fake-image\"))\n\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t\tdownloadFn: func(urlStr, filename string) string {\n\t\t\tif filename != \"image.png\" {\n\t\t\t\tt.Fatalf(\"download filename = %q, want image.png\", filename)\n\t\t\t}\n\t\t\treturn localPath\n\t\t},\n\t}\n\tch.SetMediaStore(store)\n\n\terr := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{\n\t\tID:      \"msg-attachment\",\n\t\tContent: \"\",\n\t\tAuthor: &dto.User{\n\t\t\tID: \"7750283E123456\",\n\t\t},\n\t\tAttachments: []*dto.MessageAttachment{{\n\t\t\tURL:         \"https://example.com/image.png\",\n\t\t\tFileName:    \"image.png\",\n\t\t\tContentType: \"image/png\",\n\t\t}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"handleC2CMessage() error = %v\", err)\n\t}\n\n\tinbound := waitInboundMessage(t, messageBus)\n\tif inbound.Content != \"[image: image.png]\" {\n\t\tt.Fatalf(\"inbound.Content = %q\", inbound.Content)\n\t}\n\tif len(inbound.Media) != 1 {\n\t\tt.Fatalf(\"len(inbound.Media) = %d, want 1\", len(inbound.Media))\n\t}\n\tif !strings.HasPrefix(inbound.Media[0], \"media://\") {\n\t\tt.Fatalf(\"inbound.Media[0] = %q, want media:// ref\", inbound.Media[0])\n\t}\n\t_, meta, err := store.ResolveWithMeta(inbound.Media[0])\n\tif err != nil {\n\t\tt.Fatalf(\"ResolveWithMeta() error = %v\", err)\n\t}\n\tif meta.Filename != \"image.png\" {\n\t\tt.Fatalf(\"meta.Filename = %q, want image.png\", meta.Filename)\n\t}\n\tif meta.ContentType != \"image/png\" {\n\t\tt.Fatalf(\"meta.ContentType = %q, want image/png\", meta.ContentType)\n\t}\n}\n\nfunc TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tstore := media.NewFileMediaStore()\n\tlocalPath := writeTempFile(t, t.TempDir(), \"report.pdf\", []byte(\"fake-pdf\"))\n\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t\tdownloadFn: func(urlStr, filename string) string {\n\t\t\tif filename != \"report.pdf\" {\n\t\t\t\tt.Fatalf(\"download filename = %q, want report.pdf\", filename)\n\t\t\t}\n\t\t\treturn localPath\n\t\t},\n\t}\n\tch.SetMediaStore(store)\n\n\terr := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{\n\t\tID:      \"group-attachment\",\n\t\tGroupID: \"group-1\",\n\t\tContent: \"\",\n\t\tAuthor: &dto.User{\n\t\t\tID: \"7750283E123456\",\n\t\t},\n\t\tAttachments: []*dto.MessageAttachment{{\n\t\t\tURL:         \"https://example.com/report.pdf\",\n\t\t\tFileName:    \"report.pdf\",\n\t\t\tContentType: \"application/pdf\",\n\t\t}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"handleGroupATMessage() error = %v\", err)\n\t}\n\n\tinbound := waitInboundMessage(t, messageBus)\n\tif inbound.Content != \"[file: report.pdf]\" {\n\t\tt.Fatalf(\"inbound.Content = %q\", inbound.Content)\n\t}\n\tif len(inbound.Media) != 1 {\n\t\tt.Fatalf(\"len(inbound.Media) = %d, want 1\", len(inbound.Media))\n\t}\n\tif !strings.HasPrefix(inbound.Media[0], \"media://\") {\n\t\tt.Fatalf(\"inbound.Media[0] = %q, want media:// ref\", inbound.Media[0])\n\t}\n\tif inbound.Peer.Kind != \"group\" || inbound.Peer.ID != \"group-1\" {\n\t\tt.Fatalf(\"inbound.Peer = %+v, want group/group-1\", inbound.Peer)\n\t}\n}\n\nfunc TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tstore := media.NewFileMediaStore()\n\n\ttmpFile, err := os.CreateTemp(t.TempDir(), \"qq-media-*.png\")\n\tif err != nil {\n\t\tt.Fatalf(\"CreateTemp() error = %v\", err)\n\t}\n\tdefer tmpFile.Close()\n\n\tcontent := []byte(\"local-image-data\")\n\tif _, writeErr := tmpFile.Write(content); writeErr != nil {\n\t\tt.Fatalf(\"Write() error = %v\", writeErr)\n\t}\n\n\tref, err := store.Store(tmpFile.Name(), media.MediaMeta{\n\t\tFilename:    \"reply.png\",\n\t\tContentType: \"image/png\",\n\t}, \"qq:test\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store() error = %v\", err)\n\t}\n\n\tapi := &fakeQQAPI{\n\t\ttransportResp: mustJSON(t, dto.Message{FileInfo: []byte(\"uploaded-file-info\")}),\n\t}\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tapi:         api,\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t}\n\tch.SetRunning(true)\n\tch.SetMediaStore(store)\n\tch.chatType.Store(\"group-1\", \"group\")\n\tch.lastMsgID.Store(\"group-1\", \"msg-1\")\n\tch.msgSeqCounters.Store(\"group-1\", new(atomic.Uint64))\n\n\terr = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"group-1\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType:    \"image\",\n\t\t\tRef:     ref,\n\t\t\tCaption: \"see https://example.com/image\",\n\t\t}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"SendMedia() error = %v\", err)\n\t}\n\n\tif len(api.transportCalls) != 1 {\n\t\tt.Fatalf(\"transportCalls = %d, want 1\", len(api.transportCalls))\n\t}\n\tupload := api.transportCalls[0]\n\tif upload.method != \"POST\" {\n\t\tt.Fatalf(\"upload method = %q, want POST\", upload.method)\n\t}\n\tif upload.url != \"https://api.sgroup.qq.com/v2/groups/group-1/files\" {\n\t\tt.Fatalf(\"upload url = %q\", upload.url)\n\t}\n\tif upload.body.URL != \"\" {\n\t\tt.Fatalf(\"upload URL = %q, want empty\", upload.body.URL)\n\t}\n\twantBase64 := base64.StdEncoding.EncodeToString(content)\n\tif upload.body.FileData != wantBase64 {\n\t\tt.Fatalf(\"upload file_data = %q, want %q\", upload.body.FileData, wantBase64)\n\t}\n\tif upload.body.FileType != 1 {\n\t\tt.Fatalf(\"upload file_type = %d, want 1\", upload.body.FileType)\n\t}\n\n\tif len(api.groupMessages) != 1 {\n\t\tt.Fatalf(\"groupMessages = %d, want 1\", len(api.groupMessages))\n\t}\n\tmsg, ok := api.groupMessages[0].(*dto.MessageToCreate)\n\tif !ok {\n\t\tt.Fatalf(\"groupMessages[0] type = %T, want *dto.MessageToCreate\", api.groupMessages[0])\n\t}\n\tif msg.MsgType != dto.RichMediaMsg {\n\t\tt.Fatalf(\"msg.MsgType = %d, want %d\", msg.MsgType, dto.RichMediaMsg)\n\t}\n\tif msg.MsgID != \"msg-1\" {\n\t\tt.Fatalf(\"msg.MsgID = %q, want msg-1\", msg.MsgID)\n\t}\n\tif msg.MsgSeq != 1 {\n\t\tt.Fatalf(\"msg.MsgSeq = %d, want 1\", msg.MsgSeq)\n\t}\n\tif msg.Content != \"see https://example。com/image\" {\n\t\tt.Fatalf(\"msg.Content = %q\", msg.Content)\n\t}\n\tif msg.Media == nil || string(msg.Media.FileInfo) != \"uploaded-file-info\" {\n\t\tt.Fatalf(\"msg.Media.FileInfo = %q, want uploaded-file-info\", string(msg.Media.FileInfo))\n\t}\n}\n\nfunc TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tapi := &fakeQQAPI{\n\t\ttransportResp: mustJSON(t, dto.Message{FileInfo: []byte(\"remote-file-info\")}),\n\t}\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tapi:         api,\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t}\n\tch.SetRunning(true)\n\tch.chatType.Store(\"user-1\", \"direct\")\n\n\terr := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"user-1\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType: \"file\",\n\t\t\tRef:  \"https://cdn.example.com/report.pdf\",\n\t\t}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"SendMedia() error = %v\", err)\n\t}\n\n\tif len(api.transportCalls) != 1 {\n\t\tt.Fatalf(\"transportCalls = %d, want 1\", len(api.transportCalls))\n\t}\n\tupload := api.transportCalls[0]\n\tif upload.url != \"https://api.sgroup.qq.com/v2/users/user-1/files\" {\n\t\tt.Fatalf(\"upload url = %q\", upload.url)\n\t}\n\tif upload.body.URL != \"https://cdn.example.com/report.pdf\" {\n\t\tt.Fatalf(\"upload URL = %q\", upload.body.URL)\n\t}\n\tif upload.body.FileData != \"\" {\n\t\tt.Fatalf(\"upload file_data = %q, want empty\", upload.body.FileData)\n\t}\n\tif upload.body.FileType != 4 {\n\t\tt.Fatalf(\"upload file_type = %d, want 4\", upload.body.FileType)\n\t}\n\n\tif len(api.c2cMessages) != 1 {\n\t\tt.Fatalf(\"c2cMessages = %d, want 1\", len(api.c2cMessages))\n\t}\n\tmsg, ok := api.c2cMessages[0].(*dto.MessageToCreate)\n\tif !ok {\n\t\tt.Fatalf(\"c2cMessages[0] type = %T, want *dto.MessageToCreate\", api.c2cMessages[0])\n\t}\n\tif msg.MsgType != dto.RichMediaMsg {\n\t\tt.Fatalf(\"msg.MsgType = %d, want %d\", msg.MsgType, dto.RichMediaMsg)\n\t}\n\tif msg.Media == nil || string(msg.Media.FileInfo) != \"remote-file-info\" {\n\t\tt.Fatalf(\"msg.Media.FileInfo = %q, want remote-file-info\", string(msg.Media.FileInfo))\n\t}\n}\n\nfunc TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tapi:         &fakeQQAPI{},\n\t\tdedup:       make(map[string]time.Time),\n\t\tdone:        make(chan struct{}),\n\t\tctx:         context.Background(),\n\t}\n\tch.SetRunning(true)\n\tch.chatType.Store(\"group-1\", \"group\")\n\n\terr := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"group-1\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType: \"image\",\n\t\t\tRef:  \"media://missing\",\n\t\t}},\n\t})\n\tif !errors.Is(err, channels.ErrSendFailed) {\n\t\tt.Fatalf(\"SendMedia() error = %v, want ErrSendFailed\", err)\n\t}\n}\n\nfunc TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tstore := media.NewFileMediaStore()\n\n\ttmpFile, err := os.CreateTemp(t.TempDir(), \"qq-media-too-large-*.bin\")\n\tif err != nil {\n\t\tt.Fatalf(\"CreateTemp() error = %v\", err)\n\t}\n\tdefer tmpFile.Close()\n\n\tcontent := make([]byte, bytesPerMiB+1)\n\tif _, writeErr := tmpFile.Write(content); writeErr != nil {\n\t\tt.Fatalf(\"Write() error = %v\", writeErr)\n\t}\n\n\tref, err := store.Store(tmpFile.Name(), media.MediaMeta{\n\t\tFilename:    \"large.bin\",\n\t\tContentType: \"application/octet-stream\",\n\t}, \"qq:test\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store() error = %v\", err)\n\t}\n\n\tapi := &fakeQQAPI{}\n\tch := &QQChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"qq\", nil, messageBus, nil),\n\t\tconfig: config.QQConfig{\n\t\t\tMaxBase64FileSizeMiB: 1,\n\t\t},\n\t\tapi:   api,\n\t\tdedup: make(map[string]time.Time),\n\t\tdone:  make(chan struct{}),\n\t\tctx:   context.Background(),\n\t}\n\tch.SetRunning(true)\n\tch.SetMediaStore(store)\n\tch.chatType.Store(\"group-1\", \"group\")\n\n\terr = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"group-1\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType: \"file\",\n\t\t\tRef:  ref,\n\t\t}},\n\t})\n\tif !errors.Is(err, channels.ErrSendFailed) {\n\t\tt.Fatalf(\"SendMedia() error = %v, want ErrSendFailed\", err)\n\t}\n\tif len(api.transportCalls) != 0 {\n\t\tt.Fatalf(\"transportCalls = %d, want 0\", len(api.transportCalls))\n\t}\n}\n\ntype fakeQQAPI struct {\n\ttransportResp  []byte\n\ttransportErr   error\n\tgroupErr       error\n\tc2cErr         error\n\ttransportCalls []fakeTransportCall\n\tgroupMessages  []dto.APIMessage\n\tc2cMessages    []dto.APIMessage\n}\n\ntype fakeTransportCall struct {\n\tmethod string\n\turl    string\n\tbody   qqMediaUpload\n}\n\nfunc (f *fakeQQAPI) WS(\n\tcontext.Context,\n\tmap[string]string,\n\tstring,\n) (*dto.WebsocketAP, error) {\n\treturn nil, nil\n}\n\nfunc (f *fakeQQAPI) PostGroupMessage(\n\t_ context.Context,\n\t_ string,\n\tmsg dto.APIMessage,\n\t_ ...options.Option,\n) (*dto.Message, error) {\n\tf.groupMessages = append(f.groupMessages, msg)\n\treturn &dto.Message{}, f.groupErr\n}\n\nfunc (f *fakeQQAPI) PostC2CMessage(\n\t_ context.Context,\n\t_ string,\n\tmsg dto.APIMessage,\n\t_ ...options.Option,\n) (*dto.Message, error) {\n\tf.c2cMessages = append(f.c2cMessages, msg)\n\treturn &dto.Message{}, f.c2cErr\n}\n\nfunc (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) {\n\tupload, ok := body.(*qqMediaUpload)\n\tif !ok {\n\t\treturn nil, errors.New(\"unexpected transport body type\")\n\t}\n\tf.transportCalls = append(f.transportCalls, fakeTransportCall{\n\t\tmethod: method,\n\t\turl:    url,\n\t\tbody:   *upload,\n\t})\n\treturn f.transportResp, f.transportErr\n}\n\nfunc mustJSON(t *testing.T, v any) []byte {\n\tt.Helper()\n\n\tb, err := json.Marshal(v)\n\tif err != nil {\n\t\tt.Fatalf(\"json.Marshal() error = %v\", err)\n\t}\n\treturn b\n}\n\nfunc waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage {\n\tt.Helper()\n\n\tctx, cancel := context.WithTimeout(context.Background(), time.Second)\n\tdefer cancel()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tt.Fatal(\"timeout waiting for inbound message\")\n\t\tcase inbound, ok := <-messageBus.InboundChan():\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"expected inbound message\")\n\t\t\t}\n\t\t\treturn inbound\n\t\t}\n\t}\n}\n\nfunc writeTempFile(t *testing.T, dir, name string, content []byte) string {\n\tt.Helper()\n\n\tpath := dir + \"/\" + name\n\tif err := os.WriteFile(path, content, 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\treturn path\n}\n"
  },
  {
    "path": "pkg/channels/registry.go",
    "content": "package channels\n\nimport (\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// ChannelFactory is a constructor function that creates a Channel from config and message bus.\n// Each channel subpackage registers one or more factories via init().\ntype ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)\n\nvar (\n\tfactoriesMu sync.RWMutex\n\tfactories   = map[string]ChannelFactory{}\n)\n\n// RegisterFactory registers a named channel factory. Called from subpackage init() functions.\nfunc RegisterFactory(name string, f ChannelFactory) {\n\tfactoriesMu.Lock()\n\tdefer factoriesMu.Unlock()\n\tfactories[name] = f\n}\n\n// getFactory looks up a channel factory by name.\nfunc getFactory(name string) (ChannelFactory, bool) {\n\tfactoriesMu.RLock()\n\tdefer factoriesMu.RUnlock()\n\tf, ok := factories[name]\n\treturn f, ok\n}\n"
  },
  {
    "path": "pkg/channels/slack/init.go",
    "content": "package slack\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"slack\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewSlackChannel(cfg.Channels.Slack, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/slack/slack.go",
    "content": "package slack\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/slack-go/slack\"\n\t\"github.com/slack-go/slack/slackevents\"\n\t\"github.com/slack-go/slack/socketmode\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\ntype SlackChannel struct {\n\t*channels.BaseChannel\n\tconfig       config.SlackConfig\n\tapi          *slack.Client\n\tsocketClient *socketmode.Client\n\tbotUserID    string\n\tteamID       string\n\tctx          context.Context\n\tcancel       context.CancelFunc\n\tpendingAcks  sync.Map\n}\n\ntype slackMessageRef struct {\n\tChannelID string\n\tTimestamp string\n}\n\nfunc NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {\n\tif cfg.BotToken == \"\" || cfg.AppToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"slack bot_token and app_token are required\")\n\t}\n\n\tapi := slack.New(\n\t\tcfg.BotToken,\n\t\tslack.OptionAppLevelToken(cfg.AppToken),\n\t)\n\n\tsocketClient := socketmode.New(api)\n\n\tbase := channels.NewBaseChannel(\"slack\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(40000),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &SlackChannel{\n\t\tBaseChannel:  base,\n\t\tconfig:       cfg,\n\t\tapi:          api,\n\t\tsocketClient: socketClient,\n\t}, nil\n}\n\nfunc (c *SlackChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"slack\", \"Starting Slack channel (Socket Mode)\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tauthResp, err := c.api.AuthTest()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"slack auth test failed: %w\", err)\n\t}\n\tc.botUserID = authResp.UserID\n\tc.teamID = authResp.TeamID\n\n\tlogger.InfoCF(\"slack\", \"Slack bot connected\", map[string]any{\n\t\t\"bot_user_id\": c.botUserID,\n\t\t\"team\":        authResp.Team,\n\t})\n\n\tgo c.eventLoop()\n\n\tgo func() {\n\t\tif err := c.socketClient.RunContext(c.ctx); err != nil {\n\t\t\tif c.ctx.Err() == nil {\n\t\t\t\tlogger.ErrorCF(\"slack\", \"Socket Mode connection error\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}()\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"slack\", \"Slack channel started (Socket Mode)\")\n\treturn nil\n}\n\nfunc (c *SlackChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"slack\", \"Stopping Slack channel\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"slack\", \"Slack channel stopped\")\n\treturn nil\n}\n\nfunc (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchannelID, threadTS := parseSlackChatID(msg.ChatID)\n\tif channelID == \"\" {\n\t\treturn fmt.Errorf(\"invalid slack chat ID: %s\", msg.ChatID)\n\t}\n\n\topts := []slack.MsgOption{\n\t\tslack.MsgOptionText(msg.Content, false),\n\t}\n\n\tif msg.ReplyToMessageID != \"\" && threadTS == \"\" {\n\t\t// Answer to the message by creating a Thread under it\n\t\topts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))\n\t} else if threadTS != \"\" {\n\t\t// If we are already in a thread, continue in the thread\n\t\topts = append(opts, slack.MsgOptionTS(threadTS))\n\t}\n\n\t_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"slack send: %w\", channels.ErrTemporary)\n\t}\n\n\tif ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {\n\t\tmsgRef := ref.(slackMessageRef)\n\t\tc.api.AddReaction(\"white_check_mark\", slack.ItemRef{\n\t\t\tChannel:   msgRef.ChannelID,\n\t\t\tTimestamp: msgRef.Timestamp,\n\t\t})\n\t}\n\n\tlogger.DebugCF(\"slack\", \"Message sent\", map[string]any{\n\t\t\"channel_id\": channelID,\n\t\t\"thread_ts\":  threadTS,\n\t})\n\n\treturn nil\n}\n\n// SendMedia implements the channels.MediaSender interface.\nfunc (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchannelID, _ := parseSlackChatID(msg.ChatID)\n\tif channelID == \"\" {\n\t\treturn fmt.Errorf(\"invalid slack chat ID: %s\", msg.ChatID)\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tfor _, part := range msg.Parts {\n\t\tlocalPath, err := store.Resolve(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"slack\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfilename := part.Filename\n\t\tif filename == \"\" {\n\t\t\tfilename = \"file\"\n\t\t}\n\n\t\ttitle := part.Caption\n\t\tif title == \"\" {\n\t\t\ttitle = filename\n\t\t}\n\n\t\t_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{\n\t\t\tChannel:  channelID,\n\t\t\tFile:     localPath,\n\t\t\tFilename: filename,\n\t\t\tTitle:    title,\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"slack\", \"Failed to upload media\", map[string]any{\n\t\t\t\t\"filename\": filename,\n\t\t\t\t\"error\":    err.Error(),\n\t\t\t})\n\t\t\treturn fmt.Errorf(\"slack send media: %w\", channels.ErrTemporary)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ReactToMessage implements channels.ReactionCapable.\n// It adds an \"eyes\" (👀) reaction to the inbound message and returns an undo function\n// that removes the reaction.\nfunc (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {\n\tchannelID, _ := parseSlackChatID(chatID)\n\tif channelID == \"\" {\n\t\treturn func() {}, nil\n\t}\n\n\tc.api.AddReaction(\"eyes\", slack.ItemRef{\n\t\tChannel:   channelID,\n\t\tTimestamp: messageID,\n\t})\n\n\treturn func() {\n\t\tc.api.RemoveReaction(\"eyes\", slack.ItemRef{\n\t\t\tChannel:   channelID,\n\t\t\tTimestamp: messageID,\n\t\t})\n\t}, nil\n}\n\nfunc (c *SlackChannel) eventLoop() {\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tcase event, ok := <-c.socketClient.Events:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch event.Type {\n\t\t\tcase socketmode.EventTypeEventsAPI:\n\t\t\t\tc.handleEventsAPI(event)\n\t\t\tcase socketmode.EventTypeSlashCommand:\n\t\t\t\tc.handleSlashCommand(event)\n\t\t\tcase socketmode.EventTypeInteractive:\n\t\t\t\tif event.Request != nil {\n\t\t\t\t\tc.socketClient.Ack(*event.Request)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *SlackChannel) handleEventsAPI(event socketmode.Event) {\n\tif event.Request != nil {\n\t\tc.socketClient.Ack(*event.Request)\n\t}\n\n\teventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent)\n\tif !ok {\n\t\treturn\n\t}\n\n\tswitch ev := eventsAPIEvent.InnerEvent.Data.(type) {\n\tcase *slackevents.MessageEvent:\n\t\tc.handleMessageEvent(ev)\n\tcase *slackevents.AppMentionEvent:\n\t\tc.handleAppMention(ev)\n\t}\n}\n\nfunc (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {\n\tif ev.User == c.botUserID || ev.User == \"\" {\n\t\treturn\n\t}\n\tif ev.BotID != \"\" {\n\t\treturn\n\t}\n\tif ev.SubType != \"\" && ev.SubType != \"file_share\" {\n\t\treturn\n\t}\n\n\t// check allowlist to avoid downloading attachments for rejected users\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"slack\",\n\t\tPlatformID:  ev.User,\n\t\tCanonicalID: identity.BuildCanonicalID(\"slack\", ev.User),\n\t}\n\tif !c.IsAllowedSender(sender) {\n\t\tlogger.DebugCF(\"slack\", \"Message rejected by allowlist\", map[string]any{\n\t\t\t\"user_id\": ev.User,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := ev.User\n\tchannelID := ev.Channel\n\tthreadTS := ev.ThreadTimeStamp\n\tmessageTS := ev.TimeStamp\n\n\tchatID := channelID\n\tif threadTS != \"\" {\n\t\tchatID = channelID + \"/\" + threadTS\n\t}\n\n\tc.pendingAcks.Store(chatID, slackMessageRef{\n\t\tChannelID: channelID,\n\t\tTimestamp: messageTS,\n\t})\n\n\tcontent := ev.Text\n\tcontent = c.stripBotMention(content)\n\n\t// In non-DM channels, apply group trigger filtering\n\tif !strings.HasPrefix(channelID, \"D\") {\n\t\trespond, cleaned := c.ShouldRespondInGroup(false, content)\n\t\tif !respond {\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tvar mediaPaths []string\n\n\tscope := channels.BuildMediaScope(\"slack\", chatID, messageTS)\n\n\t// Helper to register a local file with the media store\n\tstoreMedia := func(localPath, filename string) string {\n\t\tif store := c.GetMediaStore(); store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename: filename,\n\t\t\t\tSource:   \"slack\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath // fallback\n\t}\n\n\tif ev.Message != nil && len(ev.Message.Files) > 0 {\n\t\tfor _, file := range ev.Message.Files {\n\t\t\tlocalPath := c.downloadSlackFile(file)\n\t\t\tif localPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))\n\t\t\tcontent += fmt.Sprintf(\"\\n[file: %s]\", file.Name)\n\t\t}\n\t}\n\n\tif strings.TrimSpace(content) == \"\" {\n\t\treturn\n\t}\n\n\tpeerKind := \"channel\"\n\tpeerID := channelID\n\tif strings.HasPrefix(channelID, \"D\") {\n\t\tpeerKind = \"direct\"\n\t\tpeerID = senderID\n\t}\n\n\tpeer := bus.Peer{Kind: peerKind, ID: peerID}\n\n\tmetadata := map[string]string{\n\t\t\"message_ts\": messageTS,\n\t\t\"channel_id\": channelID,\n\t\t\"thread_ts\":  threadTS,\n\t\t\"platform\":   \"slack\",\n\t\t\"team_id\":    c.teamID,\n\t}\n\n\tlogger.DebugCF(\"slack\", \"Received message\", map[string]any{\n\t\t\"sender_id\":  senderID,\n\t\t\"chat_id\":    chatID,\n\t\t\"preview\":    utils.Truncate(content, 50),\n\t\t\"has_thread\": threadTS != \"\",\n\t})\n\n\tc.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)\n}\n\nfunc (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {\n\tif ev.User == c.botUserID {\n\t\treturn\n\t}\n\n\tif !c.IsAllowedSender(bus.SenderInfo{\n\t\tPlatform:    \"slack\",\n\t\tPlatformID:  ev.User,\n\t\tCanonicalID: identity.BuildCanonicalID(\"slack\", ev.User),\n\t}) {\n\t\tlogger.DebugCF(\"slack\", \"Mention rejected by allowlist\", map[string]any{\n\t\t\t\"user_id\": ev.User,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := ev.User\n\tmentionSender := bus.SenderInfo{\n\t\tPlatform:    \"slack\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"slack\", senderID),\n\t}\n\tchannelID := ev.Channel\n\tthreadTS := ev.ThreadTimeStamp\n\tmessageTS := ev.TimeStamp\n\n\tvar chatID string\n\tif threadTS != \"\" {\n\t\tchatID = channelID + \"/\" + threadTS\n\t} else {\n\t\tchatID = channelID + \"/\" + messageTS\n\t}\n\n\tc.pendingAcks.Store(chatID, slackMessageRef{\n\t\tChannelID: channelID,\n\t\tTimestamp: messageTS,\n\t})\n\n\tcontent := c.stripBotMention(ev.Text)\n\n\tif strings.TrimSpace(content) == \"\" {\n\t\treturn\n\t}\n\n\tmentionPeerKind := \"channel\"\n\tmentionPeerID := channelID\n\tif strings.HasPrefix(channelID, \"D\") {\n\t\tmentionPeerKind = \"direct\"\n\t\tmentionPeerID = senderID\n\t}\n\n\tmentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}\n\n\tmetadata := map[string]string{\n\t\t\"message_ts\": messageTS,\n\t\t\"channel_id\": channelID,\n\t\t\"thread_ts\":  threadTS,\n\t\t\"platform\":   \"slack\",\n\t\t\"is_mention\": \"true\",\n\t\t\"team_id\":    c.teamID,\n\t}\n\n\tc.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)\n}\n\nfunc (c *SlackChannel) handleSlashCommand(event socketmode.Event) {\n\tcmd, ok := event.Data.(slack.SlashCommand)\n\tif !ok {\n\t\treturn\n\t}\n\n\tif event.Request != nil {\n\t\tc.socketClient.Ack(*event.Request)\n\t}\n\n\tcmdSender := bus.SenderInfo{\n\t\tPlatform:    \"slack\",\n\t\tPlatformID:  cmd.UserID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"slack\", cmd.UserID),\n\t}\n\tif !c.IsAllowedSender(cmdSender) {\n\t\tlogger.DebugCF(\"slack\", \"Slash command rejected by allowlist\", map[string]any{\n\t\t\t\"user_id\": cmd.UserID,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := cmd.UserID\n\tchannelID := cmd.ChannelID\n\tchatID := channelID\n\tcontent := cmd.Text\n\n\tif strings.TrimSpace(content) == \"\" {\n\t\tcontent = \"help\"\n\t}\n\n\tmetadata := map[string]string{\n\t\t\"channel_id\": channelID,\n\t\t\"platform\":   \"slack\",\n\t\t\"is_command\": \"true\",\n\t\t\"trigger_id\": cmd.TriggerID,\n\t\t\"team_id\":    c.teamID,\n\t}\n\n\tlogger.DebugCF(\"slack\", \"Slash command received\", map[string]any{\n\t\t\"sender_id\": senderID,\n\t\t\"command\":   cmd.Command,\n\t\t\"text\":      utils.Truncate(content, 50),\n\t})\n\n\tc.HandleMessage(\n\t\tc.ctx,\n\t\tbus.Peer{Kind: \"channel\", ID: channelID},\n\t\t\"\",\n\t\tsenderID,\n\t\tchatID,\n\t\tcontent,\n\t\tnil,\n\t\tmetadata,\n\t\tcmdSender,\n\t)\n}\n\nfunc (c *SlackChannel) downloadSlackFile(file slack.File) string {\n\tdownloadURL := file.URLPrivateDownload\n\tif downloadURL == \"\" {\n\t\tdownloadURL = file.URLPrivate\n\t}\n\tif downloadURL == \"\" {\n\t\tlogger.ErrorCF(\"slack\", \"No download URL for file\", map[string]any{\"file_id\": file.ID})\n\t\treturn \"\"\n\t}\n\n\treturn utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{\n\t\tLoggerPrefix: \"slack\",\n\t\tExtraHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer \" + c.config.BotToken,\n\t\t},\n\t})\n}\n\nfunc (c *SlackChannel) stripBotMention(text string) string {\n\tmention := fmt.Sprintf(\"<@%s>\", c.botUserID)\n\ttext = strings.ReplaceAll(text, mention, \"\")\n\treturn strings.TrimSpace(text)\n}\n\nfunc parseSlackChatID(chatID string) (channelID, threadTS string) {\n\tparts := strings.SplitN(chatID, \"/\", 2)\n\tchannelID = parts[0]\n\tif len(parts) > 1 {\n\t\tthreadTS = parts[1]\n\t}\n\treturn channelID, threadTS\n}\n"
  },
  {
    "path": "pkg/channels/slack/slack_test.go",
    "content": "package slack\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestParseSlackChatID(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tchatID     string\n\t\twantChanID string\n\t\twantThread string\n\t}{\n\t\t{\n\t\t\tname:       \"channel only\",\n\t\t\tchatID:     \"C123456\",\n\t\t\twantChanID: \"C123456\",\n\t\t\twantThread: \"\",\n\t\t},\n\t\t{\n\t\t\tname:       \"channel with thread\",\n\t\t\tchatID:     \"C123456/1234567890.123456\",\n\t\t\twantChanID: \"C123456\",\n\t\t\twantThread: \"1234567890.123456\",\n\t\t},\n\t\t{\n\t\t\tname:       \"DM channel\",\n\t\t\tchatID:     \"D987654\",\n\t\t\twantChanID: \"D987654\",\n\t\t\twantThread: \"\",\n\t\t},\n\t\t{\n\t\t\tname:       \"empty string\",\n\t\t\tchatID:     \"\",\n\t\t\twantChanID: \"\",\n\t\t\twantThread: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchanID, threadTS := parseSlackChatID(tt.chatID)\n\t\t\tif chanID != tt.wantChanID {\n\t\t\t\tt.Errorf(\"parseSlackChatID(%q) channelID = %q, want %q\", tt.chatID, chanID, tt.wantChanID)\n\t\t\t}\n\t\t\tif threadTS != tt.wantThread {\n\t\t\t\tt.Errorf(\"parseSlackChatID(%q) threadTS = %q, want %q\", tt.chatID, threadTS, tt.wantThread)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStripBotMention(t *testing.T) {\n\tch := &SlackChannel{botUserID: \"U12345BOT\"}\n\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t\twant  string\n\t}{\n\t\t{\n\t\t\tname:  \"mention at start\",\n\t\t\tinput: \"<@U12345BOT> hello there\",\n\t\t\twant:  \"hello there\",\n\t\t},\n\t\t{\n\t\t\tname:  \"mention in middle\",\n\t\t\tinput: \"hey <@U12345BOT> can you help\",\n\t\t\twant:  \"hey  can you help\",\n\t\t},\n\t\t{\n\t\t\tname:  \"no mention\",\n\t\t\tinput: \"hello world\",\n\t\t\twant:  \"hello world\",\n\t\t},\n\t\t{\n\t\t\tname:  \"empty string\",\n\t\t\tinput: \"\",\n\t\t\twant:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:  \"only mention\",\n\t\t\tinput: \"<@U12345BOT>\",\n\t\t\twant:  \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ch.stripBotMention(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"stripBotMention(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewSlackChannel(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"missing bot token\", func(t *testing.T) {\n\t\tcfg := config.SlackConfig{\n\t\t\tBotToken: \"\",\n\t\t\tAppToken: \"xapp-test\",\n\t\t}\n\t\t_, err := NewSlackChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing bot_token, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing app token\", func(t *testing.T) {\n\t\tcfg := config.SlackConfig{\n\t\t\tBotToken: \"xoxb-test\",\n\t\t\tAppToken: \"\",\n\t\t}\n\t\t_, err := NewSlackChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing app_token, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"valid config\", func(t *testing.T) {\n\t\tcfg := config.SlackConfig{\n\t\t\tBotToken:  \"xoxb-test\",\n\t\t\tAppToken:  \"xapp-test\",\n\t\t\tAllowFrom: []string{\"U123\"},\n\t\t}\n\t\tch, err := NewSlackChannel(cfg, msgBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif ch.Name() != \"slack\" {\n\t\t\tt.Errorf(\"Name() = %q, want %q\", ch.Name(), \"slack\")\n\t\t}\n\t\tif ch.IsRunning() {\n\t\t\tt.Error(\"new channel should not be running\")\n\t\t}\n\t})\n}\n\nfunc TestSlackChannelIsAllowed(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"empty allowlist allows all\", func(t *testing.T) {\n\t\tcfg := config.SlackConfig{\n\t\t\tBotToken:  \"xoxb-test\",\n\t\t\tAppToken:  \"xapp-test\",\n\t\t\tAllowFrom: []string{},\n\t\t}\n\t\tch, _ := NewSlackChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"U_ANYONE\") {\n\t\t\tt.Error(\"empty allowlist should allow all users\")\n\t\t}\n\t})\n\n\tt.Run(\"allowlist restricts users\", func(t *testing.T) {\n\t\tcfg := config.SlackConfig{\n\t\t\tBotToken:  \"xoxb-test\",\n\t\t\tAppToken:  \"xapp-test\",\n\t\t\tAllowFrom: []string{\"U_ALLOWED\"},\n\t\t}\n\t\tch, _ := NewSlackChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"U_ALLOWED\") {\n\t\t\tt.Error(\"allowed user should pass allowlist check\")\n\t\t}\n\t\tif ch.IsAllowed(\"U_BLOCKED\") {\n\t\t\tt.Error(\"non-allowed user should be blocked\")\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/split.go",
    "content": "package channels\n\nimport (\n\t\"strings\"\n)\n\n// SplitMessage splits long messages into chunks, preserving code block integrity.\n// The maxLen parameter is measured in runes (Unicode characters), not bytes.\n// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,\n// but may extend to maxLen when needed.\n// Call SplitMessage with the full text content and the maximum allowed length of a single message;\n// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.\nfunc SplitMessage(content string, maxLen int) []string {\n\tif maxLen <= 0 {\n\t\tif content == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn []string{content}\n\t}\n\n\trunes := []rune(content)\n\ttotalLen := len(runes)\n\tvar messages []string\n\n\t// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible\n\tcodeBlockBuffer := max(maxLen/10, 50)\n\tif codeBlockBuffer > maxLen/2 {\n\t\tcodeBlockBuffer = maxLen / 2\n\t}\n\n\tstart := 0\n\tfor start < totalLen {\n\t\tremaining := totalLen - start\n\t\tif remaining <= maxLen {\n\t\t\tmessages = append(messages, string(runes[start:totalLen]))\n\t\t\tbreak\n\t\t}\n\n\t\t// Effective split point: maxLen minus buffer, to leave room for code blocks\n\t\teffectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)\n\n\t\tend := start + effectiveLimit\n\n\t\t// Find natural split point within the effective limit\n\t\tmsgEnd := findLastNewlineInRange(runes, start, end, 200)\n\t\tif msgEnd <= start {\n\t\t\tmsgEnd = findLastSpaceInRange(runes, start, end, 100)\n\t\t}\n\t\tif msgEnd <= start {\n\t\t\tmsgEnd = end\n\t\t}\n\n\t\t// Check if this would end with an incomplete code block\n\t\tunclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)\n\n\t\tif unclosedIdx >= 0 {\n\t\t\t// Message would end with incomplete code block\n\t\t\t// Try to extend up to maxLen to include the closing ```\n\t\t\tif totalLen > msgEnd {\n\t\t\t\tclosingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen)\n\t\t\t\tif closingIdx > 0 && closingIdx-start <= maxLen {\n\t\t\t\t\t// Extend to include the closing ```\n\t\t\t\t\tmsgEnd = closingIdx\n\t\t\t\t} else {\n\t\t\t\t\t// Code block is too long to fit in one chunk or missing closing fence.\n\t\t\t\t\t// Try to split inside by injecting closing and reopening fences.\n\t\t\t\t\theaderEnd := findNewlineFrom(runes, unclosedIdx)\n\t\t\t\t\tvar header string\n\t\t\t\t\tif headerEnd == -1 {\n\t\t\t\t\t\theader = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3]))\n\t\t\t\t\t} else {\n\t\t\t\t\t\theader = strings.TrimSpace(string(runes[unclosedIdx:headerEnd]))\n\t\t\t\t\t}\n\t\t\t\t\theaderEndIdx := unclosedIdx + len([]rune(header))\n\t\t\t\t\tif headerEnd != -1 {\n\t\t\t\t\t\theaderEndIdx = headerEnd\n\t\t\t\t\t}\n\n\t\t\t\t\t// If we have a reasonable amount of content after the header, split inside\n\t\t\t\t\tif msgEnd > headerEndIdx+20 {\n\t\t\t\t\t\t// Find a better split point closer to maxLen\n\t\t\t\t\t\tinnerLimit := min(\n\t\t\t\t\t\t\t// Leave room for \"\\n```\"\n\t\t\t\t\t\t\tstart+maxLen-5, totalLen)\n\t\t\t\t\t\tbetterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)\n\t\t\t\t\t\tif betterEnd > headerEndIdx {\n\t\t\t\t\t\t\tmsgEnd = betterEnd\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tmsgEnd = innerLimit\n\t\t\t\t\t\t}\n\t\t\t\t\t\tchunk := strings.TrimRight(string(runes[start:msgEnd]), \" \\t\\n\\r\") + \"\\n```\"\n\t\t\t\t\t\tmessages = append(messages, chunk)\n\t\t\t\t\t\tremaining := strings.TrimSpace(header + \"\\n\" + string(runes[msgEnd:totalLen]))\n\t\t\t\t\t\t// Replace the tail of runes with the reconstructed remaining\n\t\t\t\t\t\trunes = []rune(remaining)\n\t\t\t\t\t\ttotalLen = len(runes)\n\t\t\t\t\t\tstart = 0\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\t// Otherwise, try to split before the code block starts\n\t\t\t\t\tnewEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200)\n\t\t\t\t\tif newEnd <= start {\n\t\t\t\t\t\tnewEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100)\n\t\t\t\t\t}\n\t\t\t\t\tif newEnd > start {\n\t\t\t\t\t\tmsgEnd = newEnd\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// If we can't split before, we MUST split inside (last resort)\n\t\t\t\t\t\tif unclosedIdx-start > 20 {\n\t\t\t\t\t\t\tmsgEnd = unclosedIdx\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsplitAt := min(start+maxLen-5, totalLen)\n\t\t\t\t\t\t\tchunk := strings.TrimRight(string(runes[start:splitAt]), \" \\t\\n\\r\") + \"\\n```\"\n\t\t\t\t\t\t\tmessages = append(messages, chunk)\n\t\t\t\t\t\t\tremaining := strings.TrimSpace(header + \"\\n\" + string(runes[splitAt:totalLen]))\n\t\t\t\t\t\t\trunes = []rune(remaining)\n\t\t\t\t\t\t\ttotalLen = len(runes)\n\t\t\t\t\t\t\tstart = 0\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif msgEnd <= start {\n\t\t\tmsgEnd = start + effectiveLimit\n\t\t}\n\n\t\tmessages = append(messages, string(runes[start:msgEnd]))\n\t\t// Advance start, skipping leading whitespace of next chunk\n\t\tstart = msgEnd\n\t\tfor start < totalLen && (runes[start] == ' ' || runes[start] == '\\t' || runes[start] == '\\n' || runes[start] == '\\r') {\n\t\t\tstart++\n\t\t}\n\t}\n\n\treturn messages\n}\n\n// findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ```\n// within runes[start:end]. Returns the absolute rune index or -1.\nfunc findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int {\n\tinCodeBlock := false\n\tlastOpenIdx := -1\n\n\tfor i := start; i < end; i++ {\n\t\tif i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {\n\t\t\tif !inCodeBlock {\n\t\t\t\tlastOpenIdx = i\n\t\t\t}\n\t\t\tinCodeBlock = !inCodeBlock\n\t\t\ti += 2\n\t\t}\n\t}\n\n\tif inCodeBlock {\n\t\treturn lastOpenIdx\n\t}\n\treturn -1\n}\n\n// findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx\n// within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1.\nfunc findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int {\n\tfor i := startIdx; i < end; i++ {\n\t\tif i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {\n\t\t\treturn i + 3\n\t\t}\n\t}\n\treturn -1\n}\n\n// findNewlineFrom finds the first newline character starting from the given index.\n// Returns the absolute index or -1 if not found.\nfunc findNewlineFrom(runes []rune, from int) int {\n\tfor i := from; i < len(runes); i++ {\n\t\tif runes[i] == '\\n' {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\n// findLastNewlineInRange finds the last newline within the last searchWindow runes\n// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).\nfunc findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {\n\tsearchStart := max(end-searchWindow, start)\n\tfor i := end - 1; i >= searchStart; i-- {\n\t\tif runes[i] == '\\n' {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn start - 1\n}\n\n// findLastSpaceInRange finds the last space/tab within the last searchWindow runes\n// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).\nfunc findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {\n\tsearchStart := max(end-searchWindow, start)\n\tfor i := end - 1; i >= searchStart; i-- {\n\t\tif runes[i] == ' ' || runes[i] == '\\t' {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn start - 1\n}\n"
  },
  {
    "path": "pkg/channels/split_test.go",
    "content": "package channels\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestSplitMessage(t *testing.T) {\n\tlongText := strings.Repeat(\"a\", 2500)\n\tlongCode := \"```go\\n\" + strings.Repeat(\"fmt.Println(\\\"hello\\\")\\n\", 100) + \"```\" // ~2100 chars\n\n\ttests := []struct {\n\t\tname         string\n\t\tcontent      string\n\t\tmaxLen       int\n\t\texpectChunks int                                 // Check number of chunks\n\t\tcheckContent func(t *testing.T, chunks []string) // Custom validation\n\t}{\n\t\t{\n\t\t\tname:         \"Empty message\",\n\t\t\tcontent:      \"\",\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 0,\n\t\t},\n\t\t{\n\t\t\tname:         \"Short message fits in one chunk\",\n\t\t\tcontent:      \"Hello world\",\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 1,\n\t\t},\n\t\t{\n\t\t\tname:         \"Simple split regular text\",\n\t\t\tcontent:      longText,\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 2,\n\t\t\tcheckContent: func(t *testing.T, chunks []string) {\n\t\t\t\tif len([]rune(chunks[0])) > 2000 {\n\t\t\t\t\tt.Errorf(\"Chunk 0 too large: %d runes\", len([]rune(chunks[0])))\n\t\t\t\t}\n\t\t\t\tif len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) {\n\t\t\t\t\tt.Errorf(\n\t\t\t\t\t\t\"Total rune length mismatch. Got %d, want %d\",\n\t\t\t\t\t\tlen([]rune(chunks[0]))+len([]rune(chunks[1])),\n\t\t\t\t\t\tlen([]rune(longText)),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Split at newline\",\n\t\t\t// 1750 chars then newline, then more chars.\n\t\t\t// Dynamic buffer: 2000 / 10 = 200.\n\t\t\t// Effective limit: 2000 - 200 = 1800.\n\t\t\t// Split should happen at newline because it's at 1750 (< 1800).\n\t\t\t// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.\n\t\t\tcontent:      strings.Repeat(\"a\", 1750) + \"\\n\" + strings.Repeat(\"b\", 300),\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 2,\n\t\t\tcheckContent: func(t *testing.T, chunks []string) {\n\t\t\t\tif len([]rune(chunks[0])) != 1750 {\n\t\t\t\t\tt.Errorf(\"Expected chunk 0 to be 1750 runes (split at newline), got %d\", len([]rune(chunks[0])))\n\t\t\t\t}\n\t\t\t\tif chunks[1] != strings.Repeat(\"b\", 300) {\n\t\t\t\t\tt.Errorf(\"Chunk 1 content mismatch. Len: %d\", len([]rune(chunks[1])))\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Long code block split\",\n\t\t\tcontent:      \"Prefix\\n\" + longCode,\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 2,\n\t\t\tcheckContent: func(t *testing.T, chunks []string) {\n\t\t\t\t// Check that first chunk ends with closing fence\n\t\t\t\tif !strings.HasSuffix(chunks[0], \"\\n```\") {\n\t\t\t\t\tt.Error(\"First chunk should end with injected closing fence\")\n\t\t\t\t}\n\t\t\t\t// Check that second chunk starts with execution header\n\t\t\t\tif !strings.HasPrefix(chunks[1], \"```go\") {\n\t\t\t\t\tt.Error(\"Second chunk should start with injected code block header\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Preserve Unicode characters (rune-aware)\",\n\t\t\tcontent:      strings.Repeat(\"\\u4e16\", 2500), // 2500 runes, 7500 bytes\n\t\t\tmaxLen:       2000,\n\t\t\texpectChunks: 2,\n\t\t\tcheckContent: func(t *testing.T, chunks []string) {\n\t\t\t\t// Verify chunks contain valid unicode and don't split mid-rune\n\t\t\t\tfor i, chunk := range chunks {\n\t\t\t\t\truneCount := len([]rune(chunk))\n\t\t\t\t\tif runeCount > 2000 {\n\t\t\t\t\t\tt.Errorf(\"Chunk %d has %d runes, exceeds maxLen 2000\", i, runeCount)\n\t\t\t\t\t}\n\t\t\t\t\tif !strings.Contains(chunk, \"\\u4e16\") {\n\t\t\t\t\t\tt.Errorf(\"Chunk %d should contain unicode characters\", i)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Verify total rune count is preserved\n\t\t\t\ttotalRunes := 0\n\t\t\t\tfor _, chunk := range chunks {\n\t\t\t\t\ttotalRunes += len([]rune(chunk))\n\t\t\t\t}\n\t\t\t\tif totalRunes != 2500 {\n\t\t\t\t\tt.Errorf(\"Total rune count mismatch. Got %d, want 2500\", totalRunes)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Zero maxLen returns single chunk\",\n\t\t\tcontent:      \"Hello world\",\n\t\t\tmaxLen:       0,\n\t\t\texpectChunks: 1,\n\t\t\tcheckContent: func(t *testing.T, chunks []string) {\n\t\t\t\tif chunks[0] != \"Hello world\" {\n\t\t\t\t\tt.Errorf(\"Expected original content, got %q\", chunks[0])\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := SplitMessage(tc.content, tc.maxLen)\n\n\t\t\tif tc.expectChunks == 0 {\n\t\t\t\tif len(got) != 0 {\n\t\t\t\t\tt.Errorf(\"Expected 0 chunks, got %d\", len(got))\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(got) != tc.expectChunks {\n\t\t\t\tt.Errorf(\"Expected %d chunks, got %d\", tc.expectChunks, len(got))\n\t\t\t\t// Log sizes for debugging\n\t\t\t\tfor i, c := range got {\n\t\t\t\t\tt.Logf(\"Chunk %d length: %d\", i, len(c))\n\t\t\t\t}\n\t\t\t\treturn // Stop further checks if count assumes specific split\n\t\t\t}\n\n\t\t\tif tc.checkContent != nil {\n\t\t\t\ttc.checkContent(t, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- Helper function tests for index-based rune operations ---\n\nfunc TestFindLastNewlineInRange(t *testing.T) {\n\trunes := []rune(\"aaa\\nbbb\\nccc\")\n\t// Indices:        0123 4567 89 10\n\n\ttests := []struct {\n\t\tname         string\n\t\tstart, end   int\n\t\tsearchWindow int\n\t\twant         int\n\t}{\n\t\t{\"finds last newline in full range\", 0, 11, 200, 7},\n\t\t{\"finds newline within search window\", 0, 11, 4, 7},\n\t\t{\"narrow window misses newline outside window\", 4, 11, 3, 3}, // returns start-1 (not found)\n\t\t{\"no newline in range\", 0, 3, 200, -1},                       // start-1 = -1\n\t\t{\"range limited to first segment\", 0, 4, 200, 3},\n\t\t{\"search window of 1 at newline\", 0, 8, 1, 7},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d\",\n\t\t\t\t\ttc.start, tc.end, tc.searchWindow, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindLastSpaceInRange(t *testing.T) {\n\trunes := []rune(\"abc def\\tghi\")\n\t// Indices:        0123 4567 89 10\n\n\ttests := []struct {\n\t\tname         string\n\t\tstart, end   int\n\t\tsearchWindow int\n\t\twant         int\n\t}{\n\t\t{\"finds tab as last space/tab\", 0, 11, 200, 7},\n\t\t{\"finds space when tab out of window\", 0, 7, 200, 3},\n\t\t{\"no space in range\", 0, 3, 200, -1},\n\t\t{\"narrow window finds tab\", 5, 11, 4, 7},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d\",\n\t\t\t\t\ttc.start, tc.end, tc.searchWindow, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindNewlineFrom(t *testing.T) {\n\trunes := []rune(\"hello\\nworld\\n\")\n\n\ttests := []struct {\n\t\tname string\n\t\tfrom int\n\t\twant int\n\t}{\n\t\t{\"from start\", 0, 5},\n\t\t{\"from after first newline\", 6, 11},\n\t\t{\"from past all newlines\", 12, -1},\n\t\t{\"from newline itself\", 5, 5},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := findNewlineFrom(runes, tc.from)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"findNewlineFrom(runes, %d) = %d, want %d\", tc.from, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindLastUnclosedCodeBlockInRange(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tcontent    string\n\t\tstart, end int\n\t\twant       int\n\t}{\n\t\t{\n\t\t\tname:    \"no code blocks\",\n\t\t\tcontent: \"hello world\",\n\t\t\tstart:   0, end: 11,\n\t\t\twant: -1,\n\t\t},\n\t\t{\n\t\t\tname:    \"complete code block\",\n\t\t\tcontent: \"```go\\ncode\\n```\",\n\t\t\tstart:   0, end: 14,\n\t\t\twant: -1,\n\t\t},\n\t\t{\n\t\t\tname:    \"unclosed code block\",\n\t\t\tcontent: \"text\\n```go\\ncode here\",\n\t\t\tstart:   0, end: 20,\n\t\t\twant: 5,\n\t\t},\n\t\t{\n\t\t\tname:    \"closed then unclosed\",\n\t\t\tcontent: \"```a\\n```\\n```b\\ncode\",\n\t\t\tstart:   0, end: 17,\n\t\t\twant: 9,\n\t\t},\n\t\t{\n\t\t\tname:    \"search within subrange\",\n\t\t\tcontent: \"```a\\n```\\n```b\\ncode\",\n\t\t\tstart:   9, end: 17,\n\t\t\twant: 9,\n\t\t},\n\t\t{\n\t\t\tname:    \"subrange with no code blocks\",\n\t\t\tcontent: \"```a\\n```\\nhello\",\n\t\t\tstart:   9, end: 14,\n\t\t\twant: -1,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\trunes := []rune(tc.content)\n\t\t\tgot := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d\",\n\t\t\t\t\ttc.content, tc.start, tc.end, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindNextClosingCodeBlockInRange(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcontent  string\n\t\tstartIdx int\n\t\tend      int\n\t\twant     int\n\t}{\n\t\t{\n\t\t\tname:     \"finds closing fence\",\n\t\t\tcontent:  \"code\\n```\\nmore\",\n\t\t\tstartIdx: 0, end: 13,\n\t\t\twant: 8, // position after ```\n\t\t},\n\t\t{\n\t\t\tname:     \"no closing fence\",\n\t\t\tcontent:  \"just code here\",\n\t\t\tstartIdx: 0, end: 14,\n\t\t\twant: -1,\n\t\t},\n\t\t{\n\t\t\tname:     \"fence at start of search\",\n\t\t\tcontent:  \"```end\",\n\t\t\tstartIdx: 0, end: 6,\n\t\t\twant: 3,\n\t\t},\n\t\t{\n\t\t\tname:     \"fence outside range\",\n\t\t\tcontent:  \"code\\n```\",\n\t\t\tstartIdx: 0, end: 4,\n\t\t\twant: -1,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\trunes := []rune(tc.content)\n\t\t\tgot := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end)\n\t\t\tif got != tc.want {\n\t\t\t\tt.Errorf(\"findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d\",\n\t\t\t\t\ttc.content, tc.startIdx, tc.end, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSplitMessage_CodeBlockIntegrity(t *testing.T) {\n\t// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting\n\n\t// 60 chars total approximately\n\tcontent := \"```go\\npackage main\\n\\nfunc main() {\\n\\tprintln(\\\"Hello\\\")\\n}\\n```\"\n\tmaxLen := 40\n\n\tchunks := SplitMessage(content, maxLen)\n\n\tif len(chunks) != 2 {\n\t\tt.Fatalf(\"Expected 2 chunks, got %d: %q\", len(chunks), chunks)\n\t}\n\n\t// First chunk must end with \"\\n```\"\n\tif !strings.HasSuffix(chunks[0], \"\\n```\") {\n\t\tt.Errorf(\"First chunk should end with closing fence. Got: %q\", chunks[0])\n\t}\n\n\t// Second chunk must start with the header \"```go\"\n\tif !strings.HasPrefix(chunks[1], \"```go\") {\n\t\tt.Errorf(\"Second chunk should start with code block header. Got: %q\", chunks[1])\n\t}\n\n\t// First chunk should contain meaningful content\n\tif len([]rune(chunks[0])) > 40 {\n\t\tt.Errorf(\"First chunk exceeded maxLen: length %d runes\", len([]rune(chunks[0])))\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/telegram/command_registration.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"math/rand\"\n\t\"slices\"\n\t\"time\"\n\n\t\"github.com/mymmrac/telego\"\n\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nvar commandRegistrationBackoff = []time.Duration{\n\t5 * time.Second,\n\t15 * time.Second,\n\t60 * time.Second,\n\t5 * time.Minute,\n\t10 * time.Minute,\n}\n\nfunc commandRegistrationDelay(attempt int) time.Duration {\n\tif len(commandRegistrationBackoff) == 0 {\n\t\treturn 0\n\t}\n\tbase := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)]\n\t// Full jitter in [0.5, 1.0) to avoid synchronized retries across instances.\n\treturn time.Duration(float64(base) * (0.5 + rand.Float64()*0.5))\n}\n\n// RegisterCommands registers bot commands on Telegram platform.\nfunc (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {\n\tbotCommands := make([]telego.BotCommand, 0, len(defs))\n\tfor _, def := range defs {\n\t\tif def.Name == \"\" || def.Description == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tbotCommands = append(botCommands, telego.BotCommand{\n\t\t\tCommand:     def.Name,\n\t\t\tDescription: def.Description,\n\t\t})\n\t}\n\n\tcurrent, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})\n\tif err != nil {\n\t\t// If we can't read current commands, fall through to set them.\n\t\tlogger.WarnCF(\"telegram\", \"Failed to get current commands, will set unconditionally\",\n\t\t\tmap[string]any{\"error\": err.Error()})\n\t} else if slices.Equal(current, botCommands) {\n\t\tlogger.DebugCF(\"telegram\", \"Bot commands are up to date\", nil)\n\t\treturn nil\n\t}\n\n\treturn c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{\n\t\tCommands: botCommands,\n\t})\n}\n\nfunc (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {\n\tif len(defs) == 0 {\n\t\treturn\n\t}\n\n\tregister := c.registerFunc\n\tif register == nil {\n\t\tregister = c.RegisterCommands\n\t}\n\n\tregCtx, cancel := context.WithCancel(ctx)\n\tc.commandRegCancel = cancel\n\n\t// Registration runs asynchronously so Telegram message intake is never blocked\n\t// by temporary upstream API failures. Retry stops on success or channel shutdown.\n\tgo func() {\n\t\tattempt := 0\n\t\ttimer := time.NewTimer(0)\n\t\tif !timer.Stop() {\n\t\t\tselect {\n\t\t\tcase <-timer.C:\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t\tdefer timer.Stop()\n\t\tfor {\n\t\t\terr := register(regCtx, defs)\n\t\t\tif err == nil {\n\t\t\t\tlogger.InfoCF(\"telegram\", \"Telegram commands registered\", map[string]any{\n\t\t\t\t\t\"count\": len(defs),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tdelay := commandRegistrationDelay(attempt)\n\t\t\tlogger.WarnCF(\"telegram\", \"Telegram command registration failed; will retry\", map[string]any{\n\t\t\t\t\"error\":       err.Error(),\n\t\t\t\t\"retry_after\": delay.String(),\n\t\t\t})\n\t\t\tattempt++\n\n\t\t\tif !timer.Stop() {\n\t\t\t\tselect {\n\t\t\t\tcase <-timer.C:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t\ttimer.Reset(delay)\n\n\t\t\tselect {\n\t\t\tcase <-regCtx.Done():\n\t\t\t\treturn\n\t\t\tcase <-timer.C:\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/channels/telegram/command_registration_test.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n)\n\nfunc TestStartCommandRegistration_DoesNotBlock(t *testing.T) {\n\tch := &TelegramChannel{}\n\tstarted := make(chan struct{}, 1)\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tch.registerFunc = func(context.Context, []commands.Definition) error {\n\t\tstarted <- struct{}{}\n\t\treturn errors.New(\"temporary failure\")\n\t}\n\n\tch.startCommandRegistration(ctx, []commands.Definition{{Name: \"help\"}})\n\n\tselect {\n\tcase <-started:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"registration did not start asynchronously\")\n\t}\n}\n\nfunc TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) {\n\tch := &TelegramChannel{}\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\torigBackoff := commandRegistrationBackoff\n\tcommandRegistrationBackoff = []time.Duration{5 * time.Millisecond}\n\tdefer func() { commandRegistrationBackoff = origBackoff }()\n\n\tvar attempts atomic.Int32\n\tch.registerFunc = func(context.Context, []commands.Definition) error {\n\t\tn := attempts.Add(1)\n\t\tif n < 3 {\n\t\t\treturn errors.New(\"temporary failure\")\n\t\t}\n\t\treturn nil\n\t}\n\n\tch.startCommandRegistration(ctx, []commands.Definition{{Name: \"help\", Description: \"Help\"}})\n\n\tdeadline := time.Now().Add(250 * time.Millisecond)\n\tfor time.Now().Before(deadline) {\n\t\tif attempts.Load() >= 3 {\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(5 * time.Millisecond)\n\t}\n\tif attempts.Load() < 3 {\n\t\tt.Fatalf(\"expected at least 3 attempts, got %d\", attempts.Load())\n\t}\n\n\tstable := attempts.Load()\n\ttime.Sleep(30 * time.Millisecond)\n\tif attempts.Load() != stable {\n\t\tt.Fatalf(\"expected retries to stop after success, got %d -> %d\", stable, attempts.Load())\n\t}\n}\n\nfunc TestStartCommandRegistration_StopsAfterCancel(t *testing.T) {\n\tch := &TelegramChannel{}\n\tctx, cancel := context.WithCancel(context.Background())\n\n\torigBackoff := commandRegistrationBackoff\n\tcommandRegistrationBackoff = []time.Duration{5 * time.Millisecond}\n\tdefer func() { commandRegistrationBackoff = origBackoff }()\n\tdefer cancel()\n\n\tvar attempts atomic.Int32\n\tch.registerFunc = func(context.Context, []commands.Definition) error {\n\t\tattempts.Add(1)\n\t\treturn errors.New(\"always fail\")\n\t}\n\n\tch.startCommandRegistration(ctx, []commands.Definition{{Name: \"help\", Description: \"Help\"}})\n\n\ttime.Sleep(20 * time.Millisecond)\n\tcancel()\n\ttime.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle\n\tstable := attempts.Load()\n\ttime.Sleep(30 * time.Millisecond)\n\tif attempts.Load() != stable {\n\t\tt.Fatalf(\"expected retries to quiesce after cancel, got %d -> %d\", stable, attempts.Load())\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/telegram/init.go",
    "content": "package telegram\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"telegram\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewTelegramChannel(cfg, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/telegram/parse_markdown_to_md_v2.go",
    "content": "package telegram\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\n// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2\nvar mdV2SpecialChars = map[rune]bool{\n\t'*':  true,\n\t'_':  true,\n\t'[':  true,\n\t']':  true,\n\t'(':  true,\n\t')':  true,\n\t'~':  true,\n\t'`':  true,\n\t'>':  true,\n\t'<':  true,\n\t'#':  true,\n\t'+':  true,\n\t'-':  true,\n\t'=':  true,\n\t'|':  true,\n\t'{':  true,\n\t'}':  true,\n\t'.':  true,\n\t'!':  true,\n\t'\\\\': true,\n}\n\n// entityPattern describes one Telegram MarkdownV2 inline entity type.\ntype entityPattern struct {\n\tre    *regexp.Regexp\n\topen  string\n\tclose string\n}\n\n// allEntityPatterns lists every recognized entity in priority order\n// (longer / more-specific delimiters first so they win over shorter ones).\n// Each entry's regex is anchored to find the first occurrence in a string.\nvar allEntityPatterns = []entityPattern{\n\t// fenced code block — content is completely verbatim\n\t{re: regexp.MustCompile(\"(?s)```(?:[\\\\w]*\\\\n)?[\\\\s\\\\S]*?```\"), open: \"```\", close: \"```\"},\n\t// inline code — content is completely verbatim\n\t{re: regexp.MustCompile(\"`(?:[^`\\\\\\n]|\\\\\\\\.)*`\"), open: \"`\", close: \"`\"},\n\t// expandable block-quote opener  **>…\n\t{re: regexp.MustCompile(`(?m)\\*\\*>(?:[^\\n]*)`), open: \"**>\", close: \"\"},\n\t// block-quote line  >…\n\t{re: regexp.MustCompile(`(?m)^>(?:[^\\n]*)`), open: \">\", close: \"\"},\n\t// custom emoji / timestamp  ![…](…)   — must come before plain link\n\t{re: regexp.MustCompile(`!\\[[^\\]]*\\]\\([^)]*\\)`), open: \"!\", close: \"\"},\n\t// inline URL / user mention  […](…)\n\t{re: regexp.MustCompile(`\\[[^\\]]*\\]\\([^)]*\\)`), open: \"[\", close: \"\"},\n\t// spoiler  ||…||  — before single | so it wins\n\t{re: regexp.MustCompile(`\\|\\|(?:[^|\\\\\\n]|\\\\.)*\\|\\|`), open: \"||\", close: \"||\"},\n\t// underline  __…__  — before single _ so it wins\n\t{re: regexp.MustCompile(`__(?:[^_\\\\\\n]|\\\\.)*__`), open: \"__\", close: \"__\"},\n\t// bold  *…*\n\t{re: regexp.MustCompile(`\\*(?:[^*\\\\\\n]|\\\\.)*\\*`), open: \"*\", close: \"*\"},\n\t// italic  _…_\n\t{re: regexp.MustCompile(`_(?:[^_\\\\\\n]|\\\\.)*_`), open: \"_\", close: \"_\"},\n\t// strikethrough  ~…~\n\t{re: regexp.MustCompile(`~(?:[^~\\\\\\n]|\\\\.)*~`), open: \"~\", close: \"~\"},\n}\n\n// verbatimEntities are entity types whose inner content must never be\n// touched (code blocks, URLs, quotes, custom emoji).\n// Their content is passed through completely unchanged.\nvar verbatimEntities = map[string]bool{\n\t\"```\": true,\n\t\"`\":   true,\n\t\"**>\": true,\n\t\">\":   true,\n\t\"!\":   true,\n\t\"[\":   true,\n}\n\n// markdownToTelegramMarkdownV2 converts a Markdown string into a string safe\n// for sending with Telegram's MarkdownV2 parse mode.\n//\n// Rules:\n//   - Markdown headings (# … ######) are converted to *bold*.\n//   - **bold** Markdown syntax is converted to *bold*.\n//   - Recognized Telegram MarkdownV2 entity spans are preserved; their inner\n//     content is processed recursively so that nested valid entities are kept\n//     intact while stray special characters are escaped.\n//   - All plain-text segments have their MarkdownV2 special characters escaped.\n//\n// Reference: https://core.telegram.org/bots/api#formatting-options\nfunc markdownToTelegramMarkdownV2(text string) string {\n\t// 1. Convert Markdown headings → *escaped heading text*\n\ttext = reHeading.ReplaceAllStringFunc(text, func(match string) string {\n\t\tsub := reHeading.FindStringSubmatch(match)\n\t\tif len(sub) < 2 {\n\t\t\treturn match\n\t\t}\n\t\t// The heading content is fresh plain text — escape everything\n\t\t// including * so the resulting *…* bold span stays valid.\n\t\treturn \"*\" + escapeMarkdownV2(sub[1]) + \"*\"\n\t})\n\n\t// 2. Convert **bold** → *bold*\n\ttext = reBoldStar.ReplaceAllString(text, \"*$1*\")\n\n\t// 3. Recursively escape the full string.\n\treturn processText(text)\n}\n\n// processText walks `text`, finds the leftmost / longest matching entity,\n// escapes the gap before it, processes the entity (recursing into its inner\n// content when appropriate), then continues with the remainder.\nfunc processText(text string) string {\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Find the leftmost match among all entity patterns.\n\tbestStart := -1\n\tbestEnd := -1\n\tvar bestPat *entityPattern\n\n\tfor i := range allEntityPatterns {\n\t\tp := &allEntityPatterns[i]\n\t\tloc := p.re.FindStringIndex(text)\n\t\tif loc == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif bestStart == -1 || loc[0] < bestStart ||\n\t\t\t(loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) {\n\t\t\tbestStart = loc[0]\n\t\t\tbestEnd = loc[1]\n\t\t\tbestPat = p\n\t\t}\n\t}\n\n\tif bestPat == nil {\n\t\t// No entity found — escape everything.\n\t\treturn escapeMarkdownV2(text)\n\t}\n\n\tvar b strings.Builder\n\n\t// Plain text before the entity.\n\tif bestStart > 0 {\n\t\tb.WriteString(escapeMarkdownV2(text[:bestStart]))\n\t}\n\n\t// The matched entity span.\n\tmatched := text[bestStart:bestEnd]\n\n\tif verbatimEntities[bestPat.open] {\n\t\t// Code blocks, URLs, quotes: pass through completely untouched.\n\t\tb.WriteString(matched)\n\t} else {\n\t\t// Inline formatting (bold, italic, underline, strikethrough, spoiler):\n\t\t// keep the delimiters and recursively process the inner content so that\n\t\t// nested entities survive but stray specials get escaped.\n\t\topenLen := len(bestPat.open)\n\t\tcloseLen := len(bestPat.close)\n\t\tinner := matched[openLen : len(matched)-closeLen]\n\n\t\tb.WriteString(bestPat.open)\n\t\tb.WriteString(processText(inner))\n\t\tb.WriteString(bestPat.close)\n\t}\n\n\t// Continue with the remainder of the string.\n\tb.WriteString(processText(text[bestEnd:]))\n\n\treturn b.String()\n}\n\n// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text\n// segment (i.e. a segment that is not part of any recognized entity).\n// Already-escaped sequences (backslash + char) are forwarded verbatim to avoid\n// double-escaping.\nfunc escapeMarkdownV2(s string) string {\n\tvar b strings.Builder\n\tb.Grow(len(s) + 8)\n\trunes := []rune(s)\n\tfor i := 0; i < len(runes); i++ {\n\t\tch := runes[i]\n\t\t// Forward an existing escape sequence verbatim.\n\t\tif ch == '\\\\' && i+1 < len(runes) {\n\t\t\tb.WriteRune(ch)\n\t\t\tb.WriteRune(runes[i+1])\n\t\t\ti++\n\t\t\tcontinue\n\t\t}\n\t\tif mdV2SpecialChars[ch] {\n\t\t\tb.WriteByte('\\\\')\n\t\t}\n\t\tb.WriteRune(ch)\n\t}\n\treturn b.String()\n}\n"
  },
  {
    "path": "pkg/channels/telegram/parse_markdown_to_md_v2_test.go",
    "content": "package telegram\n\nimport (\n\t_ \"embed\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\n//go:embed testdata/md2_all_formats.txt\nvar md2AllFormats string\n\nfunc Test_markdownToTelegramMarkdownV2(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"heading -> bolding\",\n\t\t\tinput:    `## HeadingH2 #`,\n\t\t\texpected: \"*HeadingH2 \\\\#*\",\n\t\t},\n\t\t{\n\t\t\tname:     \"strikethrough\",\n\t\t\tinput:    \"~strikethroughMD~\",\n\t\t\texpected: \"~strikethroughMD~\",\n\t\t},\n\t\t{\n\t\t\tname:     \"inline URL\",\n\t\t\tinput:    \"[inline URL](http://www.example.com/)\",\n\t\t\texpected: \"[inline URL](http://www.example.com/)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"all telegram formats\",\n\t\t\tinput:    md2AllFormats,\n\t\t\texpected: md2AllFormats,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty\",\n\t\t\tinput:    \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"one letter\",\n\t\t\tinput:    \"o\",\n\t\t\texpected: \"o\",\n\t\t},\n\t\t{\n\t\t\tname:     \"\",\n\t\t\tinput:    \"*Last update: ~10 24h*\",\n\t\t\texpected: \"*Last update: \\\\~10 24h*\",\n\t\t},\n\t\t{\n\t\t\tname:     \"\",\n\t\t\tinput:    \"<Market Capitalization>\",\n\t\t\texpected: \"\\\\<Market Capitalization\\\\>\",\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tactual := markdownToTelegramMarkdownV2(tc.input)\n\n\t\t\trequire.EqualValues(t, tc.expected, actual)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/telegram/parser_markdown_to_html.go",
    "content": "package telegram\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc markdownToTelegramHTML(text string) string {\n\tif text == \"\" {\n\t\treturn \"\"\n\t}\n\n\tcodeBlocks := extractCodeBlocks(text)\n\ttext = codeBlocks.text\n\n\tinlineCodes := extractInlineCodes(text)\n\ttext = inlineCodes.text\n\n\ttext = reHeading.ReplaceAllString(text, \"$1\")\n\n\ttext = reBlockquote.ReplaceAllString(text, \"$1\")\n\n\ttext = escapeHTML(text)\n\n\ttext = reLink.ReplaceAllString(text, `<a href=\"$2\">$1</a>`)\n\n\ttext = reBoldStar.ReplaceAllString(text, \"<b>$1</b>\")\n\n\ttext = reBoldUnder.ReplaceAllString(text, \"<b>$1</b>\")\n\n\ttext = reItalic.ReplaceAllStringFunc(text, func(s string) string {\n\t\tmatch := reItalic.FindStringSubmatch(s)\n\t\tif len(match) < 2 {\n\t\t\treturn s\n\t\t}\n\t\treturn \"<i>\" + match[1] + \"</i>\"\n\t})\n\n\ttext = reStrike.ReplaceAllString(text, \"<s>$1</s>\")\n\n\ttext = reListItem.ReplaceAllString(text, \"• \")\n\n\tfor i, code := range inlineCodes.codes {\n\t\tescaped := escapeHTML(code)\n\t\ttext = strings.ReplaceAll(text, fmt.Sprintf(\"\\x00IC%d\\x00\", i), fmt.Sprintf(\"<code>%s</code>\", escaped))\n\t}\n\n\tfor i, code := range codeBlocks.codes {\n\t\tescaped := escapeHTML(code)\n\t\ttext = strings.ReplaceAll(\n\t\t\ttext,\n\t\t\tfmt.Sprintf(\"\\x00CB%d\\x00\", i),\n\t\t\tfmt.Sprintf(\"<pre><code>%s</code></pre>\", escaped),\n\t\t)\n\t}\n\n\treturn text\n}\n\ntype codeBlockMatch struct {\n\ttext  string\n\tcodes []string\n}\n\nfunc extractCodeBlocks(text string) codeBlockMatch {\n\tmatches := reCodeBlock.FindAllStringSubmatch(text, -1)\n\n\tcodes := make([]string, 0, len(matches))\n\tfor _, match := range matches {\n\t\tcodes = append(codes, match[1])\n\t}\n\n\ti := 0\n\ttext = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {\n\t\tplaceholder := fmt.Sprintf(\"\\x00CB%d\\x00\", i)\n\t\ti++\n\t\treturn placeholder\n\t})\n\n\treturn codeBlockMatch{text: text, codes: codes}\n}\n\ntype inlineCodeMatch struct {\n\ttext  string\n\tcodes []string\n}\n\nfunc extractInlineCodes(text string) inlineCodeMatch {\n\tmatches := reInlineCode.FindAllStringSubmatch(text, -1)\n\n\tcodes := make([]string, 0, len(matches))\n\tfor _, match := range matches {\n\t\tcodes = append(codes, match[1])\n\t}\n\n\ti := 0\n\ttext = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {\n\t\tplaceholder := fmt.Sprintf(\"\\x00IC%d\\x00\", i)\n\t\ti++\n\t\treturn placeholder\n\t})\n\n\treturn inlineCodeMatch{text: text, codes: codes}\n}\n\nfunc escapeHTML(text string) string {\n\ttext = strings.ReplaceAll(text, \"&\", \"&amp;\")\n\ttext = strings.ReplaceAll(text, \"<\", \"&lt;\")\n\ttext = strings.ReplaceAll(text, \">\", \"&gt;\")\n\treturn text\n}\n"
  },
  {
    "path": "pkg/channels/telegram/telegram.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/mymmrac/telego\"\n\tth \"github.com/mymmrac/telego/telegohandler\"\n\ttu \"github.com/mymmrac/telego/telegoutil\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/commands\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nvar (\n\treHeading    = regexp.MustCompile(`(?m)^#{1,6}\\s+([^\\n]+)`)\n\treBlockquote = regexp.MustCompile(`^>\\s*(.*)$`)\n\treLink       = regexp.MustCompile(`\\[([^\\]]+)\\]\\(([^)]+)\\)`)\n\treBoldStar   = regexp.MustCompile(`\\*\\*(.+?)\\*\\*`)\n\treBoldUnder  = regexp.MustCompile(`__(.+?)__`)\n\treItalic     = regexp.MustCompile(`_([^_]+)_`)\n\treStrike     = regexp.MustCompile(`~~(.+?)~~`)\n\treListItem   = regexp.MustCompile(`^[-*]\\s+`)\n\treCodeBlock  = regexp.MustCompile(\"```[\\\\w]*\\\\n?([\\\\s\\\\S]*?)```\")\n\treInlineCode = regexp.MustCompile(\"`([^`]+)`\")\n)\n\ntype TelegramChannel struct {\n\t*channels.BaseChannel\n\tbot     *telego.Bot\n\tbh      *th.BotHandler\n\tconfig  *config.Config\n\tchatIDs map[string]int64\n\tctx     context.Context\n\tcancel  context.CancelFunc\n\n\tregisterFunc     func(context.Context, []commands.Definition) error\n\tcommandRegCancel context.CancelFunc\n}\n\nfunc NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {\n\tvar opts []telego.BotOption\n\ttelegramCfg := cfg.Channels.Telegram\n\n\tif telegramCfg.Proxy != \"\" {\n\t\tproxyURL, parseErr := url.Parse(telegramCfg.Proxy)\n\t\tif parseErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid proxy URL %q: %w\", telegramCfg.Proxy, parseErr)\n\t\t}\n\t\topts = append(opts, telego.WithHTTPClient(&http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t\t},\n\t\t}))\n\t} else if os.Getenv(\"HTTP_PROXY\") != \"\" || os.Getenv(\"HTTPS_PROXY\") != \"\" {\n\t\t// Use environment proxy if configured\n\t\topts = append(opts, telego.WithHTTPClient(&http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t\t},\n\t\t}))\n\t}\n\n\tif baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), \"/\"); baseURL != \"\" {\n\t\topts = append(opts, telego.WithAPIServer(baseURL))\n\t}\n\topts = append(opts, telego.WithLogger(logger.NewLogger(\"telego\")))\n\n\tbot, err := telego.NewBot(telegramCfg.Token, opts...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create telegram bot: %w\", err)\n\t}\n\n\tbase := channels.NewBaseChannel(\n\t\t\"telegram\",\n\t\ttelegramCfg,\n\t\tbus,\n\t\ttelegramCfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(4000),\n\t\tchannels.WithGroupTrigger(telegramCfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),\n\t)\n\n\treturn &TelegramChannel{\n\t\tBaseChannel: base,\n\t\tbot:         bot,\n\t\tconfig:      cfg,\n\t\tchatIDs:     make(map[string]int64),\n\t}, nil\n}\n\nfunc (c *TelegramChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"telegram\", \"Starting Telegram bot (polling mode)...\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tupdates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{\n\t\tTimeout: 30,\n\t})\n\tif err != nil {\n\t\tc.cancel()\n\t\treturn fmt.Errorf(\"failed to start long polling: %w\", err)\n\t}\n\n\tbh, err := th.NewBotHandler(c.bot, updates)\n\tif err != nil {\n\t\tc.cancel()\n\t\treturn fmt.Errorf(\"failed to create bot handler: %w\", err)\n\t}\n\tc.bh = bh\n\n\tbh.HandleMessage(func(ctx *th.Context, message telego.Message) error {\n\t\treturn c.handleMessage(ctx, &message)\n\t}, th.AnyMessage())\n\n\tc.SetRunning(true)\n\tlogger.InfoCF(\"telegram\", \"Telegram bot connected\", map[string]any{\n\t\t\"username\": c.bot.Username(),\n\t})\n\n\tc.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())\n\n\tgo func() {\n\t\tif err = bh.Start(); err != nil {\n\t\t\tlogger.ErrorCF(\"telegram\", \"Bot handler failed\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *TelegramChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"telegram\", \"Stopping Telegram bot...\")\n\tc.SetRunning(false)\n\n\t// Stop the bot handler\n\tif c.bh != nil {\n\t\t_ = c.bh.StopWithContext(ctx)\n\t}\n\n\t// Cancel our context (stops long polling)\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tif c.commandRegCancel != nil {\n\t\tc.commandRegCancel()\n\t}\n\n\treturn nil\n}\n\nfunc (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tuseMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2\n\n\tchatID, threadID, err := parseTelegramChatID(msg.ChatID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid chat ID %s: %w\", msg.ChatID, channels.ErrSendFailed)\n\t}\n\n\tif msg.Content == \"\" {\n\t\treturn nil\n\t}\n\n\t// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),\n\t// so msg.Content is guaranteed to be within that limit. We still need to\n\t// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.\n\treplyToID := msg.ReplyToMessageID\n\tqueue := []string{msg.Content}\n\tfor len(queue) > 0 {\n\t\tchunk := queue[0]\n\t\tqueue = queue[1:]\n\n\t\tcontent := parseContent(chunk, useMarkdownV2)\n\n\t\tif len([]rune(content)) > 4096 {\n\t\t\truneChunk := []rune(chunk)\n\t\t\tratio := float64(len(runeChunk)) / float64(len([]rune(content)))\n\t\t\tsmallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin\n\n\t\t\t// Guarantee progress: if estimated length is >= chunk length, force it smaller\n\t\t\tif smallerLen >= len(runeChunk) {\n\t\t\t\tsmallerLen = len(runeChunk) - 1\n\t\t\t}\n\n\t\t\tif smallerLen <= 0 {\n\t\t\t\tif err := c.sendChunk(ctx, sendChunkParams{\n\t\t\t\t\tchatID:        chatID,\n\t\t\t\t\tthreadID:      threadID,\n\t\t\t\t\tcontent:       content,\n\t\t\t\t\treplyToID:     replyToID,\n\t\t\t\t\tmdFallback:    chunk,\n\t\t\t\t\tuseMarkdownV2: useMarkdownV2,\n\t\t\t\t}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\treplyToID = \"\"\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Use the estimated smaller length as a guide for SplitMessage.\n\t\t\t// SplitMessage will find natural break points (newlines/spaces) and respect code blocks.\n\t\t\tsubChunks := channels.SplitMessage(chunk, smallerLen)\n\n\t\t\t// Safety fallback: If SplitMessage failed to shorten the chunk, force a manual hard split.\n\t\t\tif len(subChunks) == 1 && subChunks[0] == chunk {\n\t\t\t\tpart1 := string(runeChunk[:smallerLen])\n\t\t\t\tpart2 := string(runeChunk[smallerLen:])\n\t\t\t\tsubChunks = []string{part1, part2}\n\t\t\t}\n\n\t\t\t// Filter out empty chunks to avoid sending empty messages to Telegram.\n\t\t\tnonEmpty := make([]string, 0, len(subChunks))\n\t\t\tfor _, s := range subChunks {\n\t\t\t\tif s != \"\" {\n\t\t\t\t\tnonEmpty = append(nonEmpty, s)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Push sub-chunks back to the front of the queue\n\t\t\tqueue = append(nonEmpty, queue...)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := c.sendChunk(ctx, sendChunkParams{\n\t\t\tchatID:        chatID,\n\t\t\tthreadID:      threadID,\n\t\t\tcontent:       content,\n\t\t\treplyToID:     replyToID,\n\t\t\tmdFallback:    chunk,\n\t\t\tuseMarkdownV2: useMarkdownV2,\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Only the first chunk should be a reply; subsequent chunks are normal messages.\n\t\treplyToID = \"\"\n\t}\n\n\treturn nil\n}\n\ntype sendChunkParams struct {\n\tchatID        int64\n\tthreadID      int\n\tcontent       string\n\treplyToID     string\n\tmdFallback    string\n\tuseMarkdownV2 bool\n}\n\n// sendChunk sends a single HTML/MarkdownV2 message, falling back to the original\n// markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags.\nfunc (c *TelegramChannel) sendChunk(\n\tctx context.Context,\n\tparams sendChunkParams,\n) error {\n\ttgMsg := tu.Message(tu.ID(params.chatID), params.content)\n\ttgMsg.MessageThreadID = params.threadID\n\tif params.useMarkdownV2 {\n\t\ttgMsg.WithParseMode(telego.ModeMarkdownV2)\n\t} else {\n\t\ttgMsg.WithParseMode(telego.ModeHTML)\n\t}\n\n\tif params.replyToID != \"\" {\n\t\tif mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil {\n\t\t\ttgMsg.ReplyParameters = &telego.ReplyParameters{\n\t\t\t\tMessageID: mid,\n\t\t\t}\n\t\t}\n\t}\n\n\tif _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {\n\t\tlogParseFailed(err, params.useMarkdownV2)\n\n\t\ttgMsg.Text = params.mdFallback\n\t\ttgMsg.ParseMode = \"\"\n\t\tif _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {\n\t\t\treturn fmt.Errorf(\"telegram send: %w\", channels.ErrTemporary)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// maxTypingDuration limits how long the typing indicator can run.\n// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.\n// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.\nconst maxTypingDuration = 5 * time.Minute\n\n// StartTyping implements channels.TypingCapable.\n// It sends ChatAction(typing) immediately and then repeats every 4 seconds\n// (Telegram's typing indicator expires after ~5s) in a background goroutine.\n// The returned stop function is idempotent and cancels the goroutine.\n// The goroutine also exits automatically after maxTypingDuration if cancel is\n// never called (e.g. when the LLM fails or times out without publishing).\nfunc (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {\n\tcid, threadID, err := parseTelegramChatID(chatID)\n\tif err != nil {\n\t\treturn func() {}, err\n\t}\n\n\taction := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)\n\taction.MessageThreadID = threadID\n\n\t// Send the first typing action immediately\n\t_ = c.bot.SendChatAction(ctx, action)\n\n\ttypingCtx, cancel := context.WithCancel(ctx)\n\t// Cap lifetime so the goroutine cannot run indefinitely if cancel is never called\n\tmaxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration)\n\tgo func() {\n\t\tdefer maxCancel()\n\t\tticker := time.NewTicker(4 * time.Second)\n\t\tdefer ticker.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-maxCtx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\ta := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)\n\t\t\t\ta.MessageThreadID = threadID\n\t\t\t\t_ = c.bot.SendChatAction(typingCtx, a)\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn cancel, nil\n}\n\n// EditMessage implements channels.MessageEditor.\nfunc (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {\n\tuseMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2\n\tcid, _, err := parseTelegramChatID(chatID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmid, err := strconv.Atoi(messageID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tparsedContent := parseContent(content, useMarkdownV2)\n\teditMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent)\n\tif useMarkdownV2 {\n\t\teditMsg.WithParseMode(telego.ModeMarkdownV2)\n\t} else {\n\t\teditMsg.WithParseMode(telego.ModeHTML)\n\t}\n\t_, err = c.bot.EditMessageText(ctx, editMsg)\n\tif err != nil {\n\t\tlogParseFailed(err, useMarkdownV2)\n\t\t_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))\n\t}\n\n\treturn err\n}\n\n// SendPlaceholder implements channels.PlaceholderCapable.\n// It sends a placeholder message (e.g. \"Thinking... 💭\") that will later be\n// edited to the actual response via EditMessage (channels.MessageEditor).\nfunc (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {\n\tphCfg := c.config.Channels.Telegram.Placeholder\n\tif !phCfg.Enabled {\n\t\treturn \"\", nil\n\t}\n\n\ttext := phCfg.Text\n\tif text == \"\" {\n\t\ttext = \"Thinking... 💭\"\n\t}\n\n\tcid, threadID, err := parseTelegramChatID(chatID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tphMsg := tu.Message(tu.ID(cid), text)\n\tphMsg.MessageThreadID = threadID\n\tpMsg, err := c.bot.SendMessage(ctx, phMsg)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn fmt.Sprintf(\"%d\", pMsg.MessageID), nil\n}\n\n// SendMedia implements the channels.MediaSender interface.\nfunc (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tchatID, threadID, err := parseTelegramChatID(msg.ChatID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid chat ID %s: %w\", msg.ChatID, channels.ErrSendFailed)\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tfor _, part := range msg.Parts {\n\t\tlocalPath, err := store.Resolve(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"telegram\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tfile, err := os.Open(localPath)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"telegram\", \"Failed to open media file\", map[string]any{\n\t\t\t\t\"path\":  localPath,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch part.Type {\n\t\tcase \"image\":\n\t\t\tparams := &telego.SendPhotoParams{\n\t\t\t\tChatID:          tu.ID(chatID),\n\t\t\t\tMessageThreadID: threadID,\n\t\t\t\tPhoto:           telego.InputFile{File: file},\n\t\t\t\tCaption:         part.Caption,\n\t\t\t}\n\t\t\t_, err = c.bot.SendPhoto(ctx, params)\n\t\t\tif err != nil && strings.Contains(err.Error(), \"PHOTO_INVALID_DIMENSIONS\") {\n\t\t\t\tif _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {\n\t\t\t\t\tfile.Close()\n\t\t\t\t\treturn fmt.Errorf(\"telegram rewind media after photo failure: %w\", channels.ErrTemporary)\n\t\t\t\t}\n\n\t\t\t\tdocParams := &telego.SendDocumentParams{\n\t\t\t\t\tChatID:          tu.ID(chatID),\n\t\t\t\t\tMessageThreadID: threadID,\n\t\t\t\t\tDocument:        telego.InputFile{File: file},\n\t\t\t\t\tCaption:         part.Caption,\n\t\t\t\t}\n\t\t\t\t_, err = c.bot.SendDocument(ctx, docParams)\n\t\t\t}\n\t\tcase \"audio\":\n\t\t\tparams := &telego.SendAudioParams{\n\t\t\t\tChatID:          tu.ID(chatID),\n\t\t\t\tMessageThreadID: threadID,\n\t\t\t\tAudio:           telego.InputFile{File: file},\n\t\t\t\tCaption:         part.Caption,\n\t\t\t}\n\t\t\t_, err = c.bot.SendAudio(ctx, params)\n\t\tcase \"video\":\n\t\t\tparams := &telego.SendVideoParams{\n\t\t\t\tChatID:          tu.ID(chatID),\n\t\t\t\tMessageThreadID: threadID,\n\t\t\t\tVideo:           telego.InputFile{File: file},\n\t\t\t\tCaption:         part.Caption,\n\t\t\t}\n\t\t\t_, err = c.bot.SendVideo(ctx, params)\n\t\tdefault: // \"file\" or unknown types\n\t\t\tparams := &telego.SendDocumentParams{\n\t\t\t\tChatID:          tu.ID(chatID),\n\t\t\t\tMessageThreadID: threadID,\n\t\t\t\tDocument:        telego.InputFile{File: file},\n\t\t\t\tCaption:         part.Caption,\n\t\t\t}\n\t\t\t_, err = c.bot.SendDocument(ctx, params)\n\t\t}\n\n\t\tfile.Close()\n\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"telegram\", \"Failed to send media\", map[string]any{\n\t\t\t\t\"type\":  part.Type,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\treturn fmt.Errorf(\"telegram send media: %w\", channels.ErrTemporary)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {\n\tif message == nil {\n\t\treturn fmt.Errorf(\"message is nil\")\n\t}\n\n\tuser := message.From\n\tif user == nil {\n\t\treturn fmt.Errorf(\"message sender (user) is nil\")\n\t}\n\n\tplatformID := fmt.Sprintf(\"%d\", user.ID)\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"telegram\",\n\t\tPlatformID:  platformID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"telegram\", platformID),\n\t\tUsername:    user.Username,\n\t\tDisplayName: user.FirstName,\n\t}\n\n\t// check allowlist to avoid downloading attachments for rejected users\n\tif !c.IsAllowedSender(sender) {\n\t\tlogger.DebugCF(\"telegram\", \"Message rejected by allowlist\", map[string]any{\n\t\t\t\"user_id\": platformID,\n\t\t})\n\t\treturn nil\n\t}\n\n\tchatID := message.Chat.ID\n\tc.chatIDs[platformID] = chatID\n\n\tcontent := \"\"\n\tmediaPaths := []string{}\n\n\tchatIDStr := fmt.Sprintf(\"%d\", chatID)\n\tmessageIDStr := fmt.Sprintf(\"%d\", message.MessageID)\n\tscope := channels.BuildMediaScope(\"telegram\", chatIDStr, messageIDStr)\n\n\t// Helper to register a local file with the media store\n\tstoreMedia := func(localPath, filename string) string {\n\t\tif store := c.GetMediaStore(); store != nil {\n\t\t\tref, err := store.Store(localPath, media.MediaMeta{\n\t\t\t\tFilename: filename,\n\t\t\t\tSource:   \"telegram\",\n\t\t\t}, scope)\n\t\t\tif err == nil {\n\t\t\t\treturn ref\n\t\t\t}\n\t\t}\n\t\treturn localPath // fallback: use raw path\n\t}\n\n\tif message.Text != \"\" {\n\t\tcontent += message.Text\n\t}\n\n\tif message.Caption != \"\" {\n\t\tif content != \"\" {\n\t\t\tcontent += \"\\n\"\n\t\t}\n\t\tcontent += message.Caption\n\t}\n\n\tif len(message.Photo) > 0 {\n\t\tphoto := message.Photo[len(message.Photo)-1]\n\t\tphotoPath := c.downloadPhoto(ctx, photo.FileID)\n\t\tif photoPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(photoPath, \"photo.jpg\"))\n\t\t\tif content != \"\" {\n\t\t\t\tcontent += \"\\n\"\n\t\t\t}\n\t\t\tcontent += \"[image: photo]\"\n\t\t}\n\t}\n\n\tif message.Voice != nil {\n\t\tvoicePath := c.downloadFile(ctx, message.Voice.FileID, \".ogg\")\n\t\tif voicePath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(voicePath, \"voice.ogg\"))\n\n\t\t\tif content != \"\" {\n\t\t\t\tcontent += \"\\n\"\n\t\t\t}\n\t\t\tcontent += \"[voice]\"\n\t\t}\n\t}\n\n\tif message.Audio != nil {\n\t\taudioPath := c.downloadFile(ctx, message.Audio.FileID, \".mp3\")\n\t\tif audioPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(audioPath, \"audio.mp3\"))\n\t\t\tif content != \"\" {\n\t\t\t\tcontent += \"\\n\"\n\t\t\t}\n\t\t\tcontent += \"[audio]\"\n\t\t}\n\t}\n\n\tif message.Document != nil {\n\t\tdocPath := c.downloadFile(ctx, message.Document.FileID, \"\")\n\t\tif docPath != \"\" {\n\t\t\tmediaPaths = append(mediaPaths, storeMedia(docPath, \"document\"))\n\t\t\tif content != \"\" {\n\t\t\t\tcontent += \"\\n\"\n\t\t\t}\n\t\t\tcontent += \"[file]\"\n\t\t}\n\t}\n\n\tif content == \"\" {\n\t\tcontent = \"[empty message]\"\n\t}\n\n\t// In group chats, apply unified group trigger filtering\n\tif message.Chat.Type != \"private\" {\n\t\tisMentioned := c.isBotMentioned(message)\n\t\tif isMentioned {\n\t\t\tcontent = c.stripBotMention(content)\n\t\t}\n\t\trespond, cleaned := c.ShouldRespondInGroup(isMentioned, content)\n\t\tif !respond {\n\t\t\treturn nil\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\t// For forum topics, embed the thread ID as \"chatID/threadID\" so replies\n\t// route to the correct topic and each topic gets its own session.\n\t// Only forum groups (IsForum) are handled; regular group reply threads\n\t// must share one session per group.\n\tcompositeChatID := fmt.Sprintf(\"%d\", chatID)\n\tthreadID := message.MessageThreadID\n\tif message.Chat.IsForum && threadID != 0 {\n\t\tcompositeChatID = fmt.Sprintf(\"%d/%d\", chatID, threadID)\n\t}\n\n\tlogger.DebugCF(\"telegram\", \"Received message\", map[string]any{\n\t\t\"sender_id\": sender.CanonicalID,\n\t\t\"chat_id\":   compositeChatID,\n\t\t\"thread_id\": threadID,\n\t\t\"preview\":   utils.Truncate(content, 50),\n\t})\n\n\tpeerKind := \"direct\"\n\tpeerID := fmt.Sprintf(\"%d\", user.ID)\n\tif message.Chat.Type != \"private\" {\n\t\tpeerKind = \"group\"\n\t\tpeerID = compositeChatID\n\t}\n\n\tpeer := bus.Peer{Kind: peerKind, ID: peerID}\n\tmessageID := fmt.Sprintf(\"%d\", message.MessageID)\n\n\tmetadata := map[string]string{\n\t\t\"user_id\":    fmt.Sprintf(\"%d\", user.ID),\n\t\t\"username\":   user.Username,\n\t\t\"first_name\": user.FirstName,\n\t\t\"is_group\":   fmt.Sprintf(\"%t\", message.Chat.Type != \"private\"),\n\t}\n\n\t// Set parent_peer metadata for per-topic agent binding.\n\tif message.Chat.IsForum && threadID != 0 {\n\t\tmetadata[\"parent_peer_kind\"] = \"topic\"\n\t\tmetadata[\"parent_peer_id\"] = fmt.Sprintf(\"%d\", threadID)\n\t}\n\n\tc.HandleMessage(c.ctx,\n\t\tpeer,\n\t\tmessageID,\n\t\tplatformID,\n\t\tcompositeChatID,\n\t\tcontent,\n\t\tmediaPaths,\n\t\tmetadata,\n\t\tsender,\n\t)\n\treturn nil\n}\n\nfunc (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {\n\tfile, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})\n\tif err != nil {\n\t\tlogger.ErrorCF(\"telegram\", \"Failed to get photo file\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\treturn c.downloadFileWithInfo(file, \".jpg\")\n}\n\nfunc (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {\n\tif file.FilePath == \"\" {\n\t\treturn \"\"\n\t}\n\n\turl := c.bot.FileDownloadURL(file.FilePath)\n\tlogger.DebugCF(\"telegram\", \"File URL\", map[string]any{\"url\": url})\n\n\t// Use FilePath as filename for better identification\n\tfilename := file.FilePath + ext\n\treturn utils.DownloadFile(url, filename, utils.DownloadOptions{\n\t\tLoggerPrefix: \"telegram\",\n\t})\n}\n\nfunc (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {\n\tfile, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})\n\tif err != nil {\n\t\tlogger.ErrorCF(\"telegram\", \"Failed to get file\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\treturn c.downloadFileWithInfo(file, ext)\n}\n\nfunc parseContent(text string, useMarkdownV2 bool) string {\n\tif useMarkdownV2 {\n\t\treturn markdownToTelegramMarkdownV2(text)\n\t}\n\n\treturn markdownToTelegramHTML(text)\n}\n\n// parseTelegramChatID splits \"chatID/threadID\" into its components.\n// Returns threadID=0 when no \"/\" is present (non-forum messages).\nfunc parseTelegramChatID(chatID string) (int64, int, error) {\n\tidx := strings.Index(chatID, \"/\")\n\tif idx == -1 {\n\t\tcid, err := strconv.ParseInt(chatID, 10, 64)\n\t\treturn cid, 0, err\n\t}\n\tcid, err := strconv.ParseInt(chatID[:idx], 10, 64)\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\ttid, err := strconv.Atoi(chatID[idx+1:])\n\tif err != nil {\n\t\treturn 0, 0, fmt.Errorf(\"invalid thread ID in chat ID %q: %w\", chatID, err)\n\t}\n\treturn cid, tid, nil\n}\n\nfunc logParseFailed(err error, useMarkdownV2 bool) {\n\tparsingName := \"HTML\"\n\tif useMarkdownV2 {\n\t\tparsingName = \"MarkdownV2\"\n\t}\n\n\tlogger.ErrorCF(\"telegram\",\n\t\tfmt.Sprintf(\"%s parse failed, falling back to plain text\", parsingName),\n\t\tmap[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t},\n\t)\n}\n\n// isBotMentioned checks if the bot is mentioned in the message via entities.\nfunc (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {\n\ttext, entities := telegramEntityTextAndList(message)\n\tif text == \"\" || len(entities) == 0 {\n\t\treturn false\n\t}\n\n\tbotUsername := \"\"\n\tif c.bot != nil {\n\t\tbotUsername = c.bot.Username()\n\t}\n\trunes := []rune(text)\n\n\tfor _, entity := range entities {\n\t\tentityText, ok := telegramEntityText(runes, entity)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch entity.Type {\n\t\tcase telego.EntityTypeMention:\n\t\t\tif botUsername != \"\" && strings.EqualFold(entityText, \"@\"+botUsername) {\n\t\t\t\treturn true\n\t\t\t}\n\t\tcase telego.EntityTypeTextMention:\n\t\t\tif botUsername != \"\" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {\n\t\t\t\treturn true\n\t\t\t}\n\t\tcase telego.EntityTypeBotCommand:\n\t\t\tif isBotCommandEntityForThisBot(entityText, botUsername) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {\n\tif message.Text != \"\" {\n\t\treturn message.Text, message.Entities\n\t}\n\treturn message.Caption, message.CaptionEntities\n}\n\nfunc telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {\n\tif entity.Offset < 0 || entity.Length <= 0 {\n\t\treturn \"\", false\n\t}\n\tend := entity.Offset + entity.Length\n\tif entity.Offset >= len(runes) || end > len(runes) {\n\t\treturn \"\", false\n\t}\n\treturn string(runes[entity.Offset:end]), true\n}\n\nfunc isBotCommandEntityForThisBot(entityText, botUsername string) bool {\n\tif !strings.HasPrefix(entityText, \"/\") {\n\t\treturn false\n\t}\n\tcommand := strings.TrimPrefix(entityText, \"/\")\n\tif command == \"\" {\n\t\treturn false\n\t}\n\n\tat := strings.IndexRune(command, '@')\n\tif at == -1 {\n\t\t// A bare /command delivered to this bot is intended for this bot.\n\t\treturn true\n\t}\n\n\tmentionUsername := command[at+1:]\n\tif mentionUsername == \"\" || botUsername == \"\" {\n\t\treturn false\n\t}\n\treturn strings.EqualFold(mentionUsername, botUsername)\n}\n\n// stripBotMention removes the @bot mention from the content.\nfunc (c *TelegramChannel) stripBotMention(content string) string {\n\tbotUsername := c.bot.Username()\n\tif botUsername == \"\" {\n\t\treturn content\n\t}\n\t// Case-insensitive replacement\n\tre := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))\n\tcontent = re.ReplaceAllString(content, \"\")\n\treturn strings.TrimSpace(content)\n}\n"
  },
  {
    "path": "pkg/channels/telegram/telegram_dispatch_test.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/mymmrac/telego\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n)\n\nfunc TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &TelegramChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"telegram\", nil, messageBus, nil),\n\t\tchatIDs:     make(map[string]int64),\n\t\tctx:         context.Background(),\n\t}\n\n\tmsg := &telego.Message{\n\t\tText:      \"/new\",\n\t\tMessageID: 9,\n\t\tChat: telego.Chat{\n\t\t\tID:   123,\n\t\t\tType: \"private\",\n\t\t},\n\t\tFrom: &telego.User{\n\t\t\tID:        42,\n\t\t\tFirstName: \"Alice\",\n\t\t},\n\t}\n\n\tif err := ch.handleMessage(context.Background(), msg); err != nil {\n\t\tt.Fatalf(\"handleMessage error: %v\", err)\n\t}\n\n\tinbound, ok := <-messageBus.InboundChan()\n\tif !ok {\n\t\tt.Fatal(\"expected inbound message to be forwarded\")\n\t}\n\tif inbound.Channel != \"telegram\" {\n\t\tt.Fatalf(\"channel=%q\", inbound.Channel)\n\t}\n\tif inbound.Content != \"/new\" {\n\t\tt.Fatalf(\"content=%q\", inbound.Content)\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/telegram/telegram_group_command_filter_test.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/mymmrac/telego\"\n\tta \"github.com/mymmrac/telego/telegoapi\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\ntype getMeCaller struct {\n\tusername string\n}\n\nfunc (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) {\n\tif strings.HasSuffix(url, \"/getMe\") {\n\t\tresult := fmt.Sprintf(`{\"id\":1,\"is_bot\":true,\"first_name\":\"bot\",\"username\":%q}`, c.username)\n\t\treturn &ta.Response{Ok: true, Result: []byte(result)}, nil\n\t}\n\treturn &ta.Response{Ok: true, Result: []byte(\"true\")}, nil\n}\n\nfunc newTestTelegramBot(t *testing.T, username string) *telego.Bot {\n\tt.Helper()\n\n\ttoken := \"123456:\" + strings.Repeat(\"a\", 35)\n\tbot, err := telego.NewBot(token,\n\t\ttelego.WithAPICaller(getMeCaller{username: username}),\n\t\ttelego.WithDiscardLogger(),\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"NewBot error: %v\", err)\n\t}\n\treturn bot\n}\n\nfunc newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) {\n\tt.Helper()\n\n\tmessageBus := bus.NewMessageBus()\n\tch := &TelegramChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"telegram\", nil, messageBus, nil,\n\t\t\tchannels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}),\n\t\t),\n\t\tbot:     newTestTelegramBot(t, botUsername),\n\t\tchatIDs: make(map[string]int64),\n\t\tctx:     context.Background(),\n\t}\n\treturn ch, messageBus\n}\n\nfunc TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\ttext          string\n\t\twantForwarded bool\n\t\twantContent   string\n\t}{\n\t\t{\n\t\t\tname:          \"command with bot username\",\n\t\t\ttext:          \"/new@testbot\",\n\t\t\twantForwarded: true,\n\t\t\twantContent:   \"/new\",\n\t\t},\n\t\t{\n\t\t\tname:          \"bare command\",\n\t\t\ttext:          \"/new\",\n\t\t\twantForwarded: true,\n\t\t\twantContent:   \"/new\",\n\t\t},\n\t\t{\n\t\t\tname:          \"command for another bot\",\n\t\t\ttext:          \"/new@otherbot\",\n\t\t\twantForwarded: false,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tch, messageBus := newGroupMentionOnlyChannel(t, \"testbot\")\n\n\t\t\tmsg := &telego.Message{\n\t\t\t\tText: tc.text,\n\t\t\t\tEntities: []telego.MessageEntity{{\n\t\t\t\t\tType:   telego.EntityTypeBotCommand,\n\t\t\t\t\tOffset: 0,\n\t\t\t\t\tLength: len([]rune(tc.text)),\n\t\t\t\t}},\n\t\t\t\tMessageID: 42,\n\t\t\t\tChat: telego.Chat{\n\t\t\t\t\tID:   123,\n\t\t\t\t\tType: \"group\",\n\t\t\t\t},\n\t\t\t\tFrom: &telego.User{\n\t\t\t\t\tID:        7,\n\t\t\t\t\tFirstName: \"Alice\",\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif err := ch.handleMessage(context.Background(), msg); err != nil {\n\t\t\t\tt.Fatalf(\"handleMessage error: %v\", err)\n\t\t\t}\n\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond)\n\t\t\tdefer cancel()\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tif tc.wantForwarded {\n\t\t\t\t\tt.Fatal(\"timeout waiting for message to be forwarded\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tcase inbound, ok := <-messageBus.InboundChan():\n\t\t\t\tif tc.wantForwarded {\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tt.Fatal(\"expected inbound message to be forwarded\")\n\t\t\t\t\t}\n\t\t\t\t\tif inbound.Content != tc.wantContent {\n\t\t\t\t\t\tt.Fatalf(\"content=%q want=%q\", inbound.Content, tc.wantContent)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {\n\tch, _ := newGroupMentionOnlyChannel(t, \"testbot\")\n\n\tmsg := &telego.Message{\n\t\tText: \"@testbot hello\",\n\t\tEntities: []telego.MessageEntity{{\n\t\t\tType:   telego.EntityTypeMention,\n\t\t\tOffset: 0,\n\t\t\tLength: len(\"@testbot\"),\n\t\t}},\n\t}\n\n\tif !ch.isBotMentioned(msg) {\n\t\tt.Fatal(\"expected mention entity to be treated as bot mention\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/telegram/telegram_test.go",
    "content": "package telegram\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/mymmrac/telego\"\n\tta \"github.com/mymmrac/telego/telegoapi\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nconst testToken = \"1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc\"\n\n// stubCaller implements ta.Caller for testing.\ntype stubCaller struct {\n\tcalls  []stubCall\n\tcallFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)\n}\n\ntype stubCall struct {\n\tURL  string\n\tData *ta.RequestData\n}\n\nfunc (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\ts.calls = append(s.calls, stubCall{URL: url, Data: data})\n\treturn s.callFn(ctx, url, data)\n}\n\n// stubConstructor implements ta.RequestConstructor for testing.\ntype stubConstructor struct{}\n\ntype multipartCall struct {\n\tParameters map[string]string\n\tFileSizes  map[string]int\n}\n\nfunc (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {\n\tb, err := json.Marshal(parameters)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ta.RequestData{\n\t\tContentType: \"application/json\",\n\t\tBodyRaw:     b,\n\t}, nil\n}\n\nfunc (s *stubConstructor) MultipartRequest(\n\tparameters map[string]string,\n\tfiles map[string]ta.NamedReader,\n) (*ta.RequestData, error) {\n\treturn &ta.RequestData{}, nil\n}\n\ntype multipartRecordingConstructor struct {\n\tstubConstructor\n\tcalls []multipartCall\n}\n\nfunc (s *multipartRecordingConstructor) MultipartRequest(\n\tparameters map[string]string,\n\tfiles map[string]ta.NamedReader,\n) (*ta.RequestData, error) {\n\tcall := multipartCall{\n\t\tParameters: make(map[string]string, len(parameters)),\n\t\tFileSizes:  make(map[string]int, len(files)),\n\t}\n\tfor k, v := range parameters {\n\t\tcall.Parameters[k] = v\n\t}\n\tfor field, file := range files {\n\t\tif file == nil {\n\t\t\tcontinue\n\t\t}\n\t\tdata, err := io.ReadAll(file)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcall.FileSizes[field] = len(data)\n\t}\n\ts.calls = append(s.calls, call)\n\treturn &ta.RequestData{}, nil\n}\n\n// successResponse returns a ta.Response that telego will treat as a successful SendMessage.\nfunc successResponse(t *testing.T) *ta.Response {\n\tt.Helper()\n\tmsg := &telego.Message{MessageID: 1}\n\tb, err := json.Marshal(msg)\n\trequire.NoError(t, err)\n\treturn &ta.Response{Ok: true, Result: b}\n}\n\n// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.\nfunc newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {\n\treturn newTestChannelWithConstructor(t, caller, &stubConstructor{})\n}\n\nfunc newTestChannelWithConstructor(\n\tt *testing.T,\n\tcaller *stubCaller,\n\tconstructor ta.RequestConstructor,\n) *TelegramChannel {\n\tt.Helper()\n\n\tbot, err := telego.NewBot(testToken,\n\t\ttelego.WithAPICaller(caller),\n\t\ttelego.WithRequestConstructor(constructor),\n\t\ttelego.WithDiscardLogger(),\n\t)\n\trequire.NoError(t, err)\n\n\tbase := channels.NewBaseChannel(\"telegram\", nil, nil, nil,\n\t\tchannels.WithMaxMessageLength(4000),\n\t)\n\tbase.SetRunning(true)\n\n\treturn &TelegramChannel{\n\t\tBaseChannel: base,\n\t\tbot:         bot,\n\t\tchatIDs:     make(map[string]int64),\n\t\tconfig:      config.DefaultConfig(),\n\t}\n}\n\nfunc TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {\n\tconstructor := &multipartRecordingConstructor{}\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\tswitch {\n\t\t\tcase strings.Contains(url, \"sendPhoto\"):\n\t\t\t\treturn nil, errors.New(`api: 400 \"Bad Request: PHOTO_INVALID_DIMENSIONS\"`)\n\t\t\tcase strings.Contains(url, \"sendDocument\"):\n\t\t\t\treturn successResponse(t), nil\n\t\t\tdefault:\n\t\t\t\tt.Fatalf(\"unexpected API call: %s\", url)\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t},\n\t}\n\tch := newTestChannelWithConstructor(t, caller, constructor)\n\n\tstore := media.NewFileMediaStore()\n\tch.SetMediaStore(store)\n\n\ttmpDir := t.TempDir()\n\tlocalPath := filepath.Join(tmpDir, \"woodstock-en-10s.png\")\n\tcontent := []byte(\"fake-png-content\")\n\trequire.NoError(t, os.WriteFile(localPath, content, 0o644))\n\n\tref, err := store.Store(\n\t\tlocalPath,\n\t\tmedia.MediaMeta{Filename: \"woodstock-en-10s.png\", ContentType: \"image/png\"},\n\t\t\"scope-1\",\n\t)\n\trequire.NoError(t, err)\n\n\terr = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"12345\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType:    \"image\",\n\t\t\tRef:     ref,\n\t\t\tCaption: \"caption\",\n\t\t}},\n\t})\n\n\trequire.NoError(t, err)\n\trequire.Len(t, caller.calls, 2)\n\tassert.Contains(t, caller.calls[0].URL, \"sendPhoto\")\n\tassert.Contains(t, caller.calls[1].URL, \"sendDocument\")\n\trequire.Len(t, constructor.calls, 2)\n\tassert.Equal(t, len(content), constructor.calls[0].FileSizes[\"photo\"])\n\tassert.Equal(t, len(content), constructor.calls[1].FileSizes[\"document\"])\n\tassert.Equal(t, \"caption\", constructor.calls[1].Parameters[\"caption\"])\n}\n\nfunc TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {\n\tconstructor := &multipartRecordingConstructor{}\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn nil, errors.New(\"api: 500 \\\"server exploded\\\"\")\n\t\t},\n\t}\n\tch := newTestChannelWithConstructor(t, caller, constructor)\n\n\tstore := media.NewFileMediaStore()\n\tch.SetMediaStore(store)\n\n\ttmpDir := t.TempDir()\n\tlocalPath := filepath.Join(tmpDir, \"image.png\")\n\trequire.NoError(t, os.WriteFile(localPath, []byte(\"fake-png-content\"), 0o644))\n\n\tref, err := store.Store(localPath, media.MediaMeta{Filename: \"image.png\", ContentType: \"image/png\"}, \"scope-1\")\n\trequire.NoError(t, err)\n\n\terr = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{\n\t\tChatID: \"12345\",\n\t\tParts: []bus.MediaPart{{\n\t\t\tType: \"image\",\n\t\t\tRef:  ref,\n\t\t}},\n\t})\n\n\trequire.Error(t, err)\n\tassert.ErrorIs(t, err, channels.ErrTemporary)\n\trequire.Len(t, caller.calls, 1)\n\tassert.Contains(t, caller.calls[0].URL, \"sendPhoto\")\n\trequire.Len(t, constructor.calls, 1)\n\tassert.NotContains(t, caller.calls[0].URL, \"sendDocument\")\n}\n\nfunc TestSend_EmptyContent(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\tt.Fatal(\"SendMessage should not be called for empty content\")\n\t\t\treturn nil, nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: \"\",\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Empty(t, caller.calls, \"no API calls should be made for empty content\")\n}\n\nfunc TestSend_ShortMessage_SingleCall(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: \"Hello, world!\",\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Len(t, caller.calls, 1, \"short message should result in exactly one SendMessage call\")\n}\n\nfunc TestSend_LongMessage_SingleCall(t *testing.T) {\n\t// With WithMaxMessageLength(4000), the Manager pre-splits messages before\n\t// they reach Send(). A message at exactly 4000 chars should go through\n\t// as a single SendMessage call (no re-split needed since HTML expansion\n\t// won't exceed 4096 for plain text).\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\tlongContent := strings.Repeat(\"a\", 4000)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: longContent,\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Len(t, caller.calls, 1, \"pre-split message within limit should result in one SendMessage call\")\n}\n\nfunc TestSend_HTMLFallback_PerChunk(t *testing.T) {\n\tcallCount := 0\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\tcallCount++\n\t\t\t// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)\n\t\t\tif callCount%2 == 1 {\n\t\t\t\treturn nil, errors.New(\"Bad Request: can't parse entities\")\n\t\t\t}\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: \"Hello **world**\",\n\t})\n\n\tassert.NoError(t, err)\n\t// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls\n\tassert.Equal(t, 2, len(caller.calls), \"should have HTML attempt + plain text fallback\")\n}\n\nfunc TestSend_HTMLFallback_BothFail(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn nil, errors.New(\"send failed\")\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: \"Hello\",\n\t})\n\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, channels.ErrTemporary), \"error should wrap ErrTemporary\")\n\tassert.Equal(t, 2, len(caller.calls), \"should have HTML attempt + plain text attempt\")\n}\n\nfunc TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {\n\t// With a long message that gets split into 2 chunks, if both HTML and\n\t// plain text fail on the first chunk, Send should return early.\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn nil, errors.New(\"send failed\")\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\tlongContent := strings.Repeat(\"x\", 4001)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: longContent,\n\t})\n\n\tassert.Error(t, err)\n\t// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.\n\tassert.Equal(t, 2, len(caller.calls), \"should stop after first chunk fails both HTML and plain text\")\n}\n\nfunc TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\t// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.\n\t// \"**a** \" (6 chars) becomes \"<b>a</b> \" (9 chars) in HTML, so repeating it many times\n\t// yields HTML that exceeds Telegram's limit while markdown stays within it.\n\tmarkdownContent := strings.Repeat(\"**a** \", 600) // 3600 chars markdown, HTML ~5400+ chars\n\tassert.LessOrEqual(t, len([]rune(markdownContent)), 4000, \"markdown content must not exceed chunk size\")\n\n\thtmlExpanded := markdownToTelegramHTML(markdownContent)\n\tassert.Greater(\n\t\tt, len([]rune(htmlExpanded)), 4096,\n\t\t\"HTML expansion must exceed Telegram limit for this test to be meaningful\",\n\t)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: markdownContent,\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Greater(\n\t\tt, len(caller.calls), 1,\n\t\t\"markdown-short but HTML-long message should be split into multiple SendMessage calls\",\n\t)\n}\n\nfunc TestSend_HTMLOverflow_WordBoundary(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\t// We want to force a split near index ~2600 while keeping markdown length <= 4000.\n\t// Prefix of 430 bold units (6 chars each) = 2580 chars.\n\t// Expansion per unit is +3 chars when converted to HTML, so 2580 + 430*3 = 3870.\n\tprefix := strings.Repeat(\"**a** \", 430)\n\ttargetWord := \"TARGETWORDTHATSTAYSTOGETHER\"\n\t// Suffix of 230 bold units (6 chars each) = 1380 chars.\n\t// Total markdown length: 2580 (prefix) + 27 (target word) + 1380 (suffix) = 3987 <= 4000.\n\t// HTML expansion adds ~3 chars per bold unit: (430 + 230)*3 = 1980 extra chars,\n\t// so total HTML length comfortably exceeds 4096.\n\tsuffix := strings.Repeat(\" **b**\", 230)\n\tcontent := prefix + targetWord + suffix\n\n\t// Ensure the test content matches the intended boundary conditions.\n\tassert.LessOrEqual(t, len([]rune(content)), 4000, \"markdown content must not exceed chunk size for this test\")\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"123456\",\n\t\tContent: content,\n\t})\n\n\tassert.NoError(t, err)\n\n\tfoundFullWord := false\n\tfor i, call := range caller.calls {\n\t\tvar params map[string]any\n\t\terr := json.Unmarshal(call.Data.BodyRaw, &params)\n\t\trequire.NoError(t, err)\n\t\ttext, _ := params[\"text\"].(string)\n\n\t\thasWord := strings.Contains(text, targetWord)\n\t\tt.Logf(\"Chunk %d length: %d, contains target word: %v\", i, len(text), hasWord)\n\n\t\tif hasWord {\n\t\t\tfoundFullWord = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tassert.True(t, foundFullWord, \"The target word should not be split between chunks\")\n}\n\nfunc TestSend_NotRunning(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\tt.Fatal(\"should not be called\")\n\t\t\treturn nil, nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\tch.SetRunning(false)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"12345\",\n\t\tContent: \"Hello\",\n\t})\n\n\tassert.ErrorIs(t, err, channels.ErrNotRunning)\n\tassert.Empty(t, caller.calls)\n}\n\nfunc TestSend_InvalidChatID(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\tt.Fatal(\"should not be called\")\n\t\t\treturn nil, nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"not-a-number\",\n\t\tContent: \"Hello\",\n\t})\n\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, channels.ErrSendFailed), \"error should wrap ErrSendFailed\")\n\tassert.Empty(t, caller.calls)\n}\n\nfunc TestParseTelegramChatID_Plain(t *testing.T) {\n\tcid, tid, err := parseTelegramChatID(\"12345\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(12345), cid)\n\tassert.Equal(t, 0, tid)\n}\n\nfunc TestParseTelegramChatID_NegativeGroup(t *testing.T) {\n\tcid, tid, err := parseTelegramChatID(\"-1001234567890\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(-1001234567890), cid)\n\tassert.Equal(t, 0, tid)\n}\n\nfunc TestParseTelegramChatID_WithThreadID(t *testing.T) {\n\tcid, tid, err := parseTelegramChatID(\"-1001234567890/42\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(-1001234567890), cid)\n\tassert.Equal(t, 42, tid)\n}\n\nfunc TestParseTelegramChatID_GeneralTopic(t *testing.T) {\n\tcid, tid, err := parseTelegramChatID(\"-100123/1\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(-100123), cid)\n\tassert.Equal(t, 1, tid)\n}\n\nfunc TestParseTelegramChatID_Invalid(t *testing.T) {\n\t_, _, err := parseTelegramChatID(\"not-a-number\")\n\tassert.Error(t, err)\n}\n\nfunc TestParseTelegramChatID_InvalidThreadID(t *testing.T) {\n\t_, _, err := parseTelegramChatID(\"-100123/not-a-thread\")\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"invalid thread ID\")\n}\n\nfunc TestSend_WithForumThreadID(t *testing.T) {\n\tcaller := &stubCaller{\n\t\tcallFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {\n\t\t\treturn successResponse(t), nil\n\t\t},\n\t}\n\tch := newTestChannel(t, caller)\n\n\terr := ch.Send(context.Background(), bus.OutboundMessage{\n\t\tChatID:  \"-1001234567890/42\",\n\t\tContent: \"Hello from topic\",\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Len(t, caller.calls, 1)\n}\n\nfunc TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &TelegramChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"telegram\", nil, messageBus, nil),\n\t\tchatIDs:     make(map[string]int64),\n\t\tctx:         context.Background(),\n\t}\n\n\tmsg := &telego.Message{\n\t\tText:            \"hello from topic\",\n\t\tMessageID:       10,\n\t\tMessageThreadID: 42,\n\t\tChat: telego.Chat{\n\t\t\tID:      -1001234567890,\n\t\t\tType:    \"supergroup\",\n\t\t\tIsForum: true,\n\t\t},\n\t\tFrom: &telego.User{\n\t\t\tID:        7,\n\t\t\tFirstName: \"Alice\",\n\t\t},\n\t}\n\n\terr := ch.handleMessage(context.Background(), msg)\n\trequire.NoError(t, err)\n\n\tinbound, ok := <-messageBus.InboundChan()\n\trequire.True(t, ok, \"expected inbound message\")\n\n\t// Composite chatID should include thread ID\n\tassert.Equal(t, \"-1001234567890/42\", inbound.ChatID)\n\n\t// Peer ID should include thread ID for session key isolation\n\tassert.Equal(t, \"group\", inbound.Peer.Kind)\n\tassert.Equal(t, \"-1001234567890/42\", inbound.Peer.ID)\n\n\t// Parent peer metadata should be set for agent binding\n\tassert.Equal(t, \"topic\", inbound.Metadata[\"parent_peer_kind\"])\n\tassert.Equal(t, \"42\", inbound.Metadata[\"parent_peer_id\"])\n}\n\nfunc TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &TelegramChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"telegram\", nil, messageBus, nil),\n\t\tchatIDs:     make(map[string]int64),\n\t\tctx:         context.Background(),\n\t}\n\n\tmsg := &telego.Message{\n\t\tText:      \"regular group message\",\n\t\tMessageID: 11,\n\t\tChat: telego.Chat{\n\t\t\tID:   -100999,\n\t\t\tType: \"group\",\n\t\t},\n\t\tFrom: &telego.User{\n\t\t\tID:        8,\n\t\t\tFirstName: \"Bob\",\n\t\t},\n\t}\n\n\terr := ch.handleMessage(context.Background(), msg)\n\trequire.NoError(t, err)\n\n\tinbound, ok := <-messageBus.InboundChan()\n\trequire.True(t, ok)\n\n\t// Plain chatID without thread suffix\n\tassert.Equal(t, \"-100999\", inbound.ChatID)\n\n\t// Peer ID should be raw chat ID (no thread suffix)\n\tassert.Equal(t, \"group\", inbound.Peer.Kind)\n\tassert.Equal(t, \"-100999\", inbound.Peer.ID)\n\n\t// No parent peer metadata\n\tassert.Empty(t, inbound.Metadata[\"parent_peer_kind\"])\n\tassert.Empty(t, inbound.Metadata[\"parent_peer_id\"])\n}\n\nfunc TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &TelegramChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"telegram\", nil, messageBus, nil),\n\t\tchatIDs:     make(map[string]int64),\n\t\tctx:         context.Background(),\n\t}\n\n\t// In regular groups, reply threads set MessageThreadID to the original\n\t// message ID. This should NOT trigger per-thread session isolation.\n\tmsg := &telego.Message{\n\t\tText:            \"reply in thread\",\n\t\tMessageID:       20,\n\t\tMessageThreadID: 15,\n\t\tChat: telego.Chat{\n\t\t\tID:      -100999,\n\t\t\tType:    \"supergroup\",\n\t\t\tIsForum: false,\n\t\t},\n\t\tFrom: &telego.User{\n\t\t\tID:        9,\n\t\t\tFirstName: \"Carol\",\n\t\t},\n\t}\n\n\terr := ch.handleMessage(context.Background(), msg)\n\trequire.NoError(t, err)\n\n\tinbound, ok := <-messageBus.InboundChan()\n\trequire.True(t, ok)\n\n\t// chatID should NOT include thread suffix for non-forum groups\n\tassert.Equal(t, \"-100999\", inbound.ChatID)\n\n\t// Peer ID should be raw chat ID (shared session for whole group)\n\tassert.Equal(t, \"group\", inbound.Peer.Kind)\n\tassert.Equal(t, \"-100999\", inbound.Peer.ID)\n\n\t// No parent peer metadata\n\tassert.Empty(t, inbound.Metadata[\"parent_peer_kind\"])\n\tassert.Empty(t, inbound.Metadata[\"parent_peer_id\"])\n}\n"
  },
  {
    "path": "pkg/channels/telegram/testdata/md2_all_formats.txt",
    "content": "*bold \\*text*\n_italic \\*text_\n__underline__\n~strikethrough~\n||spoiler||\n*bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold*\n[inline URL](http://www.example.com/)\n[inline mention of a user](tg://user?id=123456789)\n![👍](tg://emoji?id=5368324170671202286)\n![22:45 tomorrow](tg://time?unix=1647531900&format=wDT)\n![22:45 tomorrow](tg://time?unix=1647531900&format=t)\n![22:45 tomorrow](tg://time?unix=1647531900&format=r)\n![22:45 tomorrow](tg://time?unix=1647531900)\n`inline fixed-width code`\n```\npre-formatted fixed-width code block\n```\n```python\npre-formatted fixed-width code block written in the Python programming language\n```\n>Block quotation started\n>Block quotation continued\n>Block quotation continued\n>Block quotation continued\n>The last line of the block quotation\n**>The expandable block quotation started right after the previous block quotation\n>It is separated from the previous block quotation by an empty bold entity\n>Expandable block quotation continued\n>Hidden by default part of the expandable block quotation started\n>Expandable block quotation continued\n>The last line of the expandable block quotation with the expandability mark||\n"
  },
  {
    "path": "pkg/channels/webhook.go",
    "content": "package channels\n\nimport \"net/http\"\n\n// WebhookHandler is an optional interface for channels that receive messages\n// via HTTP webhooks. Manager discovers channels implementing this interface\n// and registers them on the shared HTTP server.\ntype WebhookHandler interface {\n\t// WebhookPath returns the path to mount this handler on the shared server.\n\t// Examples: \"/webhook/line\", \"/webhook/wecom\"\n\tWebhookPath() string\n\thttp.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)\n}\n\n// HealthChecker is an optional interface for channels that expose\n// a health check endpoint on the shared HTTP server.\ntype HealthChecker interface {\n\tHealthPath() string\n\tHealthHandler(w http.ResponseWriter, r *http.Request)\n}\n"
  },
  {
    "path": "pkg/channels/wecom/aibot.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/big\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// responseURLHTTPClient is a shared HTTP client for posting to WeCom response_url.\n// Reusing it enables connection pooling across replies.\nvar responseURLHTTPClient = &http.Client{Timeout: 15 * time.Second}\n\n// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人)\ntype WeComAIBotChannel struct {\n\t*channels.BaseChannel\n\tconfig      config.WeComAIBotConfig\n\tctx         context.Context\n\tcancel      context.CancelFunc\n\tstreamTasks map[string]*streamTask   // streamID -> task (for poll lookups)\n\tchatTasks   map[string][]*streamTask // chatID   -> in-flight tasks queue (FIFO)\n\ttaskMu      sync.RWMutex\n}\n\n// streamTask represents a streaming task for AI Bot.\n//\n// Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written\n// while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID,\n// ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set\n// once at creation and never modified, so they are safe to read without a lock.\ntype streamTask struct {\n\t// immutable after creation\n\tStreamID    string\n\tChatID      string // used by Send() to find this task\n\tResponseURL string // temporary URL for proactive reply (valid 1 hour, use once)\n\tQuestion    string\n\tCreatedTime time.Time\n\tDeadline    time.Time          // ~30s, we close the stream here and switch to response_url\n\tanswerCh    chan string        // receives agent reply from Send()\n\tctx         context.Context    // canceled when task is removed; used to interrupt the agent goroutine\n\tcancel      context.CancelFunc // call on task removal to cancel ctx\n\n\t// mutable — guarded by WeComAIBotChannel.taskMu\n\tStreamClosed   bool      // stream returned finish:true; waiting for agent to reply via response_url\n\tStreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup\n\tFinished       bool      // fully done\n}\n\n// WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot\n// Ref: https://developer.work.weixin.qq.com/document/path/100719\ntype WeComAIBotMessage struct {\n\tMsgID    string `json:\"msgid\"`\n\tAIBotID  string `json:\"aibotid\"`\n\tChatID   string `json:\"chatid\"`   // only for group chat\n\tChatType string `json:\"chattype\"` // \"single\" or \"group\"\n\tFrom     struct {\n\t\tUserID string `json:\"userid\"`\n\t} `json:\"from\"`\n\tResponseURL string `json:\"response_url\"` // temporary URL for proactive reply\n\tMsgType     string `json:\"msgtype\"`\n\t// text message\n\tText *struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"text,omitempty\"`\n\t// stream polling refresh\n\tStream *struct {\n\t\tID string `json:\"id\"`\n\t} `json:\"stream,omitempty\"`\n\t// image message\n\tImage *struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"image,omitempty\"`\n\t// mixed message (text + image)\n\tMixed *struct {\n\t\tMsgItem []struct {\n\t\t\tMsgType string `json:\"msgtype\"`\n\t\t\tText    *struct {\n\t\t\t\tContent string `json:\"content\"`\n\t\t\t} `json:\"text,omitempty\"`\n\t\t\tImage *struct {\n\t\t\t\tURL string `json:\"url\"`\n\t\t\t} `json:\"image,omitempty\"`\n\t\t} `json:\"msg_item\"`\n\t} `json:\"mixed,omitempty\"`\n\t// event field\n\tEvent *struct {\n\t\tEventType string `json:\"eventtype\"`\n\t} `json:\"event,omitempty\"`\n}\n\n// WeComAIBotMsgItemImage holds the image payload inside a stream message item.\ntype WeComAIBotMsgItemImage struct {\n\tBase64 string `json:\"base64\"`\n\tMD5    string `json:\"md5\"`\n}\n\n// WeComAIBotMsgItem is a single item inside a stream's msg_item list.\ntype WeComAIBotMsgItem struct {\n\tMsgType string                  `json:\"msgtype\"`\n\tImage   *WeComAIBotMsgItemImage `json:\"image,omitempty\"`\n}\n\n// WeComAIBotStreamInfo represents the detailed stream content in streaming responses.\ntype WeComAIBotStreamInfo struct {\n\tID      string              `json:\"id\"`\n\tFinish  bool                `json:\"finish\"`\n\tContent string              `json:\"content,omitempty\"`\n\tMsgItem []WeComAIBotMsgItem `json:\"msg_item,omitempty\"`\n}\n\n// WeComAIBotStreamResponse represents the streaming response format\ntype WeComAIBotStreamResponse struct {\n\tMsgType string               `json:\"msgtype\"`\n\tStream  WeComAIBotStreamInfo `json:\"stream\"`\n}\n\n// WeComAIBotEncryptedResponse represents the encrypted response wrapper\n// Fields match WXBizJsonMsgCrypt.generate() in Python SDK\ntype WeComAIBotEncryptedResponse struct {\n\tEncrypt      string `json:\"encrypt\"`\n\tMsgSignature string `json:\"msgsignature\"`\n\tTimestamp    string `json:\"timestamp\"`\n\tNonce        string `json:\"nonce\"`\n}\n\n// NewWeComAIBotChannel creates a WeCom AI Bot channel instance.\n// If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel\n// using the WebSocket long-connection API.\n// Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token +\n// EncodingAESKey).\nfunc NewWeComAIBotChannel(\n\tcfg config.WeComAIBotConfig,\n\tmessageBus *bus.MessageBus,\n) (channels.Channel, error) {\n\t// WebSocket long-connection mode takes priority when BotID + Secret are set.\n\tif cfg.BotID != \"\" && cfg.Secret != \"\" {\n\t\tlogger.InfoC(\"wecom_aibot\", \"BotID and Secret provided, using WebSocket mode\")\n\t\treturn newWeComAIBotWSChannel(cfg, messageBus)\n\t}\n\t// Webhook (short-connection) mode.\n\tif cfg.Token == \"\" || cfg.EncodingAESKey == \"\" {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode \" +\n\t\t\t\t\"or (token + encoding_aes_key) for webhook mode\")\n\t}\n\tif cfg.ProcessingMessage == \"\" {\n\t\tcfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage\n\t}\n\n\tbase := channels.NewBaseChannel(\"wecom_aibot\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(2048),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &WeComAIBotChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tstreamTasks: make(map[string]*streamTask),\n\t\tchatTasks:   make(map[string][]*streamTask),\n\t}, nil\n}\n\n// Name returns the channel name\nfunc (c *WeComAIBotChannel) Name() string {\n\treturn \"wecom_aibot\"\n}\n\n// Start initializes the WeCom AI Bot channel\nfunc (c *WeComAIBotChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"wecom_aibot\", \"Starting WeCom AI Bot channel...\")\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// Start cleanup goroutine for old tasks\n\tgo c.cleanupLoop()\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"wecom_aibot\", \"WeCom AI Bot channel started\")\n\n\treturn nil\n}\n\n// Stop gracefully stops the WeCom AI Bot channel\nfunc (c *WeComAIBotChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"wecom_aibot\", \"Stopping WeCom AI Bot channel...\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"wecom_aibot\", \"WeCom AI Bot channel stopped\")\n\treturn nil\n}\n\n// Send delivers the agent reply into the active streamTask for msg.ChatID.\n// It writes into the earliest unfinished task in the queue (FIFO per chatID).\n// If the stream has already closed (deadline passed), it posts directly to response_url.\nfunc (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\tc.taskMu.Lock()\n\tqueue := c.chatTasks[msg.ChatID]\n\t// Only compact Finished tasks at the head of the queue.\n\t// Tasks that are Finished in the middle are NOT removed here: doing a full\n\t// scan on every Send() call would be O(n) and is unnecessary given that\n\t// removeTask() always splices the task out of the queue immediately.\n\t// Any Finished task left stranded in the middle (e.g. due to an unexpected\n\t// code path) will be collected by cleanupOldTasks.\n\tfor len(queue) > 0 && queue[0].Finished {\n\t\tqueue = queue[1:]\n\t}\n\tc.chatTasks[msg.ChatID] = queue\n\tvar task *streamTask\n\tvar streamClosed bool\n\tvar responseURL string\n\tif len(queue) > 0 {\n\t\ttask = queue[0]\n\t\t// Read mutable fields while holding c.taskMu to avoid data races.\n\t\tstreamClosed = task.StreamClosed\n\t\tresponseURL = task.ResponseURL\n\t}\n\tc.taskMu.Unlock()\n\n\tif task == nil {\n\t\tlogger.DebugCF(\n\t\t\t\"wecom_aibot\",\n\t\t\t\"Send: no active task for chat (may have timed out)\",\n\t\t\tmap[string]any{\n\t\t\t\t\"chat_id\": msg.ChatID,\n\t\t\t},\n\t\t)\n\t\treturn nil\n\t}\n\n\tif streamClosed {\n\t\t// Stream already ended with a \"please wait\" notice; send the real reply via response_url.\n\t\t// Note: task.StreamID and task.ChatID are immutable, safe to read without a lock.\n\t\tlogger.InfoCF(\"wecom_aibot\", \"Sending reply via response_url\", map[string]any{\n\t\t\t\"stream_id\": task.StreamID,\n\t\t\t\"chat_id\":   msg.ChatID,\n\t\t})\n\t\tif responseURL != \"\" {\n\t\t\tif err := c.sendViaResponseURL(responseURL, msg.Content); err != nil {\n\t\t\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to send via response_url\", map[string]any{\n\t\t\t\t\t\"error\":     err,\n\t\t\t\t\t\"stream_id\": task.StreamID,\n\t\t\t\t})\n\t\t\t\tc.removeTask(task)\n\t\t\t\treturn fmt.Errorf(\"response_url delivery failed: %w\", channels.ErrSendFailed)\n\t\t\t}\n\t\t} else {\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Stream closed but no response_url available\", map[string]any{\n\t\t\t\t\"stream_id\": task.StreamID,\n\t\t\t})\n\t\t}\n\t\tc.removeTask(task)\n\t\treturn nil\n\t}\n\n\t// Stream still open: deliver via answerCh for the next poll response.\n\tselect {\n\tcase task.answerCh <- msg.Content:\n\tcase <-task.ctx.Done():\n\t\t// Task was canceled (cleanup removed it); silently drop the reply.\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n\treturn nil\n}\n\n// WebhookPath returns the path for registering on the shared HTTP server\nfunc (c *WeComAIBotChannel) WebhookPath() string {\n\tif c.config.WebhookPath == \"\" {\n\t\treturn \"/webhook/wecom-aibot\"\n\t}\n\treturn c.config.WebhookPath\n}\n\n// ServeHTTP implements http.Handler for the shared HTTP server\nfunc (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tc.handleWebhook(w, r)\n}\n\n// HealthPath returns the health check endpoint path\nfunc (c *WeComAIBotChannel) HealthPath() string {\n\treturn c.WebhookPath() + \"/health\"\n}\n\n// HealthHandler handles health check requests\nfunc (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {\n\tc.handleHealth(w, r)\n}\n\n// handleWebhook handles incoming webhook requests from WeCom AI Bot\nfunc (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\n\t// Log all incoming requests for debugging\n\tlogger.DebugCF(\"wecom_aibot\", \"Received webhook request\", map[string]any{\n\t\t\"method\": r.Method,\n\t\t\"path\":   r.URL.Path,\n\t\t\"query\":  r.URL.RawQuery,\n\t})\n\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\t// URL verification\n\t\tc.handleVerification(ctx, w, r)\n\tcase http.MethodPost:\n\t\t// Message callback\n\t\tc.handleMessageCallback(ctx, w, r)\n\tdefault:\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\n// handleVerification handles the URL verification request from WeCom\nfunc (c *WeComAIBotChannel) handleVerification(\n\tctx context.Context,\n\tw http.ResponseWriter,\n\tr *http.Request,\n) {\n\tmsgSignature := r.URL.Query().Get(\"msg_signature\")\n\ttimestamp := r.URL.Query().Get(\"timestamp\")\n\tnonce := r.URL.Query().Get(\"nonce\")\n\techostr := r.URL.Query().Get(\"echostr\")\n\n\tlogger.DebugCF(\"wecom_aibot\", \"URL verification request\", map[string]any{\n\t\t\"msg_signature\": msgSignature,\n\t\t\"timestamp\":     timestamp,\n\t\t\"nonce\":         nonce,\n\t})\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"Signature verification failed\")\n\t\thttp.Error(w, \"Signature verification failed\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\t// Decrypt echostr\n\t// For WeCom AI Bot (智能机器人), receiveid should be empty string\n\tdecrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, \"\")\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to decrypt echostr\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Remove BOM and whitespace as per WeCom documentation\n\tdecrypted = strings.TrimPrefix(decrypted, \"\\ufeff\")\n\tdecrypted = strings.TrimSpace(decrypted)\n\n\tlogger.InfoC(\"wecom_aibot\", \"URL verification successful\")\n\tw.Header().Set(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tw.WriteHeader(http.StatusOK)\n\tw.Write([]byte(decrypted))\n}\n\n// handleMessageCallback handles incoming messages from WeCom AI Bot\nfunc (c *WeComAIBotChannel) handleMessageCallback(\n\tctx context.Context,\n\tw http.ResponseWriter,\n\tr *http.Request,\n) {\n\tmsgSignature := r.URL.Query().Get(\"msg_signature\")\n\ttimestamp := r.URL.Query().Get(\"timestamp\")\n\tnonce := r.URL.Query().Get(\"nonce\")\n\n\t// Read request body (limit to 4 MB to prevent memory exhaustion)\n\tconst maxBodySize = 4 << 20 // 4 MB\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to read request body\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\thttp.Error(w, \"Failed to read body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif len(body) > maxBodySize {\n\t\thttp.Error(w, \"Request body too large\", http.StatusRequestEntityTooLarge)\n\t\treturn\n\t}\n\n\t// Parse JSON body to get encrypted message\n\t// Format: {\"encrypt\": \"base64_encrypted_string\"}\n\tvar encryptedMsg struct {\n\t\tEncrypt string `json:\"encrypt\"`\n\t}\n\tif unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to parse JSON body\", map[string]any{\n\t\t\t\"error\": unmarshalErr,\n\t\t\t\"body\":  string(body),\n\t\t})\n\t\thttp.Error(w, \"Failed to parse JSON\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"Signature verification failed\")\n\t\thttp.Error(w, \"Signature verification failed\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\t// Decrypt message\n\t// For WeCom AI Bot (智能机器人), receiveid is empty string\n\tdecrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, \"\")\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to decrypt message\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Parse decrypted JSON message\n\tvar msg WeComAIBotMessage\n\tif unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to parse decrypted JSON\", map[string]any{\n\t\t\t\"error\":     unmarshalErr,\n\t\t\t\"decrypted\": decrypted,\n\t\t})\n\t\thttp.Error(w, \"Failed to parse message\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tlogger.DebugCF(\"wecom_aibot\", \"Decrypted message\", map[string]any{\n\t\t\"msgtype\": msg.MsgType,\n\t})\n\n\t// Process the message and get streaming response\n\tresponse := c.processMessage(ctx, msg, timestamp, nonce)\n\n\t// Check if response is empty (e.g. due to unsupported message type)\n\tif response == \"\" {\n\t\tresponse = c.encryptEmptyResponse(timestamp, nonce)\n\t}\n\n\t// Return encrypted JSON response\n\tw.Header().Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\tw.WriteHeader(http.StatusOK)\n\tw.Write([]byte(response))\n}\n\n// processMessage processes the received message and returns encrypted response\nfunc (c *WeComAIBotChannel) processMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\tlogger.DebugCF(\"wecom_aibot\", \"Processing message\", map[string]any{\n\t\t\"msgtype\": msg.MsgType,\n\t})\n\n\tswitch msg.MsgType {\n\tcase \"text\":\n\t\treturn c.handleTextMessage(ctx, msg, timestamp, nonce)\n\tcase \"stream\":\n\t\treturn c.handleStreamMessage(ctx, msg, timestamp, nonce)\n\tcase \"image\":\n\t\treturn c.handleImageMessage(ctx, msg, timestamp, nonce)\n\tcase \"mixed\":\n\t\treturn c.handleMixedMessage(ctx, msg, timestamp, nonce)\n\tcase \"event\":\n\t\treturn c.handleEventMessage(ctx, msg, timestamp, nonce)\n\tdefault:\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Unsupported message type\", map[string]any{\n\t\t\t\"msgtype\": msg.MsgType,\n\t\t})\n\t\treturn c.encryptResponse(\"\", timestamp, nonce, WeComAIBotStreamResponse{\n\t\t\tMsgType: \"stream\",\n\t\t\tStream: WeComAIBotStreamInfo{\n\t\t\t\tID:      c.generateStreamID(),\n\t\t\t\tFinish:  true,\n\t\t\t\tContent: \"Unsupported message type: \" + msg.MsgType,\n\t\t\t},\n\t\t})\n\t}\n}\n\n// handleTextMessage handles text messages by starting a new streaming task\nfunc (c *WeComAIBotChannel) handleTextMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\tif msg.Text == nil {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"text message missing text field\")\n\t\treturn c.encryptEmptyResponse(timestamp, nonce)\n\t}\n\n\tcontent := msg.Text.Content\n\tuserID := msg.From.UserID\n\tif userID == \"\" {\n\t\tuserID = \"unknown\"\n\t}\n\n\t// chatID: group chat uses chatid, single chat uses userid\n\tchatID := msg.ChatID\n\tif chatID == \"\" {\n\t\tchatID = userID\n\t}\n\n\tstreamID := c.generateStreamID()\n\n\t// WeCom stops sending stream-refresh callbacks after 6 minutes.\n\t// Set a slightly shorter deadline so we can send a timeout notice before it gives up.\n\tdeadline := time.Now().Add(30 * time.Second)\n\n\t// Each task gets its own context derived from the channel lifetime context.\n\t// Canceling taskCancel interrupts the agent goroutine when the task is removed.\n\ttaskCtx, taskCancel := context.WithCancel(c.ctx)\n\n\ttask := &streamTask{\n\t\tStreamID:    streamID,\n\t\tChatID:      chatID,\n\t\tResponseURL: msg.ResponseURL,\n\t\tQuestion:    content,\n\t\tCreatedTime: time.Now(),\n\t\tDeadline:    deadline,\n\t\tFinished:    false,\n\t\tanswerCh:    make(chan string, 1),\n\t\tctx:         taskCtx,\n\t\tcancel:      taskCancel,\n\t}\n\n\tc.taskMu.Lock()\n\tc.streamTasks[streamID] = task\n\tc.chatTasks[chatID] = append(c.chatTasks[chatID], task)\n\tc.taskMu.Unlock()\n\n\t// Publish to agent asynchronously; agent will call Send() with reply.\n\t// Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed.\n\tgo func() {\n\t\tsender := bus.SenderInfo{\n\t\t\tPlatform:    \"wecom_aibot\",\n\t\t\tPlatformID:  userID,\n\t\t\tCanonicalID: identity.BuildCanonicalID(\"wecom_aibot\", userID),\n\t\t\tDisplayName: userID,\n\t\t}\n\t\tpeerKind := \"direct\"\n\t\tif msg.ChatType == \"group\" {\n\t\t\tpeerKind = \"group\"\n\t\t}\n\t\tpeer := bus.Peer{Kind: peerKind, ID: chatID}\n\t\tmetadata := map[string]string{\n\t\t\t\"channel\":      \"wecom_aibot\",\n\t\t\t\"chat_type\":    msg.ChatType,\n\t\t\t\"msg_type\":     \"text\",\n\t\t\t\"msgid\":        msg.MsgID,\n\t\t\t\"aibotid\":      msg.AIBotID,\n\t\t\t\"stream_id\":    streamID,\n\t\t\t\"response_url\": msg.ResponseURL,\n\t\t}\n\t\tc.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID,\n\t\t\tcontent, nil, metadata, sender)\n\t}()\n\n\t// Return first streaming response immediately (finish=false, content empty)\n\treturn c.getStreamResponse(task, timestamp, nonce)\n}\n\n// handleStreamMessage handles stream polling requests\nfunc (c *WeComAIBotChannel) handleStreamMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\tif msg.Stream == nil {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"Stream message missing stream field\")\n\t\treturn c.encryptEmptyResponse(timestamp, nonce)\n\t}\n\n\tstreamID := msg.Stream.ID\n\n\tc.taskMu.RLock()\n\ttask, exists := c.streamTasks[streamID]\n\tc.taskMu.RUnlock()\n\n\tif !exists {\n\t\tlogger.DebugCF(\n\t\t\t\"wecom_aibot\",\n\t\t\t\"Stream task not found (may be from previous session)\",\n\t\t\tmap[string]any{\n\t\t\t\t\"stream_id\": streamID,\n\t\t\t},\n\t\t)\n\t\treturn c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{\n\t\t\tMsgType: \"stream\",\n\t\t\tStream: WeComAIBotStreamInfo{\n\t\t\t\tID:      streamID,\n\t\t\t\tFinish:  true,\n\t\t\t\tContent: \"Task not found or already finished. Please resend your message to start a new session.\",\n\t\t\t},\n\t\t})\n\t}\n\n\t// Get next response\n\treturn c.getStreamResponse(task, timestamp, nonce)\n}\n\n// handleImageMessage handles image messages\nfunc (c *WeComAIBotChannel) handleImageMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\tlogger.WarnC(\"wecom_aibot\", \"Image message type not yet fully implemented\")\n\tif msg.Image == nil {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"Image message missing image field\")\n\t\treturn c.encryptEmptyResponse(timestamp, nonce)\n\t}\n\n\timageURL := msg.Image.URL\n\n\t// For now, just acknowledge receipt without echoing the image\n\treturn c.encryptResponse(\"\", timestamp, nonce, WeComAIBotStreamResponse{\n\t\tMsgType: \"stream\",\n\t\tStream: WeComAIBotStreamInfo{\n\t\t\tID:     c.generateStreamID(),\n\t\t\tFinish: true,\n\t\t\tContent: fmt.Sprintf(\n\t\t\t\t\"Image received (URL: %s), but image messages are not yet supported\",\n\t\t\t\timageURL,\n\t\t\t),\n\t\t},\n\t})\n}\n\n// handleMixedMessage handles mixed (text + image) messages\nfunc (c *WeComAIBotChannel) handleMixedMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\tlogger.WarnC(\"wecom_aibot\", \"Mixed message type not yet fully implemented\")\n\treturn c.encryptResponse(\"\", timestamp, nonce, WeComAIBotStreamResponse{\n\t\tMsgType: \"stream\",\n\t\tStream: WeComAIBotStreamInfo{\n\t\t\tID:      c.generateStreamID(),\n\t\t\tFinish:  true,\n\t\t\tContent: \"Mixed message type is not yet supported\",\n\t\t},\n\t})\n}\n\n// handleEventMessage handles event messages\nfunc (c *WeComAIBotChannel) handleEventMessage(\n\tctx context.Context,\n\tmsg WeComAIBotMessage,\n\ttimestamp, nonce string,\n) string {\n\teventType := \"\"\n\tif msg.Event != nil {\n\t\teventType = msg.Event.EventType\n\t}\n\tlogger.DebugCF(\"wecom_aibot\", \"Received event\", map[string]any{\n\t\t\"event_type\": eventType,\n\t})\n\n\t// Send welcome message when user opens the chat window\n\tif eventType == \"enter_chat\" && c.config.WelcomeMessage != \"\" {\n\t\tstreamID := c.generateStreamID()\n\t\treturn c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{\n\t\t\tMsgType: \"stream\",\n\t\t\tStream: WeComAIBotStreamInfo{\n\t\t\t\tID:      streamID,\n\t\t\t\tFinish:  true,\n\t\t\t\tContent: c.config.WelcomeMessage,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn c.encryptEmptyResponse(timestamp, nonce)\n}\n\n// getStreamResponse gets the next streaming response for a task.\n// - If agent replied: return finish=true with the real answer.\n// - If deadline passed: return finish=true with a \"please wait\" notice, keep task alive for response_url.\n// - Otherwise: return finish=false (empty), client will poll again.\nfunc (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string {\n\tvar content string\n\tvar finish bool\n\tvar closeStreamOnly bool // close stream but do NOT remove task (response_url still pending)\n\n\tselect {\n\tcase answer := <-task.answerCh:\n\t\t// Agent replied before deadline — normal finish.\n\t\tcontent = answer\n\t\tfinish = true\n\tdefault:\n\t\tif time.Now().After(task.Deadline) {\n\t\t\t// Deadline reached: close the stream with a notice, then wait for agent via response_url.\n\t\t\tcontent = c.config.ProcessingMessage\n\t\t\tfinish = true\n\t\t\tcloseStreamOnly = true\n\t\t\tlogger.InfoCF(\n\t\t\t\t\"wecom_aibot\",\n\t\t\t\t\"Stream deadline reached, switching to response_url mode\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"stream_id\":    task.StreamID,\n\t\t\t\t\t\"chat_id\":      task.ChatID,\n\t\t\t\t\t\"response_url\": task.ResponseURL != \"\",\n\t\t\t\t},\n\t\t\t)\n\t\t}\n\t\t// else: still waiting, return finish=false\n\t}\n\n\tif finish && !closeStreamOnly {\n\t\t// Normal finish: remove from all maps.\n\t\tc.removeTask(task)\n\t} else if closeStreamOnly {\n\t\t// Mark stream as closed and remove from streamTasks under a single lock\n\t\t// to keep StreamClosed/StreamClosedAt consistent with map membership.\n\t\tc.taskMu.Lock()\n\t\ttask.StreamClosed = true\n\t\ttask.StreamClosedAt = time.Now()\n\t\tdelete(c.streamTasks, task.StreamID)\n\t\tc.taskMu.Unlock()\n\t}\n\n\tresponse := WeComAIBotStreamResponse{\n\t\tMsgType: \"stream\",\n\t\tStream: WeComAIBotStreamInfo{\n\t\t\tID:      task.StreamID,\n\t\t\tFinish:  finish,\n\t\t\tContent: content,\n\t\t},\n\t}\n\n\treturn c.encryptResponse(task.StreamID, timestamp, nonce, response)\n}\n\n// removeTask removes a task from both streamTasks and chatTasks, marks it finished,\n// and cancels its context to interrupt the associated agent goroutine.\nfunc (c *WeComAIBotChannel) removeTask(task *streamTask) {\n\t// Cancel first so the agent goroutine stops as soon as possible,\n\t// before we acquire the write lock.\n\ttask.cancel()\n\n\tc.taskMu.Lock()\n\ttask.Finished = true // written under c.taskMu, consistent with all readers\n\tdelete(c.streamTasks, task.StreamID)\n\tqueue := c.chatTasks[task.ChatID]\n\tfor i, t := range queue {\n\t\tif t == task {\n\t\t\tc.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\tif len(c.chatTasks[task.ChatID]) == 0 {\n\t\tdelete(c.chatTasks, task.ChatID)\n\t}\n\tc.taskMu.Unlock()\n}\n\n// sendViaResponseURL posts a markdown reply to the WeCom response_url.\n// response_url is valid for 1 hour and can only be used once per callback.\n// Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary,\n// or channels.ErrSendFailed so the manager can apply the right retry policy.\nfunc (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error {\n\tpayload := map[string]any{\n\t\t\"msgtype\": \"markdown\",\n\t\t\"markdown\": map[string]string{\n\t\t\t\"content\": content,\n\t\t},\n\t}\n\tbody, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal payload: %w\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(c.ctx, 15*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\n\tresp, err := responseURLHTTPClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"post to response_url failed: %w: %w\", channels.ErrTemporary, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode == http.StatusOK {\n\t\treturn nil\n\t}\n\n\tconst maxErrBody = 64 << 10 // 64 KB is more than enough for any error response\n\trespBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading response_url body: %w: %w\", channels.ErrTemporary, err)\n\t}\n\tswitch {\n\tcase resp.StatusCode == http.StatusTooManyRequests:\n\t\treturn fmt.Errorf(\"response_url rate limited (%d): %s: %w\",\n\t\t\tresp.StatusCode, respBody, channels.ErrRateLimit)\n\tcase resp.StatusCode >= 500:\n\t\treturn fmt.Errorf(\"response_url server error (%d): %s: %w\",\n\t\t\tresp.StatusCode, respBody, channels.ErrTemporary)\n\tdefault:\n\t\treturn fmt.Errorf(\"response_url returned %d: %s: %w\",\n\t\t\tresp.StatusCode, respBody, channels.ErrSendFailed)\n\t}\n}\n\n// encryptResponse encrypts a streaming response\nfunc (c *WeComAIBotChannel) encryptResponse(\n\tstreamID, timestamp, nonce string,\n\tresponse WeComAIBotStreamResponse,\n) string {\n\t// Marshal response to JSON\n\tplaintext, err := json.Marshal(response)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to marshal response\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tlogger.DebugCF(\"wecom_aibot\", \"Encrypting response\", map[string]any{\n\t\t\"stream_id\": streamID,\n\t\t\"finish\":    response.Stream.Finish,\n\t\t\"preview\":   utils.Truncate(response.Stream.Content, 100),\n\t})\n\n\t// Encrypt message\n\tencrypted, err := c.encryptMessage(string(plaintext), \"\")\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to encrypt message\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\t// Generate signature\n\tsignature := computeSignature(c.config.Token, timestamp, nonce, encrypted)\n\n\t// Build encrypted response\n\tencryptedResp := WeComAIBotEncryptedResponse{\n\t\tEncrypt:      encrypted,\n\t\tMsgSignature: signature,\n\t\tTimestamp:    timestamp,\n\t\tNonce:        nonce,\n\t}\n\n\trespJSON, err := json.Marshal(encryptedResp)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_aibot\", \"Failed to marshal encrypted response\", map[string]any{\n\t\t\t\"error\": err,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tlogger.DebugCF(\"wecom_aibot\", \"Response encrypted\", map[string]any{\n\t\t\"stream_id\": streamID,\n\t})\n\n\treturn string(respJSON)\n}\n\n// encryptEmptyResponse returns a minimal valid encrypted response\nfunc (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string {\n\t// Construct a zero-value stream response and encrypt it so that\n\t// WeCom always receives a syntactically valid encrypted JSON object.\n\temptyResp := WeComAIBotStreamResponse{}\n\treturn c.encryptResponse(\"\", timestamp, nonce, emptyResp)\n}\n\n// encryptMessage encrypts a plain text message for WeCom AI Bot\nfunc (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {\n\taesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tframe, err := packWeComFrame(plaintext, receiveid)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// PKCS7 padding then AES-CBC encrypt\n\tpaddedFrame := pkcs7Pad(frame, blockSize)\n\tciphertext, err := encryptAESCBC(aesKey, paddedFrame)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// func (c *WeComAIBotChannel) downloadAndDecryptImage(\n// \tctx context.Context,\n// \timageURL string,\n// ) ([]byte, error) {\n// \t// Download image\n// \treq, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)\n// \tif err != nil {\n// \t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n// \t}\n\n// \tclient := &http.Client{\n// \t\tTimeout: 15 * time.Second,\n// \t}\n\n// \tresp, err := client.Do(req)\n// \tif err != nil {\n// \t\treturn nil, fmt.Errorf(\"failed to download image: %w\", err)\n// \t}\n// \tdefer resp.Body.Close()\n\n// \tif resp.StatusCode != http.StatusOK {\n// \t\treturn nil, fmt.Errorf(\"download failed with status: %d\", resp.StatusCode)\n// \t}\n\n// \t// Limit image download to 20 MB to prevent memory exhaustion\n// \tconst maxImageSize = 20 << 20 // 20 MB\n// \tencryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))\n// \tif err != nil {\n// \t\treturn nil, fmt.Errorf(\"failed to read image data: %w\", err)\n// \t}\n// \tif len(encryptedData) > maxImageSize {\n// \t\treturn nil, fmt.Errorf(\"image too large (exceeds %d MB)\", maxImageSize>>20)\n// \t}\n\n// \tlogger.DebugCF(\"wecom_aibot\", \"Image downloaded\", map[string]any{\n// \t\t\"size\": len(encryptedData),\n// \t})\n\n// \t// Decode AES key\n// \taesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)\n// \tif err != nil {\n// \t\treturn nil, err\n// \t}\n\n// \t// Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped)\n// \tdecryptedData, err := decryptAESCBC(aesKey, encryptedData)\n// \tif err != nil {\n// \t\treturn nil, fmt.Errorf(\"failed to decrypt image: %w\", err)\n// \t}\n\n// \tlogger.DebugCF(\"wecom_aibot\", \"Image decrypted\", map[string]any{\n// \t\t\"size\": len(decryptedData),\n// \t})\n\n// \treturn decryptedData, nil\n// }\n\n// generateRandomID generates a cryptographically random alphanumeric ID of\n// length n.  Used for stream IDs and WebSocket request IDs.\nfunc generateRandomID(n int) string {\n\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\tb := make([]byte, n)\n\tfor i := range b {\n\t\tnum, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))\n\t\tb[i] = letters[num.Int64()]\n\t}\n\treturn string(b)\n}\n\n// generateStreamID generates a random 10-character stream ID (webhook mode).\nfunc (c *WeComAIBotChannel) generateStreamID() string {\n\treturn generateRandomID(10)\n}\n\n// cleanupLoop periodically cleans up old streaming tasks\nfunc (c *WeComAIBotChannel) cleanupLoop() {\n\tticker := time.NewTicker(5 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tc.cleanupOldTasks()\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// cleanupOldTasks removes tasks that have exceeded their expected lifetime:\n//   - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window).\n//   - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod.\n//     These tasks are waiting for the agent to call Send() via response_url. If the agent\n//     crashes or times out without calling Send(), we must not let them accumulate indefinitely.\n//     The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour,\n//     preventing chatTasks from filling up when many requests time out in quick succession.\nconst (\n\tstreamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes\n\ttaskMaxLifetime         = 1 * time.Hour    // absolute max (≈ response_url validity)\n)\n\nfunc (c *WeComAIBotChannel) cleanupOldTasks() {\n\tc.taskMu.Lock()\n\tdefer c.taskMu.Unlock()\n\n\tnow := time.Now()\n\tcutoff := now.Add(-taskMaxLifetime)\n\tfor id, task := range c.streamTasks {\n\t\tif task.CreatedTime.Before(cutoff) {\n\t\t\tdelete(c.streamTasks, id)\n\t\t\ttask.cancel() // interrupt agent goroutine still waiting for LLM\n\t\t\tqueue := c.chatTasks[task.ChatID]\n\t\t\tfor i, t := range queue {\n\t\t\t\tif t == task {\n\t\t\t\t\tc.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(c.chatTasks[task.ChatID]) == 0 {\n\t\t\t\tdelete(c.chatTasks, task.ChatID)\n\t\t\t}\n\t\t\tlogger.DebugCF(\"wecom_aibot\", \"Cleaned up expired task\", map[string]any{\n\t\t\t\t\"stream_id\": id,\n\t\t\t})\n\t\t}\n\t}\n\t// Clean up StreamClosed tasks from chatTasks.\n\t// Two expiry conditions are checked:\n\t//  1. Absolute expiry: task was created more than taskMaxLifetime ago.\n\t//  2. Grace expiry: stream closed more than streamClosedGracePeriod ago\n\t//     (agent had enough time to reply; it is not coming back).\n\tfor chatID, queue := range c.chatTasks {\n\t\tfiltered := queue[:0]\n\t\tfor i, t := range queue {\n\t\t\tabsoluteExpired := t.CreatedTime.Before(cutoff)\n\t\t\tgraceExpired := t.StreamClosed &&\n\t\t\t\t!t.StreamClosedAt.IsZero() &&\n\t\t\t\tt.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod))\n\t\t\tif t.Finished {\n\t\t\t\t// Finished tasks should have been removed by removeTask().\n\t\t\t\t// Finding one here (especially not at position 0) means an\n\t\t\t\t// unexpected code path left it stranded, causing the queue to\n\t\t\t\t// grow silently. Log a warning so it is visible, then drop it.\n\t\t\t\tif i > 0 {\n\t\t\t\t\tlogger.WarnCF(\"wecom_aibot\",\n\t\t\t\t\t\t\"Found stranded Finished task in the middle of chatTasks queue; \"+\n\t\t\t\t\t\t\t\"this should not happen — removeTask() should have spliced it out\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"chat_id\":   chatID,\n\t\t\t\t\t\t\t\"stream_id\": t.StreamID,\n\t\t\t\t\t\t\t\"position\":  i,\n\t\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\t// The task is already finished; its context was already canceled\n\t\t\t\t// by removeTask(), so no further action is required.\n\t\t\t\tcontinue\n\t\t\t} else if !absoluteExpired && !graceExpired {\n\t\t\t\tfiltered = append(filtered, t)\n\t\t\t} else {\n\t\t\t\tt.cancel() // cancel any lingering agent goroutine\n\t\t\t}\n\t\t}\n\t\tif len(filtered) == 0 {\n\t\t\tdelete(c.chatTasks, chatID)\n\t\t} else {\n\t\t\tc.chatTasks[chatID] = filtered\n\t\t}\n\t}\n}\n\n// handleHealth handles health check requests\nfunc (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {\n\tstatus := \"ok\"\n\tif !c.IsRunning() {\n\t\tstatus = \"not running\"\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\"status\": status,\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/wecom/aibot_test.go",
    "content": "package wecom\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// ---- Webhook mode tests ----\n\nfunc TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {\n\tt.Run(\"success with valid config\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tToken:          \"test_token\",\n\t\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t\t\tWebhookPath:    \"/webhook/test\",\n\t\t}\n\n\t\tmessageBus := bus.NewMessageBus()\n\t\tch, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t}\n\t\tif ch == nil {\n\t\t\tt.Fatal(\"Expected channel to be created\")\n\t\t}\n\t\tif ch.Name() != \"wecom_aibot\" {\n\t\t\tt.Errorf(\"Expected name 'wecom_aibot', got '%s'\", ch.Name())\n\t\t}\n\t\t// Webhook mode must implement WebhookHandler.\n\t\tif _, ok := ch.(channels.WebhookHandler); !ok {\n\t\t\tt.Error(\"Webhook mode channel should implement WebhookHandler\")\n\t\t}\n\t})\n\n\tt.Run(\"error with missing token\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\t_, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"Expected error for missing token, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"error with missing encoding key\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled: true,\n\t\t\tToken:   \"test_token\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\t_, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"Expected error for missing encoding key, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAIBotWebhookChannelStartStop(t *testing.T) {\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled:        true,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t}\n\n\tmessageBus := bus.NewMessageBus()\n\tch, err := NewWeComAIBotChannel(cfg, messageBus)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create channel: %v\", err)\n\t}\n\n\tctx := context.Background()\n\n\tif err := ch.Start(ctx); err != nil {\n\t\tt.Fatalf(\"Failed to start channel: %v\", err)\n\t}\n\tif !ch.IsRunning() {\n\t\tt.Error(\"Expected channel to be running after Start\")\n\t}\n\n\tif err := ch.Stop(ctx); err != nil {\n\t\tt.Fatalf(\"Failed to stop channel: %v\", err)\n\t}\n\tif ch.IsRunning() {\n\t\tt.Error(\"Expected channel to be stopped after Stop\")\n\t}\n}\n\nfunc TestWeComAIBotChannelWebhookPath(t *testing.T) {\n\tt.Run(\"default path\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tToken:          \"test_token\",\n\t\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\tch, _ := NewWeComAIBotChannel(cfg, messageBus)\n\n\t\twh, ok := ch.(channels.WebhookHandler)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Expected channel to implement WebhookHandler\")\n\t\t}\n\t\texpectedPath := \"/webhook/wecom-aibot\"\n\t\tif wh.WebhookPath() != expectedPath {\n\t\t\tt.Errorf(\"Expected webhook path '%s', got '%s'\", expectedPath, wh.WebhookPath())\n\t\t}\n\t})\n\n\tt.Run(\"custom path\", func(t *testing.T) {\n\t\tcustomPath := \"/custom/webhook\"\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tToken:          \"test_token\",\n\t\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t\t\tWebhookPath:    customPath,\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\tch, _ := NewWeComAIBotChannel(cfg, messageBus)\n\n\t\twh, ok := ch.(channels.WebhookHandler)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Expected channel to implement WebhookHandler\")\n\t\t}\n\t\tif wh.WebhookPath() != customPath {\n\t\t\tt.Errorf(\"Expected webhook path '%s', got '%s'\", customPath, wh.WebhookPath())\n\t\t}\n\t})\n}\n\nfunc TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {\n\tvalidAESKey := \"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG\"\n\n\tt.Run(\"uses default processing message\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tToken:          \"test_token\",\n\t\t\tEncodingAESKey: validAESKey,\n\t\t}\n\n\t\tmessageBus := bus.NewMessageBus()\n\t\tchannel, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create channel: %v\", err)\n\t\t}\n\t\tch, ok := channel.(*WeComAIBotChannel)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Expected webhook mode channel\")\n\t\t}\n\n\t\ttask := &streamTask{\n\t\t\tStreamID: \"stream-default\",\n\t\t\tChatID:   \"chat-default\",\n\t\t\tDeadline: time.Now().Add(-time.Second),\n\t\t}\n\t\tch.streamTasks[task.StreamID] = task\n\t\tch.chatTasks[task.ChatID] = []*streamTask{task}\n\n\t\tresp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, \"1234567890\", \"nonce\"))\n\n\t\tif !resp.Stream.Finish {\n\t\t\tt.Fatal(\"Expected finished stream response after deadline\")\n\t\t}\n\t\tif resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage {\n\t\t\tt.Fatalf(\"Expected default processing message %q, got %q\",\n\t\t\t\tconfig.DefaultWeComAIBotProcessingMessage, resp.Stream.Content)\n\t\t}\n\t\tif !task.StreamClosed {\n\t\t\tt.Fatal(\"Expected task stream to be marked closed\")\n\t\t}\n\t\tif _, ok := ch.streamTasks[task.StreamID]; ok {\n\t\t\tt.Fatal(\"Expected closed stream task to be removed from streamTasks\")\n\t\t}\n\t\tif len(ch.chatTasks[task.ChatID]) != 1 {\n\t\t\tt.Fatalf(\"Expected task to remain queued for response_url delivery, got %d entries\",\n\t\t\t\tlen(ch.chatTasks[task.ChatID]))\n\t\t}\n\t})\n\n\tt.Run(\"uses custom processing message\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:           true,\n\t\t\tToken:             \"test_token\",\n\t\t\tEncodingAESKey:    validAESKey,\n\t\t\tProcessingMessage: \"Please wait a moment. The result will be delivered in a follow-up message.\",\n\t\t}\n\n\t\tmessageBus := bus.NewMessageBus()\n\t\tchannel, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create channel: %v\", err)\n\t\t}\n\t\tch, ok := channel.(*WeComAIBotChannel)\n\t\tif !ok {\n\t\t\tt.Fatal(\"Expected webhook mode channel\")\n\t\t}\n\n\t\ttask := &streamTask{\n\t\t\tStreamID: \"stream-custom\",\n\t\t\tChatID:   \"chat-custom\",\n\t\t\tDeadline: time.Now().Add(-time.Second),\n\t\t}\n\n\t\tresp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, \"1234567890\", \"nonce\"))\n\n\t\tif resp.Stream.Content != cfg.ProcessingMessage {\n\t\t\tt.Fatalf(\"Expected custom processing message %q, got %q\", cfg.ProcessingMessage, resp.Stream.Content)\n\t\t}\n\t})\n}\n\nfunc TestGenerateStreamID(t *testing.T) {\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled:        true,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t}\n\tmessageBus := bus.NewMessageBus()\n\tch, _ := NewWeComAIBotChannel(cfg, messageBus)\n\twebhookCh, ok := ch.(*WeComAIBotChannel)\n\tif !ok {\n\t\tt.Fatal(\"Expected webhook mode channel\")\n\t}\n\n\tids := make(map[string]bool)\n\tfor i := 0; i < 100; i++ {\n\t\tid := webhookCh.generateStreamID()\n\t\tif len(id) != 10 {\n\t\t\tt.Errorf(\"Expected stream ID length 10, got %d\", len(id))\n\t\t}\n\t\tif ids[id] {\n\t\t\tt.Errorf(\"Duplicate stream ID generated: %s\", id)\n\t\t}\n\t\tids[id] = true\n\t}\n}\n\nfunc TestEncryptDecrypt(t *testing.T) {\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled:        true,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: \"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG\", // 43 characters\n\t}\n\tmessageBus := bus.NewMessageBus()\n\tch, _ := NewWeComAIBotChannel(cfg, messageBus)\n\twebhookCh, ok := ch.(*WeComAIBotChannel)\n\tif !ok {\n\t\tt.Fatal(\"Expected webhook mode channel\")\n\t}\n\n\tplaintext := \"Hello, World!\"\n\treceiveid := \"\"\n\n\tencrypted, err := webhookCh.encryptMessage(plaintext, receiveid)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to encrypt message: %v\", err)\n\t}\n\tif encrypted == \"\" {\n\t\tt.Fatal(\"Encrypted message is empty\")\n\t}\n\n\tdecrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to decrypt message: %v\", err)\n\t}\n\tif decrypted != plaintext {\n\t\tt.Errorf(\"Expected decrypted message '%s', got '%s'\", plaintext, decrypted)\n\t}\n}\n\nfunc TestGenerateSignature(t *testing.T) {\n\ttoken := \"test_token\"\n\ttimestamp := \"1234567890\"\n\tnonce := \"test_nonce\"\n\tencrypt := \"encrypted_msg\"\n\n\tsignature := computeSignature(token, timestamp, nonce, encrypt)\n\tif signature == \"\" {\n\t\tt.Error(\"Generated signature is empty\")\n\t}\n\tif !verifySignature(token, signature, timestamp, nonce, encrypt) {\n\t\tt.Error(\"Generated signature does not verify correctly\")\n\t}\n}\n\nfunc decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse {\n\tt.Helper()\n\n\tvar wrapped WeComAIBotEncryptedResponse\n\tif err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil {\n\t\tt.Fatalf(\"Failed to unmarshal encrypted response: %v\", err)\n\t}\n\n\tplaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to decrypt response: %v\", err)\n\t}\n\n\tvar resp WeComAIBotStreamResponse\n\tif err := json.Unmarshal([]byte(plaintext), &resp); err != nil {\n\t\tt.Fatalf(\"Failed to unmarshal decrypted response: %v\", err)\n\t}\n\n\treturn resp\n}\n\n// ---- WebSocket long-connection mode tests ----\n\nfunc TestNewWeComAIBotChannel_WSMode(t *testing.T) {\n\tt.Run(\"success with bot_id and secret\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled: true,\n\t\t\tBotID:   \"test_bot_id\",\n\t\t\tSecret:  \"test_secret\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\tch, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t}\n\t\tif ch == nil {\n\t\t\tt.Fatal(\"Expected channel to be created\")\n\t\t}\n\t\tif ch.Name() != \"wecom_aibot\" {\n\t\t\tt.Errorf(\"Expected name 'wecom_aibot', got '%s'\", ch.Name())\n\t\t}\n\t\t// WebSocket mode must NOT implement WebhookHandler.\n\t\tif _, ok := ch.(channels.WebhookHandler); ok {\n\t\t\tt.Error(\"WebSocket mode channel should NOT implement WebhookHandler\")\n\t\t}\n\t})\n\n\tt.Run(\"ws mode takes priority over webhook fields\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled:        true,\n\t\t\tBotID:          \"test_bot_id\",\n\t\t\tSecret:         \"test_secret\",\n\t\t\tToken:          \"also_set\",\n\t\t\tEncodingAESKey: \"testkey1234567890123456789012345678901234567\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\tch, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t\t}\n\t\tif _, ok := ch.(*WeComAIBotWSChannel); !ok {\n\t\t\tt.Error(\"Expected WebSocket mode channel when both BotID+Secret and Token+Key are set\")\n\t\t}\n\t})\n\n\tt.Run(\"error with missing bot_id\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled: true,\n\t\t\tSecret:  \"test_secret\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\t_, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\t// Missing bot_id alone means neither WS mode nor webhook mode is fully configured.\n\t\tif err == nil {\n\t\t\tt.Fatal(\"Expected error for missing bot_id, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"error with missing secret\", func(t *testing.T) {\n\t\tcfg := config.WeComAIBotConfig{\n\t\t\tEnabled: true,\n\t\t\tBotID:   \"test_bot_id\",\n\t\t}\n\t\tmessageBus := bus.NewMessageBus()\n\t\t_, err := NewWeComAIBotChannel(cfg, messageBus)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"Expected error for missing secret, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAIBotWSChannelStartStop(t *testing.T) {\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled: true,\n\t\tBotID:   \"test_bot_id\",\n\t\tSecret:  \"test_secret\",\n\t}\n\tmessageBus := bus.NewMessageBus()\n\tch, err := NewWeComAIBotChannel(cfg, messageBus)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create channel: %v\", err)\n\t}\n\n\tctx := context.Background()\n\n\t// Start launches a background goroutine; it should not block or return an error.\n\tif err := ch.Start(ctx); err != nil {\n\t\tt.Fatalf(\"Failed to start channel: %v\", err)\n\t}\n\tif !ch.IsRunning() {\n\t\tt.Error(\"Expected channel to be running after Start\")\n\t}\n\n\t// Stop should work regardless of whether the WebSocket actually connected.\n\tif err := ch.Stop(ctx); err != nil {\n\t\tt.Fatalf(\"Failed to stop channel: %v\", err)\n\t}\n\tif ch.IsRunning() {\n\t\tt.Error(\"Expected channel to be stopped after Stop\")\n\t}\n}\n\nfunc TestGenerateRandomID(t *testing.T) {\n\tids := make(map[string]bool)\n\tfor i := 0; i < 200; i++ {\n\t\tid := generateRandomID(10)\n\t\tif len(id) != 10 {\n\t\t\tt.Errorf(\"Expected ID length 10, got %d\", len(id))\n\t\t}\n\t\tif ids[id] {\n\t\t\tt.Errorf(\"Duplicate ID generated: %s\", id)\n\t\t}\n\t\tids[id] = true\n\t}\n}\n\nfunc TestWSGenerateID(t *testing.T) {\n\tids := make(map[string]bool)\n\tfor i := 0; i < 200; i++ {\n\t\tid := wsGenerateID()\n\t\tif len(id) != 10 {\n\t\t\tt.Errorf(\"Expected ID length 10, got %d\", len(id))\n\t\t}\n\t\tif ids[id] {\n\t\t\tt.Errorf(\"Duplicate wsGenerateID result: %s\", id)\n\t\t}\n\t\tids[id] = true\n\t}\n}\n\n// ---- Webhook streaming fallback tests ----\n\n// makeWebhookChannel creates a started WeComAIBotChannel for testing.\nfunc makeWebhookChannel(t *testing.T) *WeComAIBotChannel {\n\tt.Helper()\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled:        true,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: \"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG\",\n\t}\n\tch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())\n\tif err != nil {\n\t\tt.Fatalf(\"create channel: %v\", err)\n\t}\n\twc := ch.(*WeComAIBotChannel)\n\twc.ctx, wc.cancel = context.WithCancel(context.Background())\n\treturn wc\n}\n\n// makeStreamTask creates and registers a streamTask for testing.\nfunc makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask {\n\tt.Helper()\n\ttask := &streamTask{\n\t\tStreamID: streamID,\n\t\tChatID:   chatID,\n\t\tDeadline: deadline,\n\t\tanswerCh: make(chan string, 1),\n\t}\n\ttask.ctx, task.cancel = context.WithCancel(ch.ctx)\n\tch.taskMu.Lock()\n\tch.streamTasks[streamID] = task\n\tch.chatTasks[chatID] = append(ch.chatTasks[chatID], task)\n\tch.taskMu.Unlock()\n\treturn task\n}\n\n// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already\n// placed its answer in answerCh, getStreamResponse returns a finish=true response\n// and fully removes the task.\nfunc TestGetStreamResponse_ImmediateAnswer(t *testing.T) {\n\tch := makeWebhookChannel(t)\n\tdefer ch.cancel()\n\n\ttask := makeStreamTask(t, ch, \"stream-1\", \"chat-1\", time.Now().Add(30*time.Second))\n\ttask.answerCh <- \"hello from agent\"\n\n\tresult := ch.getStreamResponse(task, \"ts123\", \"nonce123\")\n\tif result == \"\" {\n\t\tt.Fatal(\"expected non-empty encrypted response\")\n\t}\n\n\tch.taskMu.RLock()\n\t_, exists := ch.streamTasks[\"stream-1\"]\n\tch.taskMu.RUnlock()\n\tif exists {\n\t\tt.Error(\"task should have been removed from streamTasks after normal finish\")\n\t}\n\tif !task.Finished {\n\t\tt.Error(\"task.Finished should be true after normal finish\")\n\t}\n}\n\n// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has\n// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the\n// task alive so the response_url fallback can still deliver the answer.\nfunc TestGetStreamResponse_DeadlinePassed(t *testing.T) {\n\tch := makeWebhookChannel(t)\n\tdefer ch.cancel()\n\n\ttask := makeStreamTask(t, ch, \"stream-2\", \"chat-2\", time.Now().Add(-time.Millisecond))\n\n\tresult := ch.getStreamResponse(task, \"ts456\", \"nonce456\")\n\tif result == \"\" {\n\t\tt.Fatal(\"expected non-empty encrypted response\")\n\t}\n\n\tch.taskMu.RLock()\n\t_, stillStreaming := ch.streamTasks[\"stream-2\"]\n\tch.taskMu.RUnlock()\n\tif stillStreaming {\n\t\tt.Error(\"task should have been removed from streamTasks after deadline\")\n\t}\n\tif !task.StreamClosed {\n\t\tt.Error(\"task.StreamClosed should be true after deadline\")\n\t}\n\tif task.Finished {\n\t\tt.Error(\"task.Finished must remain false: agent reply still expected via response_url\")\n\t}\n}\n\n// TestGetStreamResponse_StillPending verifies that when neither the agent has\n// replied nor the deadline has passed, getStreamResponse returns without altering\n// task state (client should poll again).\nfunc TestGetStreamResponse_StillPending(t *testing.T) {\n\tch := makeWebhookChannel(t)\n\tdefer ch.cancel()\n\n\ttask := makeStreamTask(t, ch, \"stream-3\", \"chat-3\", time.Now().Add(30*time.Second))\n\n\tresult := ch.getStreamResponse(task, \"ts789\", \"nonce789\")\n\tif result == \"\" {\n\t\tt.Fatal(\"expected non-empty encrypted response\")\n\t}\n\n\tch.taskMu.RLock()\n\t_, exists := ch.streamTasks[\"stream-3\"]\n\tch.taskMu.RUnlock()\n\tif !exists {\n\t\tt.Error(\"pending task should still be in streamTasks\")\n\t}\n\tif task.Finished || task.StreamClosed {\n\t\tt.Error(\"pending task should not be finished or stream-closed\")\n\t}\n\t// Cleanup.\n\tch.removeTask(task)\n}\n"
  },
  {
    "path": "pkg/channels/wecom/aibot_ws.go",
    "content": "package wecom\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// Long-connection WebSocket endpoint.\n// Ref: https://developer.work.weixin.qq.com/document/path/101463\nconst (\n\twsEndpoint          = \"wss://openws.work.weixin.qq.com\"\n\twsHeartbeatInterval = 30 * time.Second\n\twsConnectTimeout    = 15 * time.Second\n\twsSubscribeTimeout  = 10 * time.Second\n\twsSendMsgTimeout    = 10 * time.Second\n\twsRespondMsgTimeout = 10 * time.Second\n\twsWelcomeMsgTimeout = 5 * time.Second // WeCom requires welcome reply within 5 seconds\n\twsMaxReconnectWait  = 60 * time.Second\n\twsInitialReconnect  = time.Second\n\n\t// WeCom requires finish=true within 6 minutes of the first stream frame.\n\t// wsStreamTickInterval controls how often we send an in-progress hint.\n\t// wsStreamMaxDuration is a safety margin below the 6-minute hard limit.\n\twsStreamTickInterval = 30 * time.Second\n\twsStreamMaxDuration  = 5*time.Minute + 30*time.Second\n\n\t// wsImageDownloadTimeout caps the time we spend downloading an inbound image.\n\twsImageDownloadTimeout = 30 * time.Second\n\n\t// Keep req_id -> chat route for late fallback pushes after stream window closes.\n\twsLateReplyRouteTTL = 30 * time.Minute\n\n\t// wsStreamMaxContentBytes is the maximum UTF-8 byte length for the content field\n\t// of a single WeCom AI Bot stream / text / markdown frame.\n\t// Ref: https://developer.work.weixin.qq.com/document/path/101463\n\twsStreamMaxContentBytes = 20480\n)\n\n// wsImageHTTPClient is a shared HTTP client for downloading inbound images.\n// Reusing it enables connection pooling across multiple image downloads.\nvar wsImageHTTPClient = &http.Client{Timeout: wsImageDownloadTimeout}\n\n// WeComAIBotWSChannel implements channels.Channel for WeCom AI Bot using the\n// WebSocket long-connection API.\n// Unlike the webhook counterpart it does NOT implement WebhookHandler, so the\n// HTTP manager will not register any callback URL for it.\ntype WeComAIBotWSChannel struct {\n\t*channels.BaseChannel\n\tconfig config.WeComAIBotConfig\n\tctx    context.Context\n\tcancel context.CancelFunc\n\n\t// conn is the active WebSocket connection; nil when disconnected.\n\t// All writes are serialized through connMu.\n\tconn   *websocket.Conn\n\tconnMu sync.Mutex\n\n\t// dedupe prevents duplicate message processing (WeCom may re-deliver).\n\tdedupe *MessageDeduplicator\n\n\t// reqStates holds per-req_id runtime state.\n\t// It unifies active task state and late-reply fallback routing.\n\treqStates   map[string]*wsReqState\n\treqStatesMu sync.Mutex\n\n\t// reqPending correlates command req_ids with response channels.\n\t// Used only for subscribe/ping command-response pairs.\n\treqPending   map[string]chan wsEnvelope\n\treqPendingMu sync.Mutex\n}\n\n// wsTask tracks one in-progress agent reply for a single chat turn.\ntype wsTask struct {\n\tReqID    string // req_id echoed in all replies for this turn\n\tChatID   string\n\tChatType uint32\n\tStreamID string      // our generated stream.id\n\tanswerCh chan string // agent delivers its reply here via Send()\n\tctx      context.Context\n\tcancel   context.CancelFunc\n}\n\ntype wsReqState struct {\n\tTask  *wsTask\n\tRoute wsLateReplyRoute\n}\n\ntype wsLateReplyRoute struct {\n\tChatID    string\n\tChatType  uint32\n\tReadyAt   time.Time\n\tExpiresAt time.Time\n}\n\n// ---- WebSocket protocol types ----\n\n// wsEnvelope is the generic JSON envelope for all WebSocket messages.\ntype wsEnvelope struct {\n\tCmd     string          `json:\"cmd,omitempty\"`\n\tHeaders wsHeaders       `json:\"headers\"`\n\tBody    json.RawMessage `json:\"body,omitempty\"`\n\tErrCode int             `json:\"errcode,omitempty\"`\n\tErrMsg  string          `json:\"errmsg,omitempty\"`\n}\n\ntype wsHeaders struct {\n\tReqID string `json:\"req_id\"`\n}\n\n// wsCommand is an outgoing request sent over the WebSocket.\ntype wsCommand struct {\n\tCmd     string    `json:\"cmd\"`\n\tHeaders wsHeaders `json:\"headers\"`\n\tBody    any       `json:\"body,omitempty\"`\n}\n\ntype wsSendMsgBody struct {\n\tChatID   string             `json:\"chatid\"`\n\tChatType uint32             `json:\"chat_type,omitempty\"`\n\tMsgType  string             `json:\"msgtype\"`\n\tMarkdown *wsMarkdownContent `json:\"markdown,omitempty\"`\n}\n\n// wsRespondMsgBody is the body for aibot_respond_msg / aibot_respond_welcome_msg.\ntype wsRespondMsgBody struct {\n\tMsgType  string             `json:\"msgtype\"`\n\tStream   *wsStreamContent   `json:\"stream,omitempty\"`\n\tText     *wsTextContent     `json:\"text,omitempty\"`\n\tMarkdown *wsMarkdownContent `json:\"markdown,omitempty\"`\n\tImage    *wsImageContent    `json:\"image,omitempty\"`\n}\n\ntype wsStreamContent struct {\n\tID      string `json:\"id\"`\n\tFinish  bool   `json:\"finish\"`\n\tContent string `json:\"content,omitempty\"`\n}\n\n// wsImageContent carries a base64-encoded image payload for outbound messages.\ntype wsImageContent struct {\n\tBase64 string `json:\"base64\"`\n\tMD5    string `json:\"md5\"`\n}\n\ntype wsTextContent struct {\n\tContent string `json:\"content\"`\n}\n\ntype wsMarkdownContent struct {\n\tContent string `json:\"content\"`\n}\n\n// WeComAIBotWSMessage is the decoded body of aibot_msg_callback /\n// aibot_event_callback in WebSocket long-connection mode.\n// The structure mirrors WeComAIBotMessage but includes extra fields\n// that only appear in long-connection callbacks (Voice, AESKey on Image/File).\ntype WeComAIBotWSMessage struct {\n\tMsgID      string `json:\"msgid\"`\n\tCreateTime int64  `json:\"create_time,omitempty\"`\n\tAIBotID    string `json:\"aibotid\"`\n\tChatID     string `json:\"chatid,omitempty\"`\n\tChatType   string `json:\"chattype,omitempty\"` // \"single\" | \"group\"\n\tFrom       struct {\n\t\tUserID string `json:\"userid\"`\n\t} `json:\"from\"`\n\tMsgType string `json:\"msgtype\"`\n\tText    *struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"text,omitempty\"`\n\tImage *struct {\n\t\tURL    string `json:\"url\"`\n\t\tAESKey string `json:\"aeskey,omitempty\"` // long-connection: per-resource decrypt key\n\t} `json:\"image,omitempty\"`\n\tVoice *struct {\n\t\tContent string `json:\"content\"` // WeCom transcribes voice to text in callbacks\n\t} `json:\"voice,omitempty\"`\n\tMixed *struct {\n\t\tMsgItem []struct {\n\t\t\tMsgType string `json:\"msgtype\"`\n\t\t\tText    *struct {\n\t\t\t\tContent string `json:\"content\"`\n\t\t\t} `json:\"text,omitempty\"`\n\t\t\tImage *struct {\n\t\t\t\tURL    string `json:\"url\"`\n\t\t\t\tAESKey string `json:\"aeskey,omitempty\"`\n\t\t\t} `json:\"image,omitempty\"`\n\t\t} `json:\"msg_item\"`\n\t} `json:\"mixed,omitempty\"`\n\tEvent *struct {\n\t\tEventType string `json:\"eventtype\"`\n\t} `json:\"event,omitempty\"`\n\tFile *struct {\n\t\tURL    string `json:\"url\"`\n\t\tAESKey string `json:\"aeskey,omitempty\"`\n\t} `json:\"file,omitempty\"`\n\tVideo *struct {\n\t\tURL    string `json:\"url\"`\n\t\tAESKey string `json:\"aeskey,omitempty\"`\n\t} `json:\"video,omitempty\"`\n}\n\n// ---- Constructor ----\n\n// newWeComAIBotWSChannel creates a WeComAIBotWSChannel for WebSocket mode.\nfunc newWeComAIBotWSChannel(\n\tcfg config.WeComAIBotConfig,\n\tmessageBus *bus.MessageBus,\n) (*WeComAIBotWSChannel, error) {\n\tif cfg.BotID == \"\" || cfg.Secret == \"\" {\n\t\treturn nil, fmt.Errorf(\"bot_id and secret are required for WeCom AI Bot WebSocket mode\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"wecom_aibot\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &WeComAIBotWSChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tdedupe:      NewMessageDeduplicator(wecomMaxProcessedMessages),\n\t\treqStates:   make(map[string]*wsReqState),\n\t\treqPending:  make(map[string]chan wsEnvelope),\n\t}, nil\n}\n\n// ---- Channel interface ----\n\n// Name implements channels.Channel.\nfunc (c *WeComAIBotWSChannel) Name() string { return \"wecom_aibot\" }\n\n// Start connects to the WeCom WebSocket endpoint and begins message processing.\nfunc (c *WeComAIBotWSChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"wecom_aibot\", \"Starting WeCom AI Bot channel (WebSocket long-connection mode)...\")\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\tc.SetRunning(true)\n\tgo c.connectLoop()\n\tlogger.InfoC(\"wecom_aibot\", \"WeCom AI Bot channel started (WebSocket mode)\")\n\treturn nil\n}\n\n// Stop shuts down the channel and closes the WebSocket connection.\nfunc (c *WeComAIBotWSChannel) Stop(_ context.Context) error {\n\tlogger.InfoC(\"wecom_aibot\", \"Stopping WeCom AI Bot channel (WebSocket mode)...\")\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tc.connMu.Lock()\n\tif c.conn != nil {\n\t\tc.conn.Close()\n\t\tc.conn = nil\n\t}\n\tc.connMu.Unlock()\n\tc.SetRunning(false)\n\tlogger.InfoC(\"wecom_aibot\", \"WeCom AI Bot channel stopped\")\n\treturn nil\n}\n\n// Send delivers the agent reply for msg.ChatID.\n// The waiting task goroutine picks it up and writes the final stream response.\nfunc (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// msg.ChatID carries the inbound req_id (set by dispatchWSAgentTask).\n\t// For cron-triggered messages, msg.ChatID is the real WeCom chat/user ID\n\t// and there will be no matching entry in reqStates; fall through to proactive push.\n\ttask, route, ok := c.getReqState(msg.ChatID)\n\tif !ok {\n\t\t// No req_id record found — this is a cron/scheduler-originated message.\n\t\t// Send it as a proactive markdown push using the chat ID directly.\n\t\tlogger.InfoCF(\"wecom_aibot\", \"Send: no req_id state, delivering via proactive push (cron/scheduler)\",\n\t\t\tmap[string]any{\"chat_id\": msg.ChatID})\n\t\tif err := c.wsSendActivePush(msg.ChatID, 0, msg.Content); err != nil {\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Proactive push failed\",\n\t\t\t\tmap[string]any{\"chat_id\": msg.ChatID, \"error\": err.Error()})\n\t\t\treturn fmt.Errorf(\"websocket delivery failed: %w\", channels.ErrSendFailed)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif task == nil {\n\t\tif time.Now().Before(route.ReadyAt) {\n\t\t\t// Keep using aibot_respond_msg within stream window; do not proactively\n\t\t\t// push unless wsStreamMaxDuration has elapsed.\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Send: stream window still open, skip proactive push\",\n\t\t\t\tmap[string]any{\"req_id\": msg.ChatID, \"ready_at\": route.ReadyAt.Format(time.RFC3339)})\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := c.wsSendActivePush(route.ChatID, route.ChatType, msg.Content); err != nil {\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Late reply proactive push failed\",\n\t\t\t\tmap[string]any{\"req_id\": msg.ChatID, \"chat_id\": route.ChatID, \"error\": err.Error()})\n\t\t\treturn fmt.Errorf(\"websocket delivery failed: %w\", channels.ErrSendFailed)\n\t\t}\n\t\tlogger.InfoCF(\"wecom_aibot\", \"Late reply delivered via proactive push\",\n\t\t\tmap[string]any{\"req_id\": msg.ChatID, \"chat_id\": route.ChatID, \"chat_type\": route.ChatType})\n\t\tc.deleteReqState(msg.ChatID)\n\t\treturn nil\n\t}\n\n\t// Non-blocking fast path: when answerCh has space, deliver without racing\n\t// against task.ctx.Done() (which fires when the task is canceled by a new\n\t// incoming message, but the response must still be sent).\n\tselect {\n\tcase task.answerCh <- msg.Content:\n\t\treturn nil\n\tdefault:\n\t}\n\t// answerCh was full; block with cancellation guards.\n\tselect {\n\tcase task.answerCh <- msg.Content:\n\tcase <-task.ctx.Done():\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n\treturn nil\n}\n\n// ---- Connection management ----\n\n// wsBackoffResetDuration is the minimum duration a WebSocket connection must\n// stay up before we reset the reconnect backoff to its initial value. This\n// prevents a short burst of failures from causing long waits after later,\n// stable connection periods.\nconst wsBackoffResetDuration = time.Minute\n\n// connectLoop maintains the WebSocket connection, reconnecting on failure with\n// exponential backoff.\nfunc (c *WeComAIBotWSChannel) connectLoop() {\n\tbackoff := wsInitialReconnect\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tlogger.InfoC(\"wecom_aibot\", \"Connecting to WeCom WebSocket endpoint...\")\n\t\tstart := time.Now()\n\t\tif err := c.runConnection(); err != nil {\n\t\t\telapsed := time.Since(start)\n\t\t\t// If the connection was stable for long enough, reset backoff so that\n\t\t\t// a previous burst of failures does not keep us at the maximum delay.\n\t\t\tif elapsed >= wsBackoffResetDuration {\n\t\t\t\tbackoff = wsInitialReconnect\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-c.ctx.Done():\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\tlogger.WarnCF(\"wecom_aibot\", \"WebSocket connection lost, reconnecting\",\n\t\t\t\t\tmap[string]any{\"error\": err.Error(), \"backoff\": backoff.String()})\n\t\t\t\tselect {\n\t\t\t\tcase <-time.After(backoff):\n\t\t\t\tcase <-c.ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif backoff < wsMaxReconnectWait {\n\t\t\t\t\tbackoff *= 2\n\t\t\t\t\tif backoff > wsMaxReconnectWait {\n\t\t\t\t\t\tbackoff = wsMaxReconnectWait\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// Clean exit (context canceled); stop reconnecting.\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// runConnection dials, subscribes, and runs the read/heartbeat loops until the\n// connection closes or the channel context is canceled.\nfunc (c *WeComAIBotWSChannel) runConnection() error {\n\tdialCtx, dialCancel := context.WithTimeout(c.ctx, wsConnectTimeout)\n\tconn, httpResp, err := websocket.DefaultDialer.DialContext(dialCtx, wsEndpoint, nil)\n\tdialCancel()\n\tif httpResp != nil {\n\t\thttpResp.Body.Close()\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"dial failed: %w\", err)\n\t}\n\n\tc.connMu.Lock()\n\tc.conn = conn\n\tc.connMu.Unlock()\n\n\tdefer func() {\n\t\tc.connMu.Lock()\n\t\tif c.conn == conn {\n\t\t\tc.conn = nil\n\t\t}\n\t\tc.connMu.Unlock()\n\t\t// Cancel any tasks that were started over this connection so their\n\t\t// agent goroutines do not keep running after the connection is gone.\n\t\tc.cancelAllTasks()\n\t}()\n\n\t// ---- Read loop (must start BEFORE subscribing) ----\n\t// sendAndWait blocks waiting for the subscribe response on reqPending;\n\t// readLoop is the only goroutine that delivers messages to reqPending.\n\t// Starting readLoop first avoids a deadlock where sendAndWait times out\n\t// because no one reads the server's reply.\n\treadErrCh := make(chan error, 1)\n\tgo func() { readErrCh <- c.readLoop(conn) }()\n\n\t// ---- Subscribe ----\n\treqID := wsGenerateID()\n\tresp, err := c.sendAndWait(conn, reqID, wsCommand{\n\t\tCmd:     \"aibot_subscribe\",\n\t\tHeaders: wsHeaders{ReqID: reqID},\n\t\tBody: map[string]string{\n\t\t\t\"bot_id\": c.config.BotID,\n\t\t\t\"secret\": c.config.Secret,\n\t\t},\n\t}, wsSubscribeTimeout)\n\tif err != nil {\n\t\tconn.Close() // stop readLoop\n\t\t<-readErrCh\n\t\treturn fmt.Errorf(\"subscribe failed: %w\", err)\n\t}\n\tif resp.ErrCode != 0 {\n\t\tconn.Close()\n\t\t<-readErrCh\n\t\treturn fmt.Errorf(\"subscribe rejected (errcode=%d): %s\", resp.ErrCode, resp.ErrMsg)\n\t}\n\n\tlogger.InfoC(\"wecom_aibot\", \"WebSocket subscription successful\")\n\n\t// ---- Heartbeat goroutine ----\n\thbDone := make(chan struct{})\n\tgo func() {\n\t\tdefer close(hbDone)\n\t\tc.heartbeatLoop(conn)\n\t}()\n\n\t// Wait for the read loop to exit, then tear down the heartbeat.\n\treadErr := <-readErrCh\n\tconn.Close() // signal heartbeat to stop (idempotent)\n\t<-hbDone\n\treturn readErr\n}\n\n// sendAndWait registers a pending-response slot, sends cmd, and blocks until\n// the matching response arrives or the timeout/context fires.\nfunc (c *WeComAIBotWSChannel) sendAndWait(\n\tconn *websocket.Conn,\n\treqID string,\n\tcmd wsCommand,\n\ttimeout time.Duration,\n) (wsEnvelope, error) {\n\tch := make(chan wsEnvelope, 1)\n\tc.reqPendingMu.Lock()\n\tc.reqPending[reqID] = ch\n\tc.reqPendingMu.Unlock()\n\n\tcleanup := func() {\n\t\tc.reqPendingMu.Lock()\n\t\tdelete(c.reqPending, reqID)\n\t\tc.reqPendingMu.Unlock()\n\t}\n\n\tdata, err := json.Marshal(cmd)\n\tif err != nil {\n\t\tcleanup()\n\t\treturn wsEnvelope{}, fmt.Errorf(\"marshal command: %w\", err)\n\t}\n\tc.connMu.Lock()\n\terr = conn.WriteMessage(websocket.TextMessage, data)\n\tc.connMu.Unlock()\n\tif err != nil {\n\t\tcleanup()\n\t\treturn wsEnvelope{}, fmt.Errorf(\"write command: %w\", err)\n\t}\n\n\ttimer := time.NewTimer(timeout)\n\tdefer timer.Stop()\n\tselect {\n\tcase env := <-ch:\n\t\treturn env, nil\n\tcase <-timer.C:\n\t\tcleanup()\n\t\treturn wsEnvelope{}, fmt.Errorf(\"timeout waiting for response (req_id=%s)\", reqID)\n\tcase <-c.ctx.Done():\n\t\tcleanup()\n\t\treturn wsEnvelope{}, c.ctx.Err()\n\t}\n}\n\n// heartbeatLoop sends a ping every wsHeartbeatInterval until conn is closed.\n// It validates the server's pong response via sendAndWait; a failed pong\n// triggers a reconnection by closing the connection.\nfunc (c *WeComAIBotWSChannel) heartbeatLoop(conn *websocket.Conn) {\n\tticker := time.NewTicker(wsHeartbeatInterval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\treqID := wsGenerateID()\n\t\t\tresp, err := c.sendAndWait(conn, reqID, wsCommand{\n\t\t\t\tCmd:     \"ping\",\n\t\t\t\tHeaders: wsHeaders{ReqID: reqID},\n\t\t\t}, wsHeartbeatInterval)\n\t\t\tif err != nil {\n\t\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Heartbeat failed, closing connection\",\n\t\t\t\t\tmap[string]any{\"error\": err.Error()})\n\t\t\t\tconn.Close()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif resp.ErrCode != 0 {\n\t\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Heartbeat rejected\",\n\t\t\t\t\tmap[string]any{\"errcode\": resp.ErrCode, \"errmsg\": resp.ErrMsg})\n\t\t\t\tconn.Close()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogger.DebugCF(\"wecom_aibot\", \"Heartbeat pong received\", map[string]any{\"req_id\": reqID})\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// readLoop reads WebSocket messages and dispatches them until the connection\n// closes or the channel is stopped.\nfunc (c *WeComAIBotWSChannel) readLoop(conn *websocket.Conn) error {\n\tfor {\n\t\t_, raw, err := conn.ReadMessage()\n\t\tif err != nil {\n\t\t\tselect {\n\t\t\tcase <-c.ctx.Done():\n\t\t\t\treturn nil // clean shutdown\n\t\t\tdefault:\n\t\t\t\treturn fmt.Errorf(\"read error: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tvar env wsEnvelope\n\t\tif err := json.Unmarshal(raw, &env); err != nil {\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Failed to parse WebSocket message\",\n\t\t\t\tmap[string]any{\"error\": err.Error(), \"raw\": string(raw)})\n\t\t\tcontinue\n\t\t}\n\n\t\t// Command responses have an empty Cmd field; forward to any waiting\n\t\t// sendAndWait() call, or silently drop if no one is waiting (e.g.\n\t\t// late responses after timeout).\n\t\tif env.Cmd == \"\" && env.Headers.ReqID != \"\" {\n\t\t\tc.reqPendingMu.Lock()\n\t\t\tch, ok := c.reqPending[env.Headers.ReqID]\n\t\t\tif ok {\n\t\t\t\tdelete(c.reqPending, env.Headers.ReqID)\n\t\t\t}\n\t\t\tc.reqPendingMu.Unlock()\n\t\t\tif ok {\n\t\t\t\tch <- env\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Dispatch to appropriate handler in a separate goroutine so the\n\t\t// read loop is never blocked by a slow agent.\n\t\tgo c.handleEnvelope(env)\n\t}\n}\n\n// ---- Message / event handlers ----\n\n// handleEnvelope routes a WebSocket envelope to the right handler.\nfunc (c *WeComAIBotWSChannel) handleEnvelope(env wsEnvelope) {\n\tswitch env.Cmd {\n\tcase \"aibot_msg_callback\":\n\t\tc.handleMsgCallback(env)\n\tcase \"aibot_event_callback\":\n\t\tc.handleEventCallback(env)\n\tdefault:\n\t\tlogger.DebugCF(\"wecom_aibot\", \"Unhandled WebSocket command\",\n\t\t\tmap[string]any{\"cmd\": env.Cmd})\n\t}\n}\n\n// handleMsgCallback processes aibot_msg_callback.\nfunc (c *WeComAIBotWSChannel) handleMsgCallback(env wsEnvelope) {\n\tvar msg WeComAIBotWSMessage\n\tif err := json.Unmarshal(env.Body, &msg); err != nil {\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Failed to parse msg callback body\",\n\t\t\tmap[string]any{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Deduplicate by msgid (WeCom may re-deliver on network issues).\n\tif msg.MsgID != \"\" && !c.dedupe.MarkMessageProcessed(msg.MsgID) {\n\t\tlogger.DebugCF(\"wecom_aibot\", \"Duplicate message ignored\",\n\t\t\tmap[string]any{\"msgid\": msg.MsgID})\n\t\treturn\n\t}\n\n\treqID := env.Headers.ReqID\n\tswitch msg.MsgType {\n\tcase \"text\":\n\t\tc.handleWSTextMessage(reqID, msg)\n\tcase \"image\":\n\t\tc.handleWSImageMessage(reqID, msg)\n\tcase \"voice\":\n\t\tc.handleWSVoiceMessage(reqID, msg)\n\tcase \"mixed\":\n\t\tc.handleWSMixedMessage(reqID, msg)\n\tcase \"file\":\n\t\tc.handleWSFileMessage(reqID, msg)\n\tcase \"video\":\n\t\tc.handleWSVideoMessage(reqID, msg)\n\tdefault:\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Unsupported message type\",\n\t\t\tmap[string]any{\"msgtype\": msg.MsgType})\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(),\n\t\t\t\"Unsupported message type: \"+msg.MsgType)\n\t}\n}\n\n// handleEventCallback processes aibot_event_callback.\nfunc (c *WeComAIBotWSChannel) handleEventCallback(env wsEnvelope) {\n\tvar msg WeComAIBotWSMessage\n\tif err := json.Unmarshal(env.Body, &msg); err != nil {\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Failed to parse event callback body\",\n\t\t\tmap[string]any{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\t// Deduplicate by msgid.\n\tif msg.MsgID != \"\" && !c.dedupe.MarkMessageProcessed(msg.MsgID) {\n\t\tlogger.DebugCF(\"wecom_aibot\", \"Duplicate event ignored\",\n\t\t\tmap[string]any{\"msgid\": msg.MsgID})\n\t\treturn\n\t}\n\n\tvar eventType string\n\tif msg.Event != nil {\n\t\teventType = msg.Event.EventType\n\t}\n\tlogger.DebugCF(\"wecom_aibot\", \"Received event callback\",\n\t\tmap[string]any{\"event_type\": eventType})\n\n\tswitch eventType {\n\tcase \"enter_chat\":\n\t\tif c.config.WelcomeMessage != \"\" {\n\t\t\tc.wsSendWelcomeMsg(env.Headers.ReqID, c.config.WelcomeMessage)\n\t\t}\n\tcase \"disconnected_event\":\n\t\t// The server will close this connection after sending this event.\n\t\t// connectLoop will detect the closure and reconnect automatically.\n\t\tlogger.WarnC(\"wecom_aibot\",\n\t\t\t\"Received disconnected_event: this connection is being replaced by a newer one\")\n\tdefault:\n\t\tlogger.DebugCF(\"wecom_aibot\", \"Unhandled event type\",\n\t\t\tmap[string]any{\"event_type\": eventType})\n\t}\n}\n\n// handleWSTextMessage dispatches a plain-text message to the agent and streams\n// the reply back over the WebSocket connection.\nfunc (c *WeComAIBotWSChannel) handleWSTextMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.Text == nil {\n\t\tlogger.ErrorC(\"wecom_aibot\", \"text message missing text field\")\n\t\treturn\n\t}\n\tc.dispatchWSAgentTask(reqID, msg, msg.Text.Content, nil)\n}\n\n// handleWSImageMessage downloads and stores the inbound image, then dispatches\n// it to the agent as a media-tagged message.\nfunc (c *WeComAIBotWSChannel) handleWSImageMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.Image == nil {\n\t\tlogger.WarnC(\"wecom_aibot\", \"Image message missing image field\")\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"Image message could not be processed.\")\n\t\treturn\n\t}\n\tc.wsHandleMediaMessage(reqID, msg, msg.Image.URL, msg.Image.AESKey, \"image\")\n}\n\n// wsHandleMediaMessage is a shared helper for image, file and video messages.\n// It downloads the resource, stores it in MediaStore, and dispatches to the agent.\nfunc (c *WeComAIBotWSChannel) wsHandleMediaMessage(\n\treqID string, msg WeComAIBotWSMessage,\n\tresourceURL, aesKey, label string,\n) {\n\tchatID := wsChatID(msg)\n\n\tctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout)\n\tdefer cancel()\n\n\tref, err := c.storeWSMedia(ctx, chatID, msg.MsgID, resourceURL, aesKey, wsLabelToDefaultExt(label))\n\tif err != nil {\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Failed to download/store WS \"+label,\n\t\t\tmap[string]any{\"error\": err.Error(), \"url\": resourceURL})\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(),\n\t\t\tstrings.ToUpper(label[:1])+label[1:]+\" message could not be processed.\")\n\t\treturn\n\t}\n\n\tc.dispatchWSAgentTask(reqID, msg, \"[\"+label+\"]\", []string{ref})\n}\n\n// handleWSMixedMessage handles mixed text+image messages.\n// All text parts are collected into the content string; all image parts are\n// downloaded and stored in MediaStore before dispatching to the agent.\nfunc (c *WeComAIBotWSChannel) handleWSMixedMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.Mixed == nil {\n\t\tlogger.WarnC(\"wecom_aibot\", \"Mixed message has no content\")\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"Mixed message type is not yet fully supported.\")\n\t\treturn\n\t}\n\n\tchatID := wsChatID(msg)\n\n\tctx, cancel := context.WithTimeout(c.ctx, wsImageDownloadTimeout)\n\tdefer cancel()\n\n\tvar textParts []string\n\tvar mediaRefs []string\n\tfor _, item := range msg.Mixed.MsgItem {\n\t\tswitch item.MsgType {\n\t\tcase \"text\":\n\t\t\tif item.Text != nil && item.Text.Content != \"\" {\n\t\t\t\ttextParts = append(textParts, item.Text.Content)\n\t\t\t}\n\t\tcase \"image\":\n\t\t\tif item.Image != nil {\n\t\t\t\tref, err := c.storeWSMedia(ctx, chatID,\n\t\t\t\t\tmsg.MsgID+\"-\"+wsGenerateID(), item.Image.URL, item.Image.AESKey, \".jpg\")\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Failed to download/store mixed image\",\n\t\t\t\t\t\tmap[string]any{\"error\": err.Error()})\n\t\t\t\t} else {\n\t\t\t\t\tmediaRefs = append(mediaRefs, ref)\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\tlogger.WarnCF(\"wecom_aibot\", \"Unsupported item type in mixed message\",\n\t\t\t\tmap[string]any{\"msgtype\": item.MsgType})\n\t\t}\n\t}\n\n\tif len(textParts) == 0 && len(mediaRefs) == 0 {\n\t\tlogger.WarnC(\"wecom_aibot\", \"Mixed message has no usable content\")\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"Mixed message type is not yet fully supported.\")\n\t\treturn\n\t}\n\n\tcontent := strings.Join(textParts, \"\\n\")\n\tif content == \"\" {\n\t\tcontent = \"[images]\"\n\t}\n\tc.dispatchWSAgentTask(reqID, msg, content, mediaRefs)\n}\n\n// dispatchWSAgentTask registers a new agent task, sends the opening stream frame,\n// and starts a goroutine that runs the agent and streams the reply back.\n// content is the text forwarded to the agent; mediaRefs are optional media\n// store references attached to the inbound message.\nfunc (c *WeComAIBotWSChannel) dispatchWSAgentTask(\n\treqID string,\n\tmsg WeComAIBotWSMessage,\n\tcontent string,\n\tmediaRefs []string,\n) {\n\tuserID := msg.From.UserID\n\tif userID == \"\" {\n\t\tuserID = \"unknown\"\n\t}\n\t// actualChatID is the real WeCom chat/user ID used for peer identification.\n\t// reqID is used as the routing chatID so each turn is independently addressable.\n\tactualChatID := wsChatID(msg)\n\n\tstreamID := wsGenerateID()\n\tchatType := wsChatTypeValue(msg.ChatType)\n\ttaskCtx, taskCancel := context.WithCancel(c.ctx)\n\n\ttask := &wsTask{\n\t\tReqID:    reqID,\n\t\tChatID:   actualChatID,\n\t\tChatType: chatType,\n\t\tStreamID: streamID,\n\t\tanswerCh: make(chan string, 1),\n\t\tctx:      taskCtx,\n\t\tcancel:   taskCancel,\n\t}\n\t// Each req_id is unique per WeCom turn; tasks run concurrently, no cancellation.\n\tc.setReqState(reqID, &wsReqState{\n\t\tTask: task,\n\t\tRoute: wsLateReplyRoute{\n\t\t\tChatID:    actualChatID,\n\t\t\tChatType:  chatType,\n\t\t\tReadyAt:   time.Now().Add(wsStreamMaxDuration),\n\t\t\tExpiresAt: time.Now().Add(wsLateReplyRouteTTL),\n\t\t},\n\t})\n\n\tlogger.DebugCF(\"wecom_aibot\", \"Registered new agent task\",\n\t\tmap[string]any{\"chat_id\": actualChatID, \"req_id\": reqID, \"stream_id\": streamID})\n\n\t// Send an empty stream opening frame (finish=false) immediately.\n\tc.wsSendStreamChunk(reqID, streamID, false, \"\")\n\n\tgo func() {\n\t\tdefer func() {\n\t\t\ttaskCancel()\n\t\t\tc.clearReqTask(reqID, task)\n\t\t}()\n\n\t\tsender := bus.SenderInfo{\n\t\t\tPlatform:    \"wecom_aibot\",\n\t\t\tPlatformID:  userID,\n\t\t\tCanonicalID: identity.BuildCanonicalID(\"wecom_aibot\", userID),\n\t\t\tDisplayName: userID,\n\t\t}\n\t\tpeerKind := \"direct\"\n\t\tif msg.ChatType == \"group\" {\n\t\t\tpeerKind = \"group\"\n\t\t}\n\t\tpeer := bus.Peer{Kind: peerKind, ID: actualChatID}\n\t\tmetadata := map[string]string{\n\t\t\t\"channel\":   \"wecom_aibot\",\n\t\t\t\"chat_id\":   actualChatID,\n\t\t\t\"chat_type\": msg.ChatType,\n\t\t\t\"msg_type\":  msg.MsgType,\n\t\t\t\"msgid\":     msg.MsgID,\n\t\t\t\"aibotid\":   msg.AIBotID,\n\t\t\t\"stream_id\": streamID,\n\t\t}\n\t\t// Pass reqID as chatID: OutboundMessage.ChatID = reqID → Send() finds tasks[reqID].\n\t\tc.HandleMessage(taskCtx, peer, reqID, userID, reqID,\n\t\t\tcontent, mediaRefs, metadata, sender)\n\n\t\t// Wait for the agent reply. While waiting, send periodic finish=false\n\t\t// hints so the user knows processing is still in progress.\n\t\t// WeCom requires finish=true within 6 minutes of the first stream frame;\n\t\t// wsStreamMaxDuration enforces that limit with a safety margin.\n\t\twaitHints := []string{\n\t\t\t\"⏳ Processing, please wait...\",\n\t\t\t\"⏳ Still processing, please wait...\",\n\t\t\t\"⏳ Almost there, please wait...\",\n\t\t}\n\t\tticker := time.NewTicker(wsStreamTickInterval)\n\t\tdefer ticker.Stop()\n\t\tdeadlineTimer := time.NewTimer(wsStreamMaxDuration)\n\t\tdefer deadlineTimer.Stop()\n\t\ttickCount := 0\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase answer := <-task.answerCh:\n\t\t\t\t// Split the answer into byte-bounded chunks and send as stream frames.\n\t\t\t\t// All but the last carry finish=false; the final frame closes the stream.\n\t\t\t\tchunks := splitWSContent(answer, wsStreamMaxContentBytes)\n\t\t\t\tfor i, chunk := range chunks {\n\t\t\t\t\tc.wsSendStreamChunk(reqID, streamID, i == len(chunks)-1, chunk)\n\t\t\t\t}\n\t\t\t\tc.deleteReqState(reqID)\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\thint := waitHints[tickCount%len(waitHints)]\n\t\t\t\ttickCount++\n\t\t\t\tlogger.DebugCF(\"wecom_aibot\", \"Sending stream progress hint\",\n\t\t\t\t\tmap[string]any{\"chat_id\": actualChatID, \"tick\": tickCount})\n\t\t\t\tc.wsSendStreamChunk(reqID, streamID, false, hint)\n\t\t\tcase <-deadlineTimer.C:\n\t\t\t\tlogger.WarnCF(\"wecom_aibot\",\n\t\t\t\t\t\"Stream response deadline reached, closing stream; late reply will be pushed\",\n\t\t\t\t\tmap[string]any{\"chat_id\": actualChatID})\n\t\t\t\tc.wsSendStreamFinish(reqID, streamID,\n\t\t\t\t\t\"⏳ Processing is taking longer than expected, the response will be sent as a follow-up message.\")\n\t\t\t\treturn\n\t\t\tcase <-taskCtx.Done():\n\t\t\t\t// Give a short grace period so that a response queued in the bus\n\t\t\t\t// just before cancellation can still be delivered.  This closes a\n\t\t\t\t// race where a rapid second message cancels this task after the\n\t\t\t\t// agent already published but before Send() wrote to answerCh.\n\t\t\t\t//\n\t\t\t\t// The connection is gone at this point, so we cannot use\n\t\t\t\t// wsSendStreamFinish.  Try wsSendActivePush on the (possibly\n\t\t\t\t// already-restored) connection; if that also fails, leave the\n\t\t\t\t// route intact so Send() can push the reply once reconnected.\n\t\t\t\tselect {\n\t\t\t\tcase answer := <-task.answerCh:\n\t\t\t\t\tif err := c.wsSendActivePush(task.ChatID, task.ChatType, answer); err != nil {\n\t\t\t\t\t\tlogger.WarnCF(\"wecom_aibot\",\n\t\t\t\t\t\t\t\"Grace-period push failed after task cancellation; reply may be lost\",\n\t\t\t\t\t\t\tmap[string]any{\"req_id\": reqID, \"chat_id\": task.ChatID, \"error\": err.Error()})\n\t\t\t\t\t} else {\n\t\t\t\t\t\tc.deleteReqState(reqID)\n\t\t\t\t\t}\n\t\t\t\tcase <-time.After(100 * time.Millisecond):\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// handleWSVoiceMessage handles voice messages.\n// WeCom transcribes voice to text in the callback; if the transcription is\n// present it is dispatched as plain text to the agent.\nfunc (c *WeComAIBotWSChannel) handleWSVoiceMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.Voice != nil && msg.Voice.Content != \"\" {\n\t\tc.dispatchWSAgentTask(reqID, msg, msg.Voice.Content, nil)\n\t\treturn\n\t}\n\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"Voice messages are not yet supported.\")\n}\n\n// handleWSFileMessage handles file messages.\nfunc (c *WeComAIBotWSChannel) handleWSFileMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.File == nil {\n\t\tlogger.WarnC(\"wecom_aibot\", \"File message missing file field\")\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"File message could not be processed.\")\n\t\treturn\n\t}\n\tc.wsHandleMediaMessage(reqID, msg, msg.File.URL, msg.File.AESKey, \"file\")\n}\n\n// handleWSVideoMessage handles video messages.\nfunc (c *WeComAIBotWSChannel) handleWSVideoMessage(reqID string, msg WeComAIBotWSMessage) {\n\tif msg.Video == nil {\n\t\tlogger.WarnC(\"wecom_aibot\", \"Video message missing video field\")\n\t\tc.wsSendStreamFinish(reqID, wsGenerateID(), \"Video message could not be processed.\")\n\t\treturn\n\t}\n\tc.wsHandleMediaMessage(reqID, msg, msg.Video.URL, msg.Video.AESKey, \"video\")\n}\n\n// ---- WebSocket write helpers ----\n\n// wsSendStreamChunk sends an aibot_respond_msg stream frame.\nfunc (c *WeComAIBotWSChannel) wsSendStreamChunk(reqID, streamID string, finish bool, content string) {\n\tlogger.DebugCF(\"wecom_aibot\", \"Sending stream chunk\", map[string]any{\n\t\t\"stream_id\": streamID,\n\t\t\"finish\":    finish,\n\t\t\"preview\":   utils.Truncate(content, 100),\n\t})\n\tcmd := wsCommand{\n\t\tCmd:     \"aibot_respond_msg\",\n\t\tHeaders: wsHeaders{ReqID: reqID},\n\t\tBody: wsRespondMsgBody{\n\t\t\tMsgType: \"stream\",\n\t\t\tStream: &wsStreamContent{\n\t\t\t\tID:      streamID,\n\t\t\t\tFinish:  finish,\n\t\t\t\tContent: content,\n\t\t\t},\n\t\t},\n\t}\n\tif err := c.writeWSAndWait(cmd, wsRespondMsgTimeout); err != nil {\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Stream chunk ack failed\", map[string]any{\n\t\t\t\"req_id\":    reqID,\n\t\t\t\"stream_id\": streamID,\n\t\t\t\"finish\":    finish,\n\t\t\t\"error\":     err,\n\t\t})\n\t}\n}\n\n// wsSendStreamFinish sends the final aibot_respond_msg frame (finish=true, no images).\nfunc (c *WeComAIBotWSChannel) wsSendStreamFinish(reqID, streamID, content string) {\n\tc.wsSendStreamChunk(reqID, streamID, true, content)\n}\n\n// wsSendWelcomeMsg sends a text welcome message via aibot_respond_welcome_msg.\nfunc (c *WeComAIBotWSChannel) wsSendWelcomeMsg(reqID, content string) {\n\tlogger.DebugCF(\"wecom_aibot\", \"Sending welcome message\", map[string]any{\"req_id\": reqID})\n\tcmd := wsCommand{\n\t\tCmd:     \"aibot_respond_welcome_msg\",\n\t\tHeaders: wsHeaders{ReqID: reqID},\n\t\tBody: wsRespondMsgBody{\n\t\t\tMsgType: \"text\",\n\t\t\tText:    &wsTextContent{Content: content},\n\t\t},\n\t}\n\tif err := c.writeWSAndWait(cmd, wsWelcomeMsgTimeout); err != nil {\n\t\tlogger.WarnCF(\"wecom_aibot\", \"Welcome message ack failed\",\n\t\t\tmap[string]any{\"req_id\": reqID, \"error\": err.Error()})\n\t}\n}\n\n// wsSendActivePush sends a proactive markdown message using aibot_send_msg.\n// Long content is automatically split into byte-bounded chunks (≤ wsStreamMaxContentBytes\n// each) and delivered as consecutive messages.\n// It is used as a fallback for late replies after stream response window expires.\nfunc (c *WeComAIBotWSChannel) wsSendActivePush(chatID string, chatType uint32, content string) error {\n\tif chatID == \"\" {\n\t\treturn fmt.Errorf(\"chatid is empty\")\n\t}\n\tfor _, chunk := range splitWSContent(content, wsStreamMaxContentBytes) {\n\t\treqID := wsGenerateID()\n\t\tif err := c.writeWSAndWait(wsCommand{\n\t\t\tCmd:     \"aibot_send_msg\",\n\t\t\tHeaders: wsHeaders{ReqID: reqID},\n\t\t\tBody: wsSendMsgBody{\n\t\t\t\tChatID:   chatID,\n\t\t\t\tChatType: chatType,\n\t\t\t\tMsgType:  \"markdown\",\n\t\t\t\tMarkdown: &wsMarkdownContent{Content: chunk},\n\t\t\t},\n\t\t}, wsSendMsgTimeout); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// writeWSAndWait writes cmd to the active connection and validates the command response.\nfunc (c *WeComAIBotWSChannel) writeWSAndWait(cmd wsCommand, timeout time.Duration) error {\n\tif cmd.Headers.ReqID == \"\" {\n\t\treturn fmt.Errorf(\"req_id is empty\")\n\t}\n\n\tc.connMu.Lock()\n\tconn := c.conn\n\tc.connMu.Unlock()\n\tif conn == nil {\n\t\treturn fmt.Errorf(\"websocket not connected\")\n\t}\n\n\tresp, err := c.sendAndWait(conn, cmd.Headers.ReqID, cmd, timeout)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif resp.ErrCode != 0 {\n\t\treturn fmt.Errorf(\"%s rejected (errcode=%d): %s\", cmd.Cmd, resp.ErrCode, resp.ErrMsg)\n\t}\n\treturn nil\n}\n\n// cancelAllTasks cancels every pending agent task; called when the connection drops.\n// It also expires each task's stream window (ReadyAt = now) so that when the agent\n// eventually delivers its reply via Send(), the message is forwarded via\n// wsSendActivePush on the restored connection instead of being silently discarded.\nfunc (c *WeComAIBotWSChannel) cancelAllTasks() {\n\tc.reqStatesMu.Lock()\n\tdefer c.reqStatesMu.Unlock()\n\tnow := time.Now()\n\tfor _, state := range c.reqStates {\n\t\tif state != nil && state.Task != nil {\n\t\t\tstate.Task.cancel()\n\t\t\tstate.Task = nil\n\t\t\t// Expire the stream window immediately so Send() uses wsSendActivePush.\n\t\t\tstate.Route.ReadyAt = now\n\t\t}\n\t}\n}\n\nfunc (c *WeComAIBotWSChannel) setReqState(reqID string, state *wsReqState) {\n\tc.reqStatesMu.Lock()\n\tdefer c.reqStatesMu.Unlock()\n\tnow := time.Now()\n\tfor k, v := range c.reqStates {\n\t\tif v == nil || now.After(v.Route.ExpiresAt) {\n\t\t\tdelete(c.reqStates, k)\n\t\t}\n\t}\n\tc.reqStates[reqID] = state\n}\n\nfunc (c *WeComAIBotWSChannel) getReqState(reqID string) (*wsTask, wsLateReplyRoute, bool) {\n\tc.reqStatesMu.Lock()\n\tdefer c.reqStatesMu.Unlock()\n\tstate, ok := c.reqStates[reqID]\n\tif !ok || state == nil {\n\t\treturn nil, wsLateReplyRoute{}, false\n\t}\n\tif time.Now().After(state.Route.ExpiresAt) {\n\t\tdelete(c.reqStates, reqID)\n\t\treturn nil, wsLateReplyRoute{}, false\n\t}\n\treturn state.Task, state.Route, true\n}\n\nfunc (c *WeComAIBotWSChannel) deleteReqState(reqID string) {\n\tc.reqStatesMu.Lock()\n\tdelete(c.reqStates, reqID)\n\tc.reqStatesMu.Unlock()\n}\n\nfunc (c *WeComAIBotWSChannel) clearReqTask(reqID string, task *wsTask) {\n\tc.reqStatesMu.Lock()\n\tdefer c.reqStatesMu.Unlock()\n\tstate, ok := c.reqStates[reqID]\n\tif !ok || state == nil {\n\t\treturn\n\t}\n\tif state.Task == task {\n\t\tstate.Task = nil\n\t}\n}\n\nfunc wsChatTypeValue(chatType string) uint32 {\n\tif chatType == \"group\" {\n\t\treturn 2\n\t}\n\treturn 1\n}\n\n// wsChatID returns the effective chat ID from a WS message.\n// For group messages it is msg.ChatID; for single chats it falls back to the sender's UserID.\nfunc wsChatID(msg WeComAIBotWSMessage) string {\n\tif msg.ChatID != \"\" {\n\t\treturn msg.ChatID\n\t}\n\treturn msg.From.UserID\n}\n\n// wsGenerateID generates a random 10-character alphanumeric ID.\n// It is package-level (not a method) so it can be shared by both channel modes.\nfunc wsGenerateID() string {\n\treturn generateRandomID(10)\n}\n\n// ---- Inbound media download helpers ----\n\n// storeWSMedia downloads the resource at resourceURL (with optional AES-CBC\n// decryption) and stores it in the MediaStore. The file extension is inferred\n// from the HTTP Content-Type response header; defaultExt is used as a fallback\n// when the content type is absent or unrecognized.\nfunc (c *WeComAIBotWSChannel) storeWSMedia(\n\tctx context.Context,\n\tchatID, msgID, resourceURL, aesKey, defaultExt string,\n) (string, error) {\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn \"\", fmt.Errorf(\"no media store available\")\n\t}\n\n\tconst maxSize = 20 << 20 // 20 MB\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create request: %w\", err)\n\t}\n\tresp, err := wsImageHTTPClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"download: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"download HTTP %d\", resp.StatusCode)\n\t}\n\n\t// Infer file extension from the Content-Type response header.\n\text := wsMediaExtFromContentType(resp.Header.Get(\"Content-Type\"))\n\tif ext == \"\" {\n\t\text = defaultExt\n\t}\n\n\t// Buffer the media in memory, bounded to maxSize.\n\tdata, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxSize)+1))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"read media: %w\", err)\n\t}\n\tif len(data) > maxSize {\n\t\treturn \"\", fmt.Errorf(\"media too large (> %d MB)\", maxSize>>20)\n\t}\n\n\t// AES-CBC decryption if a key is present.\n\tif aesKey != \"\" {\n\t\tkey, decErr := base64.StdEncoding.DecodeString(aesKey)\n\t\tif decErr != nil || len(key) != 32 {\n\t\t\tkey, decErr = decodeWeComAESKey(aesKey)\n\t\t\tif decErr != nil {\n\t\t\t\treturn \"\", fmt.Errorf(\"decode media AES key: %w\", decErr)\n\t\t\t}\n\t\t}\n\t\tdata, err = decryptAESCBC(key, data)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"decrypt media: %w\", err)\n\t\t}\n\t}\n\n\t// Write to a temp file. The file is owned by the MediaStore and deleted by\n\t// store.ReleaseAll — no caller-side cleanup needed.\n\tmediaDir := filepath.Join(os.TempDir(), \"picoclaw_media\")\n\tif err = os.MkdirAll(mediaDir, 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"mkdir: %w\", err)\n\t}\n\ttmpFile, err := os.CreateTemp(mediaDir, msgID+\"-*\"+ext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"create temp file: %w\", err)\n\t}\n\ttmpPath := tmpFile.Name()\n\t_, writeErr := tmpFile.Write(data)\n\tcloseErr := tmpFile.Close()\n\tif writeErr != nil {\n\t\tos.Remove(tmpPath)\n\t\treturn \"\", fmt.Errorf(\"write media: %w\", writeErr)\n\t}\n\tif closeErr != nil {\n\t\tos.Remove(tmpPath)\n\t\treturn \"\", fmt.Errorf(\"close media: %w\", closeErr)\n\t}\n\n\tscope := channels.BuildMediaScope(\"wecom_aibot\", chatID, msgID)\n\tref, err := store.Store(tmpPath, media.MediaMeta{\n\t\tFilename: msgID + ext,\n\t\tSource:   \"wecom_aibot\",\n\t}, scope)\n\tif err != nil {\n\t\tos.Remove(tmpPath)\n\t\treturn \"\", fmt.Errorf(\"store: %w\", err)\n\t}\n\treturn ref, nil\n}\n\n// wsMediaExtFromContentType returns the lowercase file extension (with leading\n// dot) for the given Content-Type value, or \"\" when the type is unrecognized.\nfunc wsMediaExtFromContentType(contentType string) string {\n\tif contentType == \"\" {\n\t\treturn \"\"\n\t}\n\t// Strip parameters (e.g. \"image/jpeg; charset=utf-8\" → \"image/jpeg\").\n\tmt := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, \";\", 2)[0]))\n\tswitch mt {\n\tcase \"image/jpeg\", \"image/jpg\":\n\t\treturn \".jpg\"\n\tcase \"image/png\":\n\t\treturn \".png\"\n\tcase \"image/gif\":\n\t\treturn \".gif\"\n\tcase \"image/webp\":\n\t\treturn \".webp\"\n\tcase \"video/mp4\":\n\t\treturn \".mp4\"\n\tcase \"video/mpeg\", \"video/x-mpeg\":\n\t\treturn \".mpeg\"\n\tcase \"video/quicktime\":\n\t\treturn \".mov\"\n\tcase \"video/webm\":\n\t\treturn \".webm\"\n\tcase \"audio/mpeg\", \"audio/mp3\":\n\t\treturn \".mp3\"\n\tcase \"audio/ogg\":\n\t\treturn \".ogg\"\n\tcase \"audio/wav\":\n\t\treturn \".wav\"\n\tcase \"application/pdf\":\n\t\treturn \".pdf\"\n\tcase \"application/zip\":\n\t\treturn \".zip\"\n\tcase \"application/x-rar-compressed\", \"application/vnd.rar\":\n\t\treturn \".rar\"\n\tcase \"text/plain\":\n\t\treturn \".txt\"\n\tcase \"application/msword\":\n\t\treturn \".doc\"\n\tcase \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":\n\t\treturn \".docx\"\n\tcase \"application/vnd.ms-excel\":\n\t\treturn \".xls\"\n\tcase \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\":\n\t\treturn \".xlsx\"\n\tcase \"application/vnd.ms-powerpoint\":\n\t\treturn \".ppt\"\n\tcase \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n\t\treturn \".pptx\"\n\t}\n\treturn \"\"\n}\n\n// wsLabelToDefaultExt returns the default file extension for the given media label\n// used in wsHandleMediaMessage. It is the fallback when Content-Type detection fails.\nfunc wsLabelToDefaultExt(label string) string {\n\tswitch label {\n\tcase \"image\":\n\t\treturn \".jpg\"\n\tcase \"video\":\n\t\treturn \".mp4\"\n\tdefault: // \"file\" and any future labels\n\t\treturn \".bin\"\n\t}\n}\n\n// ---- Content length helpers ----\n\n// splitWSContent splits content into chunks each fitting within maxBytes UTF-8\n// bytes, preserving code block integrity via channels.SplitMessage.\n// When SplitMessage still produces an oversized chunk (e.g. dense CJK content),\n// splitAtByteBoundary is applied as a last-resort byte-level fallback.\nfunc splitWSContent(content string, maxBytes int) []string {\n\tif len(content) <= maxBytes {\n\t\treturn []string{content}\n\t}\n\t// SplitMessage works in runes. Use maxBytes as the rune limit: for pure ASCII\n\t// this is exact; for multibyte content the byte verification below catches\n\t// any chunk that still overflows.\n\tchunks := channels.SplitMessage(content, maxBytes)\n\tvar result []string\n\tfor _, chunk := range chunks {\n\t\tif len(chunk) <= maxBytes {\n\t\t\tresult = append(result, chunk)\n\t\t} else {\n\t\t\t// Still too large in bytes (e.g. dense CJK); force-split at UTF-8 boundaries.\n\t\t\tresult = append(result, splitAtByteBoundary(chunk, maxBytes)...)\n\t\t}\n\t}\n\treturn result\n}\n\n// splitAtByteBoundary splits s into parts each ≤ maxBytes bytes by walking back\n// from the hard byte limit to find a valid UTF-8 rune start boundary.\n// This is a last-resort fallback; it does not try to preserve code blocks.\nfunc splitAtByteBoundary(s string, maxBytes int) []string {\n\tvar parts []string\n\tfor len(s) > maxBytes {\n\t\tend := maxBytes\n\t\t// Walk back past any UTF-8 continuation bytes (high two bits == 10).\n\t\tfor end > 0 && s[end]>>6 == 0b10 {\n\t\t\tend--\n\t\t}\n\t\tif end == 0 {\n\t\t\tend = maxBytes // shouldn't happen with valid UTF-8\n\t\t}\n\t\tparts = append(parts, s[:end])\n\t\ts = strings.TrimLeft(s[end:], \" \\t\\n\\r\")\n\t}\n\tif s != \"\" {\n\t\tparts = append(parts, s)\n\t}\n\treturn parts\n}\n"
  },
  {
    "path": "pkg/channels/wecom/aibot_ws_test.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\n// newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing.\nfunc newTestWSChannel(t *testing.T) *WeComAIBotWSChannel {\n\tt.Helper()\n\tcfg := config.WeComAIBotConfig{\n\t\tEnabled: true,\n\t\tBotID:   \"test_bot_id\",\n\t\tSecret:  \"test_secret\",\n\t}\n\tch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus())\n\tif err != nil {\n\t\tt.Fatalf(\"create WS channel: %v\", err)\n\t}\n\treturn ch\n}\n\n// TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no\n// MediaStore has been injected.\nfunc TestStoreWSMedia_NilStore(t *testing.T) {\n\tch := newTestWSChannel(t)\n\t_, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msg1\", \"http://any\", \"\", \".jpg\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when no MediaStore is set\")\n\t}\n}\n\n// TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors\n// from the media server.\nfunc TestStoreWSMedia_HTTPError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t}))\n\tdefer srv.Close()\n\n\tch := newTestWSChannel(t)\n\tch.SetMediaStore(media.NewFileMediaStore())\n\n\t_, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msg1\", srv.URL, \"\", \".jpg\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for HTTP 404\")\n\t}\n}\n\n// TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear\n// error when the media server cannot be reached.\nfunc TestStoreWSMedia_ServerUnavailable(t *testing.T) {\n\tch := newTestWSChannel(t)\n\tch.SetMediaStore(media.NewFileMediaStore())\n\n\t// Port 1 is reserved and will refuse the connection immediately.\n\t_, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msg1\", \"http://127.0.0.1:1\", \"\", \".jpg\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for unreachable server\")\n\t}\n}\n\n// TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded,\n// a media ref is returned, and the file persists and is readable via Resolve until\n// ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used.\nfunc TestStoreWSMedia_Success_NoAES(t *testing.T) {\n\timageData := bytes.Repeat([]byte(\"x\"), 256)\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write(imageData)\n\t}))\n\tdefer srv.Close()\n\n\tch := newTestWSChannel(t)\n\tstore := media.NewFileMediaStore()\n\tch.SetMediaStore(store)\n\n\tref, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msg1\", srv.URL, \"\", \".jpg\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif ref == \"\" {\n\t\tt.Fatal(\"expected non-empty ref\")\n\t}\n\n\t// File must be accessible after storeWSMedia returns (no premature deletion).\n\tpath, err := store.Resolve(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"ref should resolve: %v\", err)\n\t}\n\tgot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"file should exist at %s: %v\", path, err)\n\t}\n\tif !bytes.Equal(got, imageData) {\n\t\tt.Errorf(\"content mismatch: got len=%d, want len=%d\", len(got), len(imageData))\n\t}\n\n\t// ReleaseAll must delete the file (store owns lifecycle).\n\tscope := channels.BuildMediaScope(\"wecom_aibot\", \"chat1\", \"msg1\")\n\tif err := store.ReleaseAll(scope); err != nil {\n\t\tt.Fatalf(\"ReleaseAll failed: %v\", err)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Errorf(\"file should have been deleted by ReleaseAll, stat err: %v\", err)\n\t}\n}\n\n// TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with\n// different msgIDs do not collide and each resolve to distinct files.\nfunc TestStoreWSMedia_MultipleMessages(t *testing.T) {\n\timageA := bytes.Repeat([]byte(\"a\"), 64)\n\timageB := bytes.Repeat([]byte(\"b\"), 64)\n\n\tsrvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write(imageA)\n\t}))\n\tdefer srvA.Close()\n\tsrvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write(imageB)\n\t}))\n\tdefer srvB.Close()\n\n\tch := newTestWSChannel(t)\n\tstore := media.NewFileMediaStore()\n\tch.SetMediaStore(store)\n\n\trefA, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msgA\", srvA.URL, \"\", \".jpg\")\n\tif err != nil {\n\t\tt.Fatalf(\"storeWSMedia A: %v\", err)\n\t}\n\trefB, err := ch.storeWSMedia(context.Background(), \"chat1\", \"msgB\", srvB.URL, \"\", \".jpg\")\n\tif err != nil {\n\t\tt.Fatalf(\"storeWSMedia B: %v\", err)\n\t}\n\tif refA == refB {\n\t\tt.Fatal(\"distinct messages must produce distinct refs\")\n\t}\n\n\tpathA, _ := store.Resolve(refA)\n\tpathB, _ := store.Resolve(refB)\n\tif pathA == pathB {\n\t\tt.Fatal(\"distinct messages must be stored at distinct paths\")\n\t}\n\n\tgotA, _ := os.ReadFile(pathA)\n\tgotB, _ := os.ReadFile(pathB)\n\tif !bytes.Equal(gotA, imageA) {\n\t\tt.Errorf(\"content mismatch for message A\")\n\t}\n\tif !bytes.Equal(gotB, imageB) {\n\t\tt.Errorf(\"content mismatch for message B\")\n\t}\n}\n\n// TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred\n// from the HTTP Content-Type header and the defaultExt fallback is used when the\n// type is absent or unrecognized.\nfunc TestStoreWSMedia_ContentTypeExt(t *testing.T) {\n\ttests := []struct {\n\t\tcontentType string\n\t\twantExt     string\n\t}{\n\t\t{\"image/jpeg\", \".jpg\"},\n\t\t{\"image/png\", \".png\"},\n\t\t{\"video/mp4\", \".mp4\"},\n\t\t{\"application/pdf\", \".pdf\"},\n\t\t{\"application/zip\", \".zip\"},\n\t\t// With parameters stripped.\n\t\t{\"video/mp4; codecs=avc1\", \".mp4\"},\n\t\t// Unknown type → falls back to defaultExt.\n\t\t{\"\", \"\"},\n\t\t{\"application/octet-stream\", \"\"},\n\t}\n\tfor _, tc := range tests {\n\t\tgot := wsMediaExtFromContentType(tc.contentType)\n\t\tif got != tc.wantExt {\n\t\t\tt.Errorf(\"wsMediaExtFromContentType(%q) = %q, want %q\", tc.contentType, got, tc.wantExt)\n\t\t}\n\t}\n\n\t// End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin.\n\t// The stored file should carry the .mp4 extension, not .bin.\n\tpayload := bytes.Repeat([]byte(\"v\"), 128)\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"video/mp4\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write(payload)\n\t}))\n\tdefer srv.Close()\n\n\tch := newTestWSChannel(t)\n\tstore := media.NewFileMediaStore()\n\tch.SetMediaStore(store)\n\n\tref, err := ch.storeWSMedia(context.Background(), \"chat1\", \"vid1\", srv.URL, \"\", \".bin\")\n\tif err != nil {\n\t\tt.Fatalf(\"storeWSMedia: %v\", err)\n\t}\n\tpath, err := store.Resolve(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"resolve: %v\", err)\n\t}\n\tif ext := path[len(path)-4:]; ext != \".mp4\" {\n\t\tt.Errorf(\"expected .mp4 extension from Content-Type, got %q\", ext)\n\t}\n}\n\n// TestSplitWSContent verifies byte-aware splitting of stream content.\nfunc TestSplitWSContent(t *testing.T) {\n\tt.Run(\"short content is not split\", func(t *testing.T) {\n\t\tchunks := splitWSContent(\"hello\", 20480)\n\t\tif len(chunks) != 1 || chunks[0] != \"hello\" {\n\t\t\tt.Fatalf(\"unexpected chunks: %v\", chunks)\n\t\t}\n\t})\n\n\tt.Run(\"ASCII content split at byte boundary\", func(t *testing.T) {\n\t\t// Build a string just over the limit.\n\t\tcontent := strings.Repeat(\"a\", 20481)\n\t\tchunks := splitWSContent(content, 20480)\n\t\tif len(chunks) < 2 {\n\t\t\tt.Fatalf(\"expected >= 2 chunks, got %d\", len(chunks))\n\t\t}\n\t\tfor i, c := range chunks {\n\t\t\tif len(c) > 20480 {\n\t\t\t\tt.Errorf(\"chunk %d has %d bytes, want <= 20480\", i, len(c))\n\t\t\t}\n\t\t}\n\t\t// Reassembled content must equal the original (possibly without leading\n\t\t// whitespace that splitWSContent trims between chunks).\n\t\tjoined := strings.Join(chunks, \"\")\n\t\tif len(joined) < len(content)-len(chunks) {\n\t\t\tt.Errorf(\"joined length %d too short (original %d)\", len(joined), len(content))\n\t\t}\n\t})\n\n\tt.Run(\"CJK content split within byte limit\", func(t *testing.T) {\n\t\t// Each CJK rune is 3 bytes in UTF-8.\n\t\t// 7000 CJK chars = 21000 bytes, which exceeds 20480.\n\t\tcontent := strings.Repeat(\"\\u4e2d\", 7000)\n\t\tchunks := splitWSContent(content, 20480)\n\t\tif len(chunks) < 2 {\n\t\t\tt.Fatalf(\"expected >= 2 chunks for 21000-byte CJK content, got %d\", len(chunks))\n\t\t}\n\t\tfor i, c := range chunks {\n\t\t\tif len(c) > 20480 {\n\t\t\t\tt.Errorf(\"chunk %d has %d bytes, want <= 20480\", i, len(c))\n\t\t\t}\n\t\t\t// Every chunk must be valid UTF-8.\n\t\t\tif !strings.ContainsRune(c, '\\u4e2d') && len(c) > 0 {\n\t\t\t\t// quick plausibility check — content was pure CJK\n\t\t\t}\n\t\t}\n\t})\n}\n\n// TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter.\nfunc TestSplitAtByteBoundary(t *testing.T) {\n\tt.Run(\"ASCII fits in one chunk\", func(t *testing.T) {\n\t\tparts := splitAtByteBoundary(\"hello world\", 100)\n\t\tif len(parts) != 1 {\n\t\t\tt.Fatalf(\"expected 1 part, got %d\", len(parts))\n\t\t}\n\t})\n\n\tt.Run(\"splits at byte boundary, never mid-rune\", func(t *testing.T) {\n\t\t// 10 CJK characters = 30 bytes; split at 20 bytes.\n\t\ts := strings.Repeat(\"\\u6587\", 10) // 10 × 3 bytes = 30 bytes\n\t\tparts := splitAtByteBoundary(s, 20)\n\t\tfor i, p := range parts {\n\t\t\tif len(p) > 20 {\n\t\t\t\tt.Errorf(\"part %d has %d bytes, want <= 20\", i, len(p))\n\t\t\t}\n\t\t\t// Must be valid UTF-8 (no torn multi-byte sequences).\n\t\t\tfor j, r := range p {\n\t\t\t\tif r == '\\uFFFD' {\n\t\t\t\t\tt.Errorf(\"part %d has replacement rune at position %d: torn UTF-8\", i, j)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/wecom/app.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\twecomAPIBase = \"https://qyapi.weixin.qq.com\"\n)\n\n// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)\ntype WeComAppChannel struct {\n\t*channels.BaseChannel\n\tconfig        config.WeComAppConfig\n\tclient        *http.Client\n\taccessToken   string\n\ttokenExpiry   time.Time\n\ttokenMu       sync.RWMutex\n\tctx           context.Context\n\tcancel        context.CancelFunc\n\tprocessedMsgs *MessageDeduplicator\n}\n\n// WeComXMLMessage represents the XML message structure from WeCom\ntype WeComXMLMessage struct {\n\tXMLName      xml.Name `xml:\"xml\"`\n\tToUserName   string   `xml:\"ToUserName\"`\n\tFromUserName string   `xml:\"FromUserName\"`\n\tCreateTime   int64    `xml:\"CreateTime\"`\n\tMsgType      string   `xml:\"MsgType\"`\n\tContent      string   `xml:\"Content\"`\n\tMsgId        int64    `xml:\"MsgId\"`\n\tAgentID      int64    `xml:\"AgentID\"`\n\tPicUrl       string   `xml:\"PicUrl\"`\n\tMediaId      string   `xml:\"MediaId\"`\n\tFormat       string   `xml:\"Format\"`\n\tThumbMediaId string   `xml:\"ThumbMediaId\"`\n\tLocationX    float64  `xml:\"Location_X\"`\n\tLocationY    float64  `xml:\"Location_Y\"`\n\tScale        int      `xml:\"Scale\"`\n\tLabel        string   `xml:\"Label\"`\n\tTitle        string   `xml:\"Title\"`\n\tDescription  string   `xml:\"Description\"`\n\tUrl          string   `xml:\"Url\"`\n\tEvent        string   `xml:\"Event\"`\n\tEventKey     string   `xml:\"EventKey\"`\n}\n\n// WeComTextMessage represents text message for sending\ntype WeComTextMessage struct {\n\tToUser  string `json:\"touser\"`\n\tMsgType string `json:\"msgtype\"`\n\tAgentID int64  `json:\"agentid\"`\n\tText    struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"text\"`\n\tSafe int `json:\"safe,omitempty\"`\n}\n\n// WeComMarkdownMessage represents markdown message for sending\ntype WeComMarkdownMessage struct {\n\tToUser   string `json:\"touser\"`\n\tMsgType  string `json:\"msgtype\"`\n\tAgentID  int64  `json:\"agentid\"`\n\tMarkdown struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"markdown\"`\n}\n\n// WeComImageMessage represents image message for sending\ntype WeComImageMessage struct {\n\tToUser  string `json:\"touser\"`\n\tMsgType string `json:\"msgtype\"`\n\tAgentID int64  `json:\"agentid\"`\n\tImage   struct {\n\t\tMediaID string `json:\"media_id\"`\n\t} `json:\"image\"`\n}\n\n// WeComAccessTokenResponse represents the access token API response\ntype WeComAccessTokenResponse struct {\n\tErrCode     int    `json:\"errcode\"`\n\tErrMsg      string `json:\"errmsg\"`\n\tAccessToken string `json:\"access_token\"`\n\tExpiresIn   int    `json:\"expires_in\"`\n}\n\n// WeComSendMessageResponse represents the send message API response\ntype WeComSendMessageResponse struct {\n\tErrCode      int    `json:\"errcode\"`\n\tErrMsg       string `json:\"errmsg\"`\n\tInvalidUser  string `json:\"invaliduser\"`\n\tInvalidParty string `json:\"invalidparty\"`\n\tInvalidTag   string `json:\"invalidtag\"`\n}\n\n// PKCS7Padding adds PKCS7 padding\ntype PKCS7Padding struct{}\n\n// NewWeComAppChannel creates a new WeCom App channel instance\nfunc NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {\n\tif cfg.CorpID == \"\" || cfg.CorpSecret == \"\" || cfg.AgentID == 0 {\n\t\treturn nil, fmt.Errorf(\"wecom_app corp_id, corp_secret and agent_id are required\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"wecom_app\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(2048),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\t// Client timeout must be >= the configured ReplyTimeout so the\n\t// per-request context deadline is always the effective limit.\n\tclientTimeout := 30 * time.Second\n\tif d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {\n\t\tclientTimeout = d\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\treturn &WeComAppChannel{\n\t\tBaseChannel:   base,\n\t\tconfig:        cfg,\n\t\tclient:        &http.Client{Timeout: clientTimeout},\n\t\tctx:           ctx,\n\t\tcancel:        cancel,\n\t\tprocessedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),\n\t}, nil\n}\n\n// Name returns the channel name\nfunc (c *WeComAppChannel) Name() string {\n\treturn \"wecom_app\"\n}\n\n// Start initializes the WeCom App channel\nfunc (c *WeComAppChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"wecom_app\", \"Starting WeCom App channel...\")\n\n\t// Cancel the context created in the constructor to avoid a resource leak.\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\t// Get initial access token\n\tif err := c.refreshAccessToken(); err != nil {\n\t\tlogger.WarnCF(\"wecom_app\", \"Failed to get initial access token\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t}\n\n\t// Start token refresh goroutine\n\tgo c.tokenRefreshLoop()\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"wecom_app\", \"WeCom App channel started\")\n\n\treturn nil\n}\n\n// Stop gracefully stops the WeCom App channel\nfunc (c *WeComAppChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"wecom_app\", \"Stopping WeCom App channel...\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"wecom_app\", \"WeCom App channel stopped\")\n\treturn nil\n}\n\n// Send sends a message to WeCom user proactively using access token\nfunc (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\taccessToken := c.getAccessToken()\n\tif accessToken == \"\" {\n\t\treturn fmt.Errorf(\"no valid access token available\")\n\t}\n\n\tlogger.DebugCF(\"wecom_app\", \"Sending message\", map[string]any{\n\t\t\"chat_id\": msg.ChatID,\n\t\t\"preview\": utils.Truncate(msg.Content, 100),\n\t})\n\n\treturn c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)\n}\n\n// SendMedia implements the channels.MediaSender interface.\nfunc (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\taccessToken := c.getAccessToken()\n\tif accessToken == \"\" {\n\t\treturn fmt.Errorf(\"no valid access token available: %w\", channels.ErrTemporary)\n\t}\n\n\tstore := c.GetMediaStore()\n\tif store == nil {\n\t\treturn fmt.Errorf(\"no media store available: %w\", channels.ErrSendFailed)\n\t}\n\n\tfor _, part := range msg.Parts {\n\t\tlocalPath, err := store.Resolve(part.Ref)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to resolve media ref\", map[string]any{\n\t\t\t\t\"ref\":   part.Ref,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\t// Map part type to WeCom media type\n\t\tvar mediaType string\n\t\tswitch part.Type {\n\t\tcase \"image\":\n\t\t\tmediaType = \"image\"\n\t\tcase \"audio\":\n\t\t\tmediaType = \"voice\"\n\t\tcase \"video\":\n\t\t\tmediaType = \"video\"\n\t\tdefault:\n\t\t\tmediaType = \"file\"\n\t\t}\n\n\t\t// Upload media to get media_id\n\t\tmediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to upload media\", map[string]any{\n\t\t\t\t\"type\":  mediaType,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\t// Fallback: send caption as text\n\t\t\tif part.Caption != \"\" {\n\t\t\t\t_ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Send media message using the media_id\n\t\tif mediaType == \"image\" {\n\t\t\terr = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)\n\t\t} else {\n\t\t\t// For non-image types, send as text fallback with caption\n\t\t\tcaption := part.Caption\n\t\t\tif caption == \"\" {\n\t\t\t\tcaption = fmt.Sprintf(\"[%s: %s]\", part.Type, part.Filename)\n\t\t\t}\n\t\t\terr = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// uploadMedia uploads a local file to WeCom temporary media storage.\nfunc (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {\n\tapiURL := fmt.Sprintf(\"%s/cgi-bin/media/upload?access_token=%s&type=%s\",\n\t\twecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))\n\n\tfile, err := os.Open(localPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to open file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\n\tfilename := filepath.Base(localPath)\n\tformFile, err := writer.CreateFormFile(\"media\", filename)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create form file: %w\", err)\n\t}\n\n\tif _, err = io.Copy(formFile, file); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to copy file content: %w\", err)\n\t}\n\twriter.Close()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn \"\", channels.ClassifyNetError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\trespBody, readErr := io.ReadAll(resp.Body)\n\t\tif readErr != nil {\n\t\t\treturn \"\", channels.ClassifySendError(\n\t\t\t\tresp.StatusCode,\n\t\t\t\tfmt.Errorf(\"reading wecom upload error response: %w\", readErr),\n\t\t\t)\n\t\t}\n\t\treturn \"\", channels.ClassifySendError(\n\t\t\tresp.StatusCode,\n\t\t\tfmt.Errorf(\"wecom upload error: %s\", string(respBody)),\n\t\t)\n\t}\n\n\tvar result struct {\n\t\tErrCode int    `json:\"errcode\"`\n\t\tErrMsg  string `json:\"errmsg\"`\n\t\tMediaID string `json:\"media_id\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse upload response: %w\", err)\n\t}\n\n\tif result.ErrCode != 0 {\n\t\treturn \"\", fmt.Errorf(\"upload API error: %s (code: %d)\", result.ErrMsg, result.ErrCode)\n\t}\n\n\treturn result.MediaID, nil\n}\n\n// sendWeComMessage marshals payload and POSTs it to the WeCom message API.\nfunc (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error {\n\tapiURL := fmt.Sprintf(\"%s/cgi-bin/message/send?access_token=%s\", wecomAPIBase, accessToken)\n\n\tjsonData, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal message: %w\", err)\n\t}\n\n\ttimeout := c.config.ReplyTimeout\n\tif timeout <= 0 {\n\t\ttimeout = 5\n\t}\n\n\treqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn channels.ClassifyNetError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\trespBody, readErr := io.ReadAll(resp.Body)\n\t\tif readErr != nil {\n\t\t\treturn channels.ClassifySendError(\n\t\t\t\tresp.StatusCode,\n\t\t\t\tfmt.Errorf(\"reading wecom_app error response: %w\", readErr),\n\t\t\t)\n\t\t}\n\t\treturn channels.ClassifySendError(\n\t\t\tresp.StatusCode,\n\t\t\tfmt.Errorf(\"wecom_app API error: %s\", string(respBody)),\n\t\t)\n\t}\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tvar sendResp WeComSendMessageResponse\n\tif err := json.Unmarshal(respBody, &sendResp); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif sendResp.ErrCode != 0 {\n\t\treturn fmt.Errorf(\"API error: %s (code: %d)\", sendResp.ErrMsg, sendResp.ErrCode)\n\t}\n\n\treturn nil\n}\n\n// sendImageMessage sends an image message using a media_id.\nfunc (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {\n\tmsg := WeComImageMessage{\n\t\tToUser:  userID,\n\t\tMsgType: \"image\",\n\t\tAgentID: c.config.AgentID,\n\t}\n\tmsg.Image.MediaID = mediaID\n\treturn c.sendWeComMessage(ctx, accessToken, msg)\n}\n\n// WebhookPath returns the path for registering on the shared HTTP server.\nfunc (c *WeComAppChannel) WebhookPath() string {\n\tif c.config.WebhookPath != \"\" {\n\t\treturn c.config.WebhookPath\n\t}\n\treturn \"/webhook/wecom-app\"\n}\n\n// ServeHTTP implements http.Handler for the shared HTTP server.\nfunc (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tc.handleWebhook(w, r)\n}\n\n// HealthPath returns the health check endpoint path.\nfunc (c *WeComAppChannel) HealthPath() string {\n\treturn \"/health/wecom-app\"\n}\n\n// HealthHandler handles health check requests.\nfunc (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {\n\tc.handleHealth(w, r)\n}\n\n// handleWebhook handles incoming webhook requests from WeCom\nfunc (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\n\t// Log all incoming requests for debugging\n\tlogger.DebugCF(\"wecom_app\", \"Received webhook request\", map[string]any{\n\t\t\"method\": r.Method,\n\t\t\"url\":    r.URL.String(),\n\t\t\"path\":   r.URL.Path,\n\t\t\"query\":  r.URL.RawQuery,\n\t})\n\n\tif r.Method == http.MethodGet {\n\t\t// Handle verification request\n\t\tc.handleVerification(ctx, w, r)\n\t\treturn\n\t}\n\n\tif r.Method == http.MethodPost {\n\t\t// Handle message callback\n\t\tc.handleMessageCallback(ctx, w, r)\n\t\treturn\n\t}\n\n\tlogger.WarnCF(\"wecom_app\", \"Method not allowed\", map[string]any{\n\t\t\"method\": r.Method,\n\t})\n\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n}\n\n// handleVerification handles the URL verification request from WeCom\nfunc (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {\n\tquery := r.URL.Query()\n\tmsgSignature := query.Get(\"msg_signature\")\n\ttimestamp := query.Get(\"timestamp\")\n\tnonce := query.Get(\"nonce\")\n\techostr := query.Get(\"echostr\")\n\n\tlogger.DebugCF(\"wecom_app\", \"Handling verification request\", map[string]any{\n\t\t\"msg_signature\": msgSignature,\n\t\t\"timestamp\":     timestamp,\n\t\t\"nonce\":         nonce,\n\t\t\"echostr\":       echostr,\n\t\t\"corp_id\":       c.config.CorpID,\n\t})\n\n\tif msgSignature == \"\" || timestamp == \"\" || nonce == \"\" || echostr == \"\" {\n\t\tlogger.ErrorC(\"wecom_app\", \"Missing parameters in verification request\")\n\t\thttp.Error(w, \"Missing parameters\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {\n\t\tlogger.WarnCF(\"wecom_app\", \"Signature verification failed\", map[string]any{\n\t\t\t\"token\":         c.config.Token,\n\t\t\t\"msg_signature\": msgSignature,\n\t\t\t\"timestamp\":     timestamp,\n\t\t\t\"nonce\":         nonce,\n\t\t})\n\t\thttp.Error(w, \"Invalid signature\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\tlogger.DebugC(\"wecom_app\", \"Signature verification passed\")\n\n\t// Decrypt echostr with CorpID verification\n\t// For WeCom App (自建应用), receiveid should be corp_id\n\tlogger.DebugCF(\"wecom_app\", \"Attempting to decrypt echostr\", map[string]any{\n\t\t\"encoding_aes_key\": c.config.EncodingAESKey,\n\t\t\"corp_id\":          c.config.CorpID,\n\t})\n\tdecryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to decrypt echostr\", map[string]any{\n\t\t\t\"error\":            err.Error(),\n\t\t\t\"encoding_aes_key\": c.config.EncodingAESKey,\n\t\t\t\"corp_id\":          c.config.CorpID,\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tlogger.DebugCF(\"wecom_app\", \"Successfully decrypted echostr\", map[string]any{\n\t\t\"decrypted\": decryptedEchoStr,\n\t})\n\n\t// Remove BOM and whitespace as per WeCom documentation\n\t// The response must be plain text without quotes, BOM, or newlines\n\tdecryptedEchoStr = strings.TrimSpace(decryptedEchoStr)\n\tdecryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, \"\\xef\\xbb\\xbf\") // Remove UTF-8 BOM\n\tw.Write([]byte(decryptedEchoStr))\n}\n\n// handleMessageCallback handles incoming messages from WeCom\nfunc (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {\n\tquery := r.URL.Query()\n\tmsgSignature := query.Get(\"msg_signature\")\n\ttimestamp := query.Get(\"timestamp\")\n\tnonce := query.Get(\"nonce\")\n\n\tif msgSignature == \"\" || timestamp == \"\" || nonce == \"\" {\n\t\thttp.Error(w, \"Missing parameters\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Read request body\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\t// Parse XML to get encrypted message\n\tvar encryptedMsg struct {\n\t\tXMLName    xml.Name `xml:\"xml\"`\n\t\tToUserName string   `xml:\"ToUserName\"`\n\t\tEncrypt    string   `xml:\"Encrypt\"`\n\t\tAgentID    string   `xml:\"AgentID\"`\n\t}\n\n\tif err = xml.Unmarshal(body, &encryptedMsg); err != nil {\n\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to parse XML\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Invalid XML\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {\n\t\tlogger.WarnC(\"wecom_app\", \"Message signature verification failed\")\n\t\thttp.Error(w, \"Invalid signature\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\t// Decrypt message with CorpID verification\n\t// For WeCom App (自建应用), receiveid should be corp_id\n\tdecryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to decrypt message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Parse decrypted XML message\n\tvar msg WeComXMLMessage\n\tif err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {\n\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to parse decrypted message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Invalid message format\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Process the message with the channel's long-lived context (not the HTTP\n\t// request context, which is canceled as soon as we return the response).\n\tgo c.processMessage(c.ctx, msg)\n\n\t// Return success response immediately\n\t// WeCom App requires response within configured timeout (default 5 seconds)\n\tw.Write([]byte(\"success\"))\n}\n\n// processMessage processes the received message\nfunc (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {\n\t// Skip non-text messages for now (can be extended)\n\tif msg.MsgType != \"text\" && msg.MsgType != \"image\" && msg.MsgType != \"voice\" {\n\t\tlogger.DebugCF(\"wecom_app\", \"Skipping non-supported message type\", map[string]any{\n\t\t\t\"msg_type\": msg.MsgType,\n\t\t})\n\t\treturn\n\t}\n\n\t// Message deduplication: Use msg_id to prevent duplicate processing\n\t// As per WeCom documentation, use msg_id for deduplication\n\tmsgID := fmt.Sprintf(\"%d\", msg.MsgId)\n\tif !c.processedMsgs.MarkMessageProcessed(msgID) {\n\t\tlogger.DebugCF(\"wecom_app\", \"Skipping duplicate message\", map[string]any{\n\t\t\t\"msg_id\": msgID,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := msg.FromUserName\n\tchatID := senderID // WeCom App uses user ID as chat ID for direct messages\n\n\t// Build metadata\n\t// WeCom App only supports direct messages (private chat)\n\tpeer := bus.Peer{Kind: \"direct\", ID: senderID}\n\tmessageID := fmt.Sprintf(\"%d\", msg.MsgId)\n\n\tmetadata := map[string]string{\n\t\t\"msg_type\":    msg.MsgType,\n\t\t\"msg_id\":      fmt.Sprintf(\"%d\", msg.MsgId),\n\t\t\"agent_id\":    fmt.Sprintf(\"%d\", msg.AgentID),\n\t\t\"platform\":    \"wecom_app\",\n\t\t\"media_id\":    msg.MediaId,\n\t\t\"create_time\": fmt.Sprintf(\"%d\", msg.CreateTime),\n\t}\n\n\tcontent := msg.Content\n\n\tlogger.DebugCF(\"wecom_app\", \"Received message\", map[string]any{\n\t\t\"sender_id\": senderID,\n\t\t\"msg_type\":  msg.MsgType,\n\t\t\"preview\":   utils.Truncate(content, 50),\n\t})\n\n\t// Build sender info\n\tappSender := bus.SenderInfo{\n\t\tPlatform:    \"wecom\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"wecom\", senderID),\n\t}\n\n\t// Handle the message through the base channel\n\tc.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)\n}\n\n// tokenRefreshLoop periodically refreshes the access token\nfunc (c *WeComAppChannel) tokenRefreshLoop() {\n\tticker := time.NewTicker(5 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tif err := c.refreshAccessToken(); err != nil {\n\t\t\t\tlogger.ErrorCF(\"wecom_app\", \"Failed to refresh access token\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n}\n\n// refreshAccessToken gets a new access token from WeCom API\nfunc (c *WeComAppChannel) refreshAccessToken() error {\n\tapiURL := fmt.Sprintf(\"%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s\",\n\t\twecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))\n\n\tresp, err := http.Get(apiURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to request access token: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tvar tokenResp WeComAccessTokenResponse\n\tif err := json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif tokenResp.ErrCode != 0 {\n\t\treturn fmt.Errorf(\"API error: %s (code: %d)\", tokenResp.ErrMsg, tokenResp.ErrCode)\n\t}\n\n\tc.tokenMu.Lock()\n\tc.accessToken = tokenResp.AccessToken\n\tc.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early\n\tc.tokenMu.Unlock()\n\n\tlogger.DebugC(\"wecom_app\", \"Access token refreshed successfully\")\n\treturn nil\n}\n\n// getAccessToken returns the current valid access token\nfunc (c *WeComAppChannel) getAccessToken() string {\n\tc.tokenMu.RLock()\n\tdefer c.tokenMu.RUnlock()\n\n\tif time.Now().After(c.tokenExpiry) {\n\t\treturn \"\"\n\t}\n\n\treturn c.accessToken\n}\n\n// sendTextMessage sends a text message to a user.\nfunc (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {\n\tmsg := WeComTextMessage{\n\t\tToUser:  userID,\n\t\tMsgType: \"text\",\n\t\tAgentID: c.config.AgentID,\n\t}\n\tmsg.Text.Content = content\n\treturn c.sendWeComMessage(ctx, accessToken, msg)\n}\n\n// handleHealth handles health check requests\nfunc (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {\n\tstatus := map[string]any{\n\t\t\"status\":    \"ok\",\n\t\t\"running\":   c.IsRunning(),\n\t\t\"has_token\": c.getAccessToken() != \"\",\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(status)\n}\n"
  },
  {
    "path": "pkg/channels/wecom/app_test.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// generateTestAESKeyApp generates a valid test AES key for WeCom App\nfunc generateTestAESKeyApp() string {\n\t// AES key needs to be 32 bytes (256 bits) for AES-256\n\tkey := make([]byte, 32)\n\tfor i := range key {\n\t\tkey[i] = byte(i + 1)\n\t}\n\t// Return base64 encoded key without padding\n\treturn base64.StdEncoding.EncodeToString(key)[:43]\n}\n\n// encryptTestMessageApp encrypts a message for testing WeCom App\nfunc encryptTestMessageApp(message, aesKey string) (string, error) {\n\t// Decode AES key\n\tkey, err := base64.StdEncoding.DecodeString(aesKey + \"=\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Prepare message: random(16) + msg_len(4) + msg + corp_id\n\trandom := make([]byte, 0, 16)\n\tfor i := range 16 {\n\t\trandom = append(random, byte(i+1))\n\t}\n\n\tmsgBytes := []byte(message)\n\tcorpID := []byte(\"test_corp_id\")\n\n\tmsgLen := uint32(len(msgBytes))\n\tlenBytes := make([]byte, 4)\n\tbinary.BigEndian.PutUint32(lenBytes, msgLen)\n\n\tplainText := append(random, lenBytes...)\n\tplainText = append(plainText, msgBytes...)\n\tplainText = append(plainText, corpID...)\n\n\t// PKCS7 padding\n\tblockSize := aes.BlockSize\n\tpadding := blockSize - len(plainText)%blockSize\n\tpadText := bytes.Repeat([]byte{byte(padding)}, padding)\n\tplainText = append(plainText, padText...)\n\n\t// Encrypt\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])\n\tcipherText := make([]byte, len(plainText))\n\tmode.CryptBlocks(cipherText, plainText)\n\n\treturn base64.StdEncoding.EncodeToString(cipherText), nil\n}\n\n// generateSignatureApp generates a signature for testing WeCom App\nfunc generateSignatureApp(token, timestamp, nonce, msgEncrypt string) string {\n\tparams := []string{token, timestamp, nonce, msgEncrypt}\n\tsort.Strings(params)\n\tstr := strings.Join(params, \"\")\n\thash := sha1.Sum([]byte(str))\n\treturn fmt.Sprintf(\"%x\", hash)\n}\n\nfunc TestNewWeComAppChannel(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"missing corp_id\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    1000002,\n\t\t}\n\t\t_, err := NewWeComAppChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing corp_id, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing corp_secret\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"\",\n\t\t\tAgentID:    1000002,\n\t\t}\n\t\t_, err := NewWeComAppChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing corp_secret, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing agent_id\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    0,\n\t\t}\n\t\t_, err := NewWeComAppChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing agent_id, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"valid config\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    1000002,\n\t\t\tAllowFrom:  []string{\"user1\", \"user2\"},\n\t\t}\n\t\tch, err := NewWeComAppChannel(cfg, msgBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif ch.Name() != \"wecom_app\" {\n\t\t\tt.Errorf(\"Name() = %q, want %q\", ch.Name(), \"wecom_app\")\n\t\t}\n\t\tif ch.IsRunning() {\n\t\t\tt.Error(\"new channel should not be running\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAppChannelIsAllowed(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"empty allowlist allows all\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    1000002,\n\t\t\tAllowFrom:  []string{},\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"any_user\") {\n\t\t\tt.Error(\"empty allowlist should allow all users\")\n\t\t}\n\t})\n\n\tt.Run(\"allowlist restricts users\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    1000002,\n\t\t\tAllowFrom:  []string{\"allowed_user\"},\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"allowed_user\") {\n\t\t\tt.Error(\"allowed user should pass allowlist check\")\n\t\t}\n\t\tif ch.IsAllowed(\"blocked_user\") {\n\t\t\tt.Error(\"non-allowed user should be blocked\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAppVerifySignature(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:     \"test_corp_id\",\n\t\tCorpSecret: \"test_secret\",\n\t\tAgentID:    1000002,\n\t\tToken:      \"test_token\",\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"valid signature\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tmsgEncrypt := \"test_message\"\n\t\texpectedSig := generateSignatureApp(\"test_token\", timestamp, nonce, msgEncrypt)\n\n\t\tif !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {\n\t\t\tt.Error(\"valid signature should pass verification\")\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tmsgEncrypt := \"test_message\"\n\n\t\tif verifySignature(ch.config.Token, \"invalid_sig\", timestamp, nonce, msgEncrypt) {\n\t\t\tt.Error(\"invalid signature should fail verification\")\n\t\t}\n\t})\n\n\tt.Run(\"empty token rejects verification (fail-closed)\", func(t *testing.T) {\n\t\tcfgEmpty := config.WeComAppConfig{\n\t\t\tCorpID:     \"test_corp_id\",\n\t\t\tCorpSecret: \"test_secret\",\n\t\t\tAgentID:    1000002,\n\t\t\tToken:      \"\",\n\t\t}\n\t\tchEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)\n\n\t\tif verifySignature(chEmpty.config.Token, \"any_sig\", \"any_ts\", \"any_nonce\", \"any_msg\") {\n\t\t\tt.Error(\"empty token should reject verification (fail-closed)\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAppDecryptMessage(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"decrypt without AES key\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:         \"test_corp_id\",\n\t\t\tCorpSecret:     \"test_secret\",\n\t\t\tAgentID:        1000002,\n\t\t\tEncodingAESKey: \"\",\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\t\t// Without AES key, message should be base64 decoded only\n\t\tplainText := \"hello world\"\n\t\tencoded := base64.StdEncoding.EncodeToString([]byte(plainText))\n\n\t\tresult, err := decryptMessage(encoded, ch.config.EncodingAESKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif result != plainText {\n\t\t\tt.Errorf(\"decryptMessage() = %q, want %q\", result, plainText)\n\t\t}\n\t})\n\n\tt.Run(\"decrypt with AES key\", func(t *testing.T) {\n\t\taesKey := generateTestAESKeyApp()\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:         \"test_corp_id\",\n\t\t\tCorpSecret:     \"test_secret\",\n\t\t\tAgentID:        1000002,\n\t\t\tEncodingAESKey: aesKey,\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\t\toriginalMsg := \"<xml><Content>Hello</Content></xml>\"\n\t\tencrypted, err := encryptTestMessageApp(originalMsg, aesKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to encrypt test message: %v\", err)\n\t\t}\n\n\t\tresult, err := decryptMessage(encrypted, ch.config.EncodingAESKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif result != originalMsg {\n\t\t\tt.Errorf(\"WeComDecryptMessage() = %q, want %q\", result, originalMsg)\n\t\t}\n\t})\n\n\tt.Run(\"invalid base64\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:         \"test_corp_id\",\n\t\t\tCorpSecret:     \"test_secret\",\n\t\t\tAgentID:        1000002,\n\t\t\tEncodingAESKey: \"\",\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\t\t_, err := decryptMessage(\"invalid_base64!!!\", ch.config.EncodingAESKey)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for invalid base64, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"invalid AES key\", func(t *testing.T) {\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:         \"test_corp_id\",\n\t\t\tCorpSecret:     \"test_secret\",\n\t\t\tAgentID:        1000002,\n\t\t\tEncodingAESKey: \"invalid_key\",\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\t\t_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte(\"test\")), ch.config.EncodingAESKey)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for invalid AES key, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"ciphertext too short\", func(t *testing.T) {\n\t\taesKey := generateTestAESKeyApp()\n\t\tcfg := config.WeComAppConfig{\n\t\t\tCorpID:         \"test_corp_id\",\n\t\t\tCorpSecret:     \"test_secret\",\n\t\t\tAgentID:        1000002,\n\t\t\tEncodingAESKey: aesKey,\n\t\t}\n\t\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\t\t// Encrypt a very short message that results in ciphertext less than block size\n\t\tshortData := make([]byte, 8)\n\t\t_, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for short ciphertext, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAppHandleVerification(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\taesKey := generateTestAESKeyApp()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:         \"test_corp_id\",\n\t\tCorpSecret:     \"test_secret\",\n\t\tAgentID:        1000002,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: aesKey,\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"valid verification request\", func(t *testing.T) {\n\t\techostr := \"test_echostr_123\"\n\t\tencryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignatureApp(\"test_token\", timestamp, nonce, encryptedEchostr)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom-app?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encryptedEchostr,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t\tif w.Body.String() != echostr {\n\t\t\tt.Errorf(\"response body = %q, want %q\", w.Body.String(), echostr)\n\t\t}\n\t})\n\n\tt.Run(\"missing parameters\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/webhook/wecom-app?msg_signature=sig&timestamp=ts\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\techostr := \"test_echostr\"\n\t\tencryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom-app?msg_signature=invalid_sig&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encryptedEchostr,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusForbidden {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusForbidden)\n\t\t}\n\t})\n}\n\nfunc TestWeComAppHandleMessageCallback(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\taesKey := generateTestAESKeyApp()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:         \"test_corp_id\",\n\t\tCorpSecret:     \"test_secret\",\n\t\tAgentID:        1000002,\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: aesKey,\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"valid message callback\", func(t *testing.T) {\n\t\t// Create XML message\n\t\txmlMsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"text\",\n\t\t\tContent:      \"Hello World\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\t\txmlData, _ := xml.Marshal(xmlMsg)\n\n\t\t// Encrypt message\n\t\tencrypted, _ := encryptTestMessageApp(string(xmlData), aesKey)\n\n\t\t// Create encrypted XML wrapper\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: encrypted,\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignatureApp(\"test_token\", timestamp, nonce, encrypted)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom-app?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t\tif w.Body.String() != \"success\" {\n\t\t\tt.Errorf(\"response body = %q, want %q\", w.Body.String(), \"success\")\n\t\t}\n\t})\n\n\tt.Run(\"missing parameters\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/webhook/wecom-app?msg_signature=sig\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid XML\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignatureApp(\"test_token\", timestamp, nonce, \"\")\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom-app?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tstrings.NewReader(\"invalid xml\"),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: \"encrypted_data\",\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom-app?msg_signature=invalid_sig&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusForbidden {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusForbidden)\n\t\t}\n\t})\n}\n\nfunc TestWeComAppProcessMessage(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:     \"test_corp_id\",\n\t\tCorpSecret: \"test_secret\",\n\t\tAgentID:    1000002,\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"process text message\", func(t *testing.T) {\n\t\tmsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"text\",\n\t\t\tContent:      \"Hello World\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"process image message\", func(t *testing.T) {\n\t\tmsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"image\",\n\t\t\tPicUrl:       \"https://example.com/image.jpg\",\n\t\t\tMediaId:      \"media_123\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"process voice message\", func(t *testing.T) {\n\t\tmsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"voice\",\n\t\t\tMediaId:      \"media_123\",\n\t\t\tFormat:       \"amr\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"skip unsupported message type\", func(t *testing.T) {\n\t\tmsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"video\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"process event message\", func(t *testing.T) {\n\t\tmsg := WeComXMLMessage{\n\t\t\tToUserName:   \"corp_id\",\n\t\t\tFromUserName: \"user123\",\n\t\t\tCreateTime:   1234567890,\n\t\t\tMsgType:      \"event\",\n\t\t\tEvent:        \"subscribe\",\n\t\t\tMsgId:        123456,\n\t\t\tAgentID:      1000002,\n\t\t}\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n}\n\nfunc TestWeComAppHandleWebhook(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:     \"test_corp_id\",\n\t\tCorpSecret: \"test_secret\",\n\t\tAgentID:    1000002,\n\t\tToken:      \"test_token\",\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"GET request calls verification\", func(t *testing.T) {\n\t\techostr := \"test_echostr\"\n\t\tencoded := base64.StdEncoding.EncodeToString([]byte(echostr))\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignatureApp(\"test_token\", timestamp, nonce, encoded)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom-app?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encoded,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t})\n\n\tt.Run(\"POST request calls message callback\", func(t *testing.T) {\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: base64.StdEncoding.EncodeToString([]byte(\"test\")),\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignatureApp(\"test_token\", timestamp, nonce, encryptedWrapper.Encrypt)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom-app?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\t// Should not be method not allowed\n\t\tif w.Code == http.StatusMethodNotAllowed {\n\t\t\tt.Error(\"POST request should not return Method Not Allowed\")\n\t\t}\n\t})\n\n\tt.Run(\"unsupported method\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/webhook/wecom-app\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\tif w.Code != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusMethodNotAllowed)\n\t\t}\n\t})\n}\n\nfunc TestWeComAppHandleHealth(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:     \"test_corp_id\",\n\t\tCorpSecret: \"test_secret\",\n\t\tAgentID:    1000002,\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/health/wecom-app\", nil)\n\tw := httptest.NewRecorder()\n\n\tch.handleHealth(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t}\n\n\tcontentType := w.Header().Get(\"Content-Type\")\n\tif contentType != \"application/json\" {\n\t\tt.Errorf(\"Content-Type = %q, want %q\", contentType, \"application/json\")\n\t}\n\n\tbody := w.Body.String()\n\tif !strings.Contains(body, \"status\") || !strings.Contains(body, \"running\") || !strings.Contains(body, \"has_token\") {\n\t\tt.Errorf(\"response body should contain status, running, and has_token fields, got: %s\", body)\n\t}\n}\n\nfunc TestWeComAppAccessToken(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComAppConfig{\n\t\tCorpID:     \"test_corp_id\",\n\t\tCorpSecret: \"test_secret\",\n\t\tAgentID:    1000002,\n\t}\n\tch, _ := NewWeComAppChannel(cfg, msgBus)\n\n\tt.Run(\"get empty access token initially\", func(t *testing.T) {\n\t\ttoken := ch.getAccessToken()\n\t\tif token != \"\" {\n\t\t\tt.Errorf(\"getAccessToken() = %q, want empty string\", token)\n\t\t}\n\t})\n\n\tt.Run(\"set and get access token\", func(t *testing.T) {\n\t\tch.tokenMu.Lock()\n\t\tch.accessToken = \"test_token_123\"\n\t\tch.tokenExpiry = time.Now().Add(1 * time.Hour)\n\t\tch.tokenMu.Unlock()\n\n\t\ttoken := ch.getAccessToken()\n\t\tif token != \"test_token_123\" {\n\t\t\tt.Errorf(\"getAccessToken() = %q, want %q\", token, \"test_token_123\")\n\t\t}\n\t})\n\n\tt.Run(\"expired token returns empty\", func(t *testing.T) {\n\t\tch.tokenMu.Lock()\n\t\tch.accessToken = \"expired_token\"\n\t\tch.tokenExpiry = time.Now().Add(-1 * time.Hour)\n\t\tch.tokenMu.Unlock()\n\n\t\ttoken := ch.getAccessToken()\n\t\tif token != \"\" {\n\t\t\tt.Errorf(\"getAccessToken() = %q, want empty string for expired token\", token)\n\t\t}\n\t})\n}\n\nfunc TestWeComAppMessageStructures(t *testing.T) {\n\tt.Run(\"WeComTextMessage structure\", func(t *testing.T) {\n\t\tmsg := WeComTextMessage{\n\t\t\tToUser:  \"user123\",\n\t\t\tMsgType: \"text\",\n\t\t\tAgentID: 1000002,\n\t\t}\n\t\tmsg.Text.Content = \"Hello World\"\n\n\t\tif msg.ToUser != \"user123\" {\n\t\t\tt.Errorf(\"ToUser = %q, want %q\", msg.ToUser, \"user123\")\n\t\t}\n\t\tif msg.MsgType != \"text\" {\n\t\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"text\")\n\t\t}\n\t\tif msg.AgentID != 1000002 {\n\t\t\tt.Errorf(\"AgentID = %d, want %d\", msg.AgentID, 1000002)\n\t\t}\n\t\tif msg.Text.Content != \"Hello World\" {\n\t\t\tt.Errorf(\"Text.Content = %q, want %q\", msg.Text.Content, \"Hello World\")\n\t\t}\n\n\t\t// Test JSON marshaling\n\t\tjsonData, err := json.Marshal(msg)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to marshal JSON: %v\", err)\n\t\t}\n\n\t\tvar unmarshaled WeComTextMessage\n\t\terr = json.Unmarshal(jsonData, &unmarshaled)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to unmarshal JSON: %v\", err)\n\t\t}\n\n\t\tif unmarshaled.ToUser != msg.ToUser {\n\t\t\tt.Errorf(\"JSON round-trip failed for ToUser\")\n\t\t}\n\t})\n\n\tt.Run(\"WeComMarkdownMessage structure\", func(t *testing.T) {\n\t\tmsg := WeComMarkdownMessage{\n\t\t\tToUser:  \"user123\",\n\t\t\tMsgType: \"markdown\",\n\t\t\tAgentID: 1000002,\n\t\t}\n\t\tmsg.Markdown.Content = \"# Hello\\nWorld\"\n\n\t\tif msg.Markdown.Content != \"# Hello\\nWorld\" {\n\t\t\tt.Errorf(\"Markdown.Content = %q, want %q\", msg.Markdown.Content, \"# Hello\\nWorld\")\n\t\t}\n\n\t\t// Test JSON marshaling\n\t\tjsonData, err := json.Marshal(msg)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to marshal JSON: %v\", err)\n\t\t}\n\n\t\tif !bytes.Contains(jsonData, []byte(\"markdown\")) {\n\t\t\tt.Error(\"JSON should contain 'markdown' field\")\n\t\t}\n\t})\n\n\tt.Run(\"WeComImageMessage structure\", func(t *testing.T) {\n\t\tmsg := WeComImageMessage{\n\t\t\tToUser:  \"user123\",\n\t\t\tMsgType: \"image\",\n\t\t\tAgentID: 1000002,\n\t\t}\n\t\tmsg.Image.MediaID = \"media_123456\"\n\n\t\tif msg.Image.MediaID != \"media_123456\" {\n\t\t\tt.Errorf(\"Image.MediaID = %q, want %q\", msg.Image.MediaID, \"media_123456\")\n\t\t}\n\t\tif msg.ToUser != \"user123\" {\n\t\t\tt.Errorf(\"ToUser = %q, want %q\", msg.ToUser, \"user123\")\n\t\t}\n\t\tif msg.MsgType != \"image\" {\n\t\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"image\")\n\t\t}\n\t\tif msg.AgentID != 1000002 {\n\t\t\tt.Errorf(\"AgentID = %d, want %d\", msg.AgentID, 1000002)\n\t\t}\n\t})\n\n\tt.Run(\"WeComAccessTokenResponse structure\", func(t *testing.T) {\n\t\tjsonData := `{\n\t\t\t\"errcode\": 0,\n\t\t\t\"errmsg\": \"ok\",\n\t\t\t\"access_token\": \"test_access_token\",\n\t\t\t\"expires_in\": 7200\n\t\t}`\n\n\t\tvar resp WeComAccessTokenResponse\n\t\terr := json.Unmarshal([]byte(jsonData), &resp)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to unmarshal JSON: %v\", err)\n\t\t}\n\n\t\tif resp.ErrCode != 0 {\n\t\t\tt.Errorf(\"ErrCode = %d, want %d\", resp.ErrCode, 0)\n\t\t}\n\t\tif resp.ErrMsg != \"ok\" {\n\t\t\tt.Errorf(\"ErrMsg = %q, want %q\", resp.ErrMsg, \"ok\")\n\t\t}\n\t\tif resp.AccessToken != \"test_access_token\" {\n\t\t\tt.Errorf(\"AccessToken = %q, want %q\", resp.AccessToken, \"test_access_token\")\n\t\t}\n\t\tif resp.ExpiresIn != 7200 {\n\t\t\tt.Errorf(\"ExpiresIn = %d, want %d\", resp.ExpiresIn, 7200)\n\t\t}\n\t})\n\n\tt.Run(\"WeComSendMessageResponse structure\", func(t *testing.T) {\n\t\tjsonData := `{\n\t\t\t\"errcode\": 0,\n\t\t\t\"errmsg\": \"ok\",\n\t\t\t\"invaliduser\": \"\",\n\t\t\t\"invalidparty\": \"\",\n\t\t\t\"invalidtag\": \"\"\n\t\t}`\n\n\t\tvar resp WeComSendMessageResponse\n\t\terr := json.Unmarshal([]byte(jsonData), &resp)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to unmarshal JSON: %v\", err)\n\t\t}\n\n\t\tif resp.ErrCode != 0 {\n\t\t\tt.Errorf(\"ErrCode = %d, want %d\", resp.ErrCode, 0)\n\t\t}\n\t\tif resp.ErrMsg != \"ok\" {\n\t\t\tt.Errorf(\"ErrMsg = %q, want %q\", resp.ErrMsg, \"ok\")\n\t\t}\n\t})\n}\n\nfunc TestWeComAppXMLMessageStructure(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[text]]></MsgType>\n\t<Content><![CDATA[Hello World]]></Content>\n\t<MsgId>1234567890123456</MsgId>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.ToUserName != \"corp_id\" {\n\t\tt.Errorf(\"ToUserName = %q, want %q\", msg.ToUserName, \"corp_id\")\n\t}\n\tif msg.FromUserName != \"user123\" {\n\t\tt.Errorf(\"FromUserName = %q, want %q\", msg.FromUserName, \"user123\")\n\t}\n\tif msg.CreateTime != 1234567890 {\n\t\tt.Errorf(\"CreateTime = %d, want %d\", msg.CreateTime, 1234567890)\n\t}\n\tif msg.MsgType != \"text\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"text\")\n\t}\n\tif msg.Content != \"Hello World\" {\n\t\tt.Errorf(\"Content = %q, want %q\", msg.Content, \"Hello World\")\n\t}\n\tif msg.MsgId != 1234567890123456 {\n\t\tt.Errorf(\"MsgId = %d, want %d\", msg.MsgId, 1234567890123456)\n\t}\n\tif msg.AgentID != 1000002 {\n\t\tt.Errorf(\"AgentID = %d, want %d\", msg.AgentID, 1000002)\n\t}\n}\n\nfunc TestWeComAppXMLMessageImage(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[image]]></MsgType>\n\t<PicUrl><![CDATA[https://example.com/image.jpg]]></PicUrl>\n\t<MediaId><![CDATA[media_123]]></MediaId>\n\t<MsgId>1234567890123456</MsgId>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.MsgType != \"image\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"image\")\n\t}\n\tif msg.PicUrl != \"https://example.com/image.jpg\" {\n\t\tt.Errorf(\"PicUrl = %q, want %q\", msg.PicUrl, \"https://example.com/image.jpg\")\n\t}\n\tif msg.MediaId != \"media_123\" {\n\t\tt.Errorf(\"MediaId = %q, want %q\", msg.MediaId, \"media_123\")\n\t}\n}\n\nfunc TestWeComAppXMLMessageVoice(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[voice]]></MsgType>\n\t<MediaId><![CDATA[media_123]]></MediaId>\n\t<Format><![CDATA[amr]]></Format>\n\t<MsgId>1234567890123456</MsgId>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.MsgType != \"voice\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"voice\")\n\t}\n\tif msg.Format != \"amr\" {\n\t\tt.Errorf(\"Format = %q, want %q\", msg.Format, \"amr\")\n\t}\n}\n\nfunc TestWeComAppXMLMessageLocation(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[location]]></MsgType>\n\t<Location_X>39.9042</Location_X>\n\t<Location_Y>116.4074</Location_Y>\n\t<Scale>16</Scale>\n\t<Label><![CDATA[Beijing]]></Label>\n\t<MsgId>1234567890123456</MsgId>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.MsgType != \"location\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"location\")\n\t}\n\tif msg.LocationX != 39.9042 {\n\t\tt.Errorf(\"LocationX = %f, want %f\", msg.LocationX, 39.9042)\n\t}\n\tif msg.LocationY != 116.4074 {\n\t\tt.Errorf(\"LocationY = %f, want %f\", msg.LocationY, 116.4074)\n\t}\n\tif msg.Scale != 16 {\n\t\tt.Errorf(\"Scale = %d, want %d\", msg.Scale, 16)\n\t}\n\tif msg.Label != \"Beijing\" {\n\t\tt.Errorf(\"Label = %q, want %q\", msg.Label, \"Beijing\")\n\t}\n}\n\nfunc TestWeComAppXMLMessageLink(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[link]]></MsgType>\n\t<Title><![CDATA[Link Title]]></Title>\n\t<Description><![CDATA[Link Description]]></Description>\n\t<Url><![CDATA[https://example.com]]></Url>\n\t<MsgId>1234567890123456</MsgId>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.MsgType != \"link\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"link\")\n\t}\n\tif msg.Title != \"Link Title\" {\n\t\tt.Errorf(\"Title = %q, want %q\", msg.Title, \"Link Title\")\n\t}\n\tif msg.Description != \"Link Description\" {\n\t\tt.Errorf(\"Description = %q, want %q\", msg.Description, \"Link Description\")\n\t}\n\tif msg.Url != \"https://example.com\" {\n\t\tt.Errorf(\"Url = %q, want %q\", msg.Url, \"https://example.com\")\n\t}\n}\n\nfunc TestWeComAppXMLMessageEvent(t *testing.T) {\n\txmlData := `<?xml version=\"1.0\"?>\n<xml>\n\t<ToUserName><![CDATA[corp_id]]></ToUserName>\n\t<FromUserName><![CDATA[user123]]></FromUserName>\n\t<CreateTime>1234567890</CreateTime>\n\t<MsgType><![CDATA[event]]></MsgType>\n\t<Event><![CDATA[subscribe]]></Event>\n\t<EventKey><![CDATA[event_key_123]]></EventKey>\n\t<AgentID>1000002</AgentID>\n</xml>`\n\n\tvar msg WeComXMLMessage\n\terr := xml.Unmarshal([]byte(xmlData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal XML: %v\", err)\n\t}\n\n\tif msg.MsgType != \"event\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"event\")\n\t}\n\tif msg.Event != \"subscribe\" {\n\t\tt.Errorf(\"Event = %q, want %q\", msg.Event, \"subscribe\")\n\t}\n\tif msg.EventKey != \"event_key_123\" {\n\t\tt.Errorf(\"EventKey = %q, want %q\", msg.EventKey, \"event_key_123\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/wecom/bot.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)\n// Uses webhook callback mode - simpler than WeCom App but only supports passive replies\ntype WeComBotChannel struct {\n\t*channels.BaseChannel\n\tconfig        config.WeComConfig\n\tclient        *http.Client\n\tctx           context.Context\n\tcancel        context.CancelFunc\n\tprocessedMsgs *MessageDeduplicator\n}\n\n// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)\ntype WeComBotMessage struct {\n\tMsgID    string `json:\"msgid\"`\n\tAIBotID  string `json:\"aibotid\"`\n\tChatID   string `json:\"chatid\"`   // Session ID, only present for group chats\n\tChatType string `json:\"chattype\"` // \"single\" for DM, \"group\" for group chat\n\tFrom     struct {\n\t\tUserID string `json:\"userid\"`\n\t} `json:\"from\"`\n\tResponseURL string `json:\"response_url\"`\n\tMsgType     string `json:\"msgtype\"` // text, image, voice, file, mixed\n\tText        struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"text\"`\n\tImage struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"image\"`\n\tVoice struct {\n\t\tContent string `json:\"content\"` // Voice to text content\n\t} `json:\"voice\"`\n\tFile struct {\n\t\tURL string `json:\"url\"`\n\t} `json:\"file\"`\n\tMixed struct {\n\t\tMsgItem []struct {\n\t\t\tMsgType string `json:\"msgtype\"`\n\t\t\tText    struct {\n\t\t\t\tContent string `json:\"content\"`\n\t\t\t} `json:\"text\"`\n\t\t\tImage struct {\n\t\t\t\tURL string `json:\"url\"`\n\t\t\t} `json:\"image\"`\n\t\t} `json:\"msg_item\"`\n\t} `json:\"mixed\"`\n\tQuote struct {\n\t\tMsgType string `json:\"msgtype\"`\n\t\tText    struct {\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"text\"`\n\t} `json:\"quote\"`\n}\n\n// WeComBotReplyMessage represents the reply message structure\ntype WeComBotReplyMessage struct {\n\tMsgType string `json:\"msgtype\"`\n\tText    struct {\n\t\tContent string `json:\"content\"`\n\t} `json:\"text,omitempty\"`\n}\n\n// NewWeComBotChannel creates a new WeCom Bot channel instance\nfunc NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {\n\tif cfg.Token == \"\" || cfg.WebhookURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"wecom token and webhook_url are required\")\n\t}\n\n\tbase := channels.NewBaseChannel(\"wecom\", cfg, messageBus, cfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(2048),\n\t\tchannels.WithGroupTrigger(cfg.GroupTrigger),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\t// Client timeout must be >= the configured ReplyTimeout so the\n\t// per-request context deadline is always the effective limit.\n\tclientTimeout := 30 * time.Second\n\tif d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {\n\t\tclientTimeout = d\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\treturn &WeComBotChannel{\n\t\tBaseChannel:   base,\n\t\tconfig:        cfg,\n\t\tclient:        &http.Client{Timeout: clientTimeout},\n\t\tctx:           ctx,\n\t\tcancel:        cancel,\n\t\tprocessedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),\n\t}, nil\n}\n\n// Name returns the channel name\nfunc (c *WeComBotChannel) Name() string {\n\treturn \"wecom\"\n}\n\n// Start initializes the WeCom Bot channel\nfunc (c *WeComBotChannel) Start(ctx context.Context) error {\n\tlogger.InfoC(\"wecom\", \"Starting WeCom Bot channel...\")\n\n\t// Cancel the context created in the constructor to avoid a resource leak.\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"wecom\", \"WeCom Bot channel started\")\n\n\treturn nil\n}\n\n// Stop gracefully stops the WeCom Bot channel\nfunc (c *WeComBotChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"wecom\", \"Stopping WeCom Bot channel...\")\n\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.SetRunning(false)\n\tlogger.InfoC(\"wecom\", \"WeCom Bot channel stopped\")\n\treturn nil\n}\n\n// Send sends a message to WeCom user via webhook API\n// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message\n// For delayed responses, we use the webhook URL\nfunc (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\tlogger.DebugCF(\"wecom\", \"Sending message via webhook\", map[string]any{\n\t\t\"chat_id\": msg.ChatID,\n\t\t\"preview\": utils.Truncate(msg.Content, 100),\n\t})\n\n\treturn c.sendWebhookReply(ctx, msg.ChatID, msg.Content)\n}\n\n// WebhookPath returns the path for registering on the shared HTTP server.\nfunc (c *WeComBotChannel) WebhookPath() string {\n\tif c.config.WebhookPath != \"\" {\n\t\treturn c.config.WebhookPath\n\t}\n\treturn \"/webhook/wecom\"\n}\n\n// ServeHTTP implements http.Handler for the shared HTTP server.\nfunc (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tc.handleWebhook(w, r)\n}\n\n// HealthPath returns the health check endpoint path.\nfunc (c *WeComBotChannel) HealthPath() string {\n\treturn \"/health/wecom\"\n}\n\n// HealthHandler handles health check requests.\nfunc (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {\n\tc.handleHealth(w, r)\n}\n\n// handleWebhook handles incoming webhook requests from WeCom\nfunc (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\n\tif r.Method == http.MethodGet {\n\t\t// Handle verification request\n\t\tc.handleVerification(ctx, w, r)\n\t\treturn\n\t}\n\n\tif r.Method == http.MethodPost {\n\t\t// Handle message callback\n\t\tc.handleMessageCallback(ctx, w, r)\n\t\treturn\n\t}\n\n\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n}\n\n// handleVerification handles the URL verification request from WeCom\nfunc (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {\n\tquery := r.URL.Query()\n\tmsgSignature := query.Get(\"msg_signature\")\n\ttimestamp := query.Get(\"timestamp\")\n\tnonce := query.Get(\"nonce\")\n\techostr := query.Get(\"echostr\")\n\n\tif msgSignature == \"\" || timestamp == \"\" || nonce == \"\" || echostr == \"\" {\n\t\thttp.Error(w, \"Missing parameters\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {\n\t\tlogger.WarnC(\"wecom\", \"Signature verification failed\")\n\t\thttp.Error(w, \"Invalid signature\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\t// Decrypt echostr\n\t// For AIBOT (智能机器人), receiveid should be empty string \"\"\n\t// Reference: https://developer.work.weixin.qq.com/document/path/101033\n\tdecryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, \"\")\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom\", \"Failed to decrypt echostr\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Remove BOM and whitespace as per WeCom documentation\n\t// The response must be plain text without quotes, BOM, or newlines\n\tdecryptedEchoStr = strings.TrimSpace(decryptedEchoStr)\n\tdecryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, \"\\xef\\xbb\\xbf\") // Remove UTF-8 BOM\n\tw.Write([]byte(decryptedEchoStr))\n}\n\n// handleMessageCallback handles incoming messages from WeCom\nfunc (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {\n\tquery := r.URL.Query()\n\tmsgSignature := query.Get(\"msg_signature\")\n\ttimestamp := query.Get(\"timestamp\")\n\tnonce := query.Get(\"nonce\")\n\n\tif msgSignature == \"\" || timestamp == \"\" || nonce == \"\" {\n\t\thttp.Error(w, \"Missing parameters\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Read request body\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\t// Parse XML to get encrypted message\n\tvar encryptedMsg struct {\n\t\tXMLName    xml.Name `xml:\"xml\"`\n\t\tToUserName string   `xml:\"ToUserName\"`\n\t\tEncrypt    string   `xml:\"Encrypt\"`\n\t\tAgentID    string   `xml:\"AgentID\"`\n\t}\n\n\tif err = xml.Unmarshal(body, &encryptedMsg); err != nil {\n\t\tlogger.ErrorCF(\"wecom\", \"Failed to parse XML\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Invalid XML\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Verify signature\n\tif !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {\n\t\tlogger.WarnC(\"wecom\", \"Message signature verification failed\")\n\t\thttp.Error(w, \"Invalid signature\", http.StatusForbidden)\n\t\treturn\n\t}\n\n\t// Decrypt message\n\t// For AIBOT (智能机器人), receiveid should be empty string \"\"\n\t// Reference: https://developer.work.weixin.qq.com/document/path/101033\n\tdecryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, \"\")\n\tif err != nil {\n\t\tlogger.ErrorCF(\"wecom\", \"Failed to decrypt message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Decryption failed\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Parse decrypted JSON message (AIBOT uses JSON format)\n\tvar msg WeComBotMessage\n\tif err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {\n\t\tlogger.ErrorCF(\"wecom\", \"Failed to parse decrypted message\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\thttp.Error(w, \"Invalid message format\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Process the message with the channel's long-lived context (not the HTTP\n\t// request context, which is canceled as soon as we return the response).\n\tgo c.processMessage(c.ctx, msg)\n\n\t// Return success response immediately\n\t// WeCom Bot requires response within configured timeout (default 5 seconds)\n\tw.Write([]byte(\"success\"))\n}\n\n// processMessage processes the received message\nfunc (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {\n\t// Skip unsupported message types\n\tif msg.MsgType != \"text\" && msg.MsgType != \"image\" && msg.MsgType != \"voice\" && msg.MsgType != \"file\" &&\n\t\tmsg.MsgType != \"mixed\" {\n\t\tlogger.DebugCF(\"wecom\", \"Skipping non-supported message type\", map[string]any{\n\t\t\t\"msg_type\": msg.MsgType,\n\t\t})\n\t\treturn\n\t}\n\n\t// Message deduplication: Use msg_id to prevent duplicate processing\n\tmsgID := msg.MsgID\n\tif !c.processedMsgs.MarkMessageProcessed(msgID) {\n\t\tlogger.DebugCF(\"wecom\", \"Skipping duplicate message\", map[string]any{\n\t\t\t\"msg_id\": msgID,\n\t\t})\n\t\treturn\n\t}\n\n\tsenderID := msg.From.UserID\n\n\t// Determine if this is a group chat or direct message\n\t// ChatType: \"single\" for DM, \"group\" for group chat\n\tisGroupChat := msg.ChatType == \"group\"\n\n\tvar chatID, peerKind, peerID string\n\tif isGroupChat {\n\t\t// Group chat: use ChatID as chatID and peer_id\n\t\tchatID = msg.ChatID\n\t\tpeerKind = \"group\"\n\t\tpeerID = msg.ChatID\n\t} else {\n\t\t// Direct message: use senderID as chatID and peer_id\n\t\tchatID = senderID\n\t\tpeerKind = \"direct\"\n\t\tpeerID = senderID\n\t}\n\n\t// Extract content based on message type\n\tvar content string\n\tswitch msg.MsgType {\n\tcase \"text\":\n\t\tcontent = msg.Text.Content\n\tcase \"voice\":\n\t\tcontent = msg.Voice.Content // Voice to text content\n\tcase \"mixed\":\n\t\t// For mixed messages, concatenate text items\n\t\tfor _, item := range msg.Mixed.MsgItem {\n\t\t\tif item.MsgType == \"text\" {\n\t\t\t\tcontent += item.Text.Content\n\t\t\t}\n\t\t}\n\tcase \"image\", \"file\":\n\t\t// For image and file, we don't have text content\n\t\tcontent = \"\"\n\t}\n\n\t// Build metadata\n\tpeer := bus.Peer{Kind: peerKind, ID: peerID}\n\n\t// In group chats, apply unified group trigger filtering\n\tif isGroupChat {\n\t\trespond, cleaned := c.ShouldRespondInGroup(false, content)\n\t\tif !respond {\n\t\t\treturn\n\t\t}\n\t\tcontent = cleaned\n\t}\n\n\tmetadata := map[string]string{\n\t\t\"msg_type\":     msg.MsgType,\n\t\t\"msg_id\":       msg.MsgID,\n\t\t\"platform\":     \"wecom\",\n\t\t\"response_url\": msg.ResponseURL,\n\t}\n\tif isGroupChat {\n\t\tmetadata[\"chat_id\"] = msg.ChatID\n\t\tmetadata[\"sender_id\"] = senderID\n\t}\n\n\tlogger.DebugCF(\"wecom\", \"Received message\", map[string]any{\n\t\t\"sender_id\":     senderID,\n\t\t\"msg_type\":      msg.MsgType,\n\t\t\"peer_kind\":     peerKind,\n\t\t\"is_group_chat\": isGroupChat,\n\t\t\"preview\":       utils.Truncate(content, 50),\n\t})\n\n\t// Build sender info\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"wecom\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"wecom\", senderID),\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\t// Handle the message through the base channel\n\tc.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)\n}\n\n// sendWebhookReply sends a reply using the webhook URL\nfunc (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {\n\treply := WeComBotReplyMessage{\n\t\tMsgType: \"text\",\n\t}\n\treply.Text.Content = content\n\n\tjsonData, err := json.Marshal(reply)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal reply: %w\", err)\n\t}\n\n\t// Use configurable timeout (default 5 seconds)\n\ttimeout := c.config.ReplyTimeout\n\tif timeout <= 0 {\n\t\ttimeout = 5\n\t}\n\n\treqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)\n\tdefer cancel()\n\n\treq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn channels.ClassifyNetError(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, readErr := io.ReadAll(resp.Body)\n\t\tif readErr != nil {\n\t\t\treturn channels.ClassifySendError(\n\t\t\t\tresp.StatusCode,\n\t\t\t\tfmt.Errorf(\"reading webhook error response: %w\", readErr),\n\t\t\t)\n\t\t}\n\t\treturn channels.ClassifySendError(\n\t\t\tresp.StatusCode,\n\t\t\tfmt.Errorf(\"webhook API error: %s\", string(body)),\n\t\t)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Check response\n\tvar result struct {\n\t\tErrCode int    `json:\"errcode\"`\n\t\tErrMsg  string `json:\"errmsg\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif result.ErrCode != 0 {\n\t\treturn fmt.Errorf(\"webhook API error: %s (code: %d)\", result.ErrMsg, result.ErrCode)\n\t}\n\n\treturn nil\n}\n\n// handleHealth handles health check requests\nfunc (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {\n\tstatus := map[string]any{\n\t\t\"status\":  \"ok\",\n\t\t\"running\": c.IsRunning(),\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(status)\n}\n"
  },
  {
    "path": "pkg/channels/wecom/bot_test.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"encoding/xml\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// generateTestAESKey generates a valid test AES key\nfunc generateTestAESKey() string {\n\t// AES key needs to be 32 bytes (256 bits) for AES-256\n\tkey := make([]byte, 32)\n\tfor i := range key {\n\t\tkey[i] = byte(i)\n\t}\n\t// Return base64 encoded key without padding\n\treturn base64.StdEncoding.EncodeToString(key)[:43]\n}\n\n// encryptTestMessage encrypts a message for testing (AIBOT JSON format)\nfunc encryptTestMessage(message, aesKey string) (string, error) {\n\t// Decode AES key\n\tkey, err := base64.StdEncoding.DecodeString(aesKey + \"=\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Prepare message: random(16) + msg_len(4) + msg + receiveid\n\trandom := make([]byte, 0, 16)\n\tfor i := range 16 {\n\t\trandom = append(random, byte(i))\n\t}\n\n\tmsgBytes := []byte(message)\n\treceiveID := []byte(\"test_aibot_id\")\n\n\tmsgLen := uint32(len(msgBytes))\n\tlenBytes := make([]byte, 4)\n\tbinary.BigEndian.PutUint32(lenBytes, msgLen)\n\n\tplainText := append(random, lenBytes...)\n\tplainText = append(plainText, msgBytes...)\n\tplainText = append(plainText, receiveID...)\n\n\t// PKCS7 padding\n\tblockSize := aes.BlockSize\n\tpadding := blockSize - len(plainText)%blockSize\n\tpadText := bytes.Repeat([]byte{byte(padding)}, padding)\n\tplainText = append(plainText, padText...)\n\n\t// Encrypt\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tmode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])\n\tcipherText := make([]byte, len(plainText))\n\tmode.CryptBlocks(cipherText, plainText)\n\n\treturn base64.StdEncoding.EncodeToString(cipherText), nil\n}\n\n// generateSignature generates a signature for testing\nfunc generateSignature(token, timestamp, nonce, msgEncrypt string) string {\n\tparams := []string{token, timestamp, nonce, msgEncrypt}\n\tsort.Strings(params)\n\tstr := strings.Join(params, \"\")\n\thash := sha1.Sum([]byte(str))\n\treturn fmt.Sprintf(\"%x\", hash)\n}\n\nfunc TestNewWeComBotChannel(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"missing token\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:      \"\",\n\t\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t}\n\t\t_, err := NewWeComBotChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing token, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing webhook_url\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:      \"test_token\",\n\t\t\tWebhookURL: \"\",\n\t\t}\n\t\t_, err := NewWeComBotChannel(cfg, msgBus)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for missing webhook_url, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"valid config\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:      \"test_token\",\n\t\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tAllowFrom:  []string{\"user1\", \"user2\"},\n\t\t}\n\t\tch, err := NewWeComBotChannel(cfg, msgBus)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif ch.Name() != \"wecom\" {\n\t\t\tt.Errorf(\"Name() = %q, want %q\", ch.Name(), \"wecom\")\n\t\t}\n\t\tif ch.IsRunning() {\n\t\t\tt.Error(\"new channel should not be running\")\n\t\t}\n\t})\n}\n\nfunc TestWeComBotChannelIsAllowed(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"empty allowlist allows all\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:      \"test_token\",\n\t\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tAllowFrom:  []string{},\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"any_user\") {\n\t\t\tt.Error(\"empty allowlist should allow all users\")\n\t\t}\n\t})\n\n\tt.Run(\"allowlist restricts users\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:      \"test_token\",\n\t\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tAllowFrom:  []string{\"allowed_user\"},\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\t\tif !ch.IsAllowed(\"allowed_user\") {\n\t\t\tt.Error(\"allowed user should pass allowlist check\")\n\t\t}\n\t\tif ch.IsAllowed(\"blocked_user\") {\n\t\t\tt.Error(\"non-allowed user should be blocked\")\n\t\t}\n\t})\n}\n\nfunc TestWeComBotVerifySignature(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComConfig{\n\t\tToken:      \"test_token\",\n\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\tt.Run(\"valid signature\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tmsgEncrypt := \"test_message\"\n\t\texpectedSig := generateSignature(\"test_token\", timestamp, nonce, msgEncrypt)\n\n\t\tif !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {\n\t\t\tt.Error(\"valid signature should pass verification\")\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tmsgEncrypt := \"test_message\"\n\n\t\tif verifySignature(ch.config.Token, \"invalid_sig\", timestamp, nonce, msgEncrypt) {\n\t\t\tt.Error(\"invalid signature should fail verification\")\n\t\t}\n\t})\n\n\tt.Run(\"empty token rejects verification (fail-closed)\", func(t *testing.T) {\n\t\tcfgEmpty := config.WeComConfig{\n\t\t\tToken:      \"\",\n\t\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t}\n\t\tchEmpty := &WeComBotChannel{\n\t\t\tconfig: cfgEmpty,\n\t\t}\n\n\t\tif verifySignature(chEmpty.config.Token, \"any_sig\", \"any_ts\", \"any_nonce\", \"any_msg\") {\n\t\t\tt.Error(\"empty token should reject verification (fail-closed)\")\n\t\t}\n\t})\n}\n\nfunc TestWeComBotDecryptMessage(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\n\tt.Run(\"decrypt without AES key\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:          \"test_token\",\n\t\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tEncodingAESKey: \"\",\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\t\t// Without AES key, message should be base64 decoded only\n\t\tplainText := \"hello world\"\n\t\tencoded := base64.StdEncoding.EncodeToString([]byte(plainText))\n\n\t\tresult, err := decryptMessage(encoded, ch.config.EncodingAESKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif result != plainText {\n\t\t\tt.Errorf(\"decryptMessage() = %q, want %q\", result, plainText)\n\t\t}\n\t})\n\n\tt.Run(\"decrypt with AES key\", func(t *testing.T) {\n\t\taesKey := generateTestAESKey()\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:          \"test_token\",\n\t\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tEncodingAESKey: aesKey,\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\t\toriginalMsg := \"<xml><Content>Hello</Content></xml>\"\n\t\tencrypted, err := encryptTestMessage(originalMsg, aesKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to encrypt test message: %v\", err)\n\t\t}\n\n\t\tresult, err := decryptMessage(encrypted, ch.config.EncodingAESKey)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif result != originalMsg {\n\t\t\tt.Errorf(\"WeComDecryptMessage() = %q, want %q\", result, originalMsg)\n\t\t}\n\t})\n\n\tt.Run(\"invalid base64\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:          \"test_token\",\n\t\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tEncodingAESKey: \"\",\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\t\t_, err := decryptMessage(\"invalid_base64!!!\", ch.config.EncodingAESKey)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for invalid base64, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"invalid AES key\", func(t *testing.T) {\n\t\tcfg := config.WeComConfig{\n\t\t\tToken:          \"test_token\",\n\t\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tEncodingAESKey: \"invalid_key\",\n\t\t}\n\t\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\t\t_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte(\"test\")), ch.config.EncodingAESKey)\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for invalid AES key, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestWeComBotPKCS7Unpad(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected []byte\n\t}{\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tinput:    []byte{},\n\t\t\texpected: []byte{},\n\t\t},\n\t\t{\n\t\t\tname:     \"valid padding 3 bytes\",\n\t\t\tinput:    append([]byte(\"hello\"), bytes.Repeat([]byte{3}, 3)...),\n\t\t\texpected: []byte(\"hello\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"valid padding 16 bytes (full block)\",\n\t\t\tinput:    append([]byte(\"123456789012345\"), bytes.Repeat([]byte{16}, 16)...),\n\t\t\texpected: []byte(\"123456789012345\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid padding larger than data\",\n\t\t\tinput:    []byte{20},\n\t\t\texpected: nil, // should return error\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid padding zero\",\n\t\t\tinput:    append([]byte(\"test\"), byte(0)),\n\t\t\texpected: nil, // should return error\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := pkcs7Unpad(tt.input)\n\t\t\tif tt.expected == nil {\n\t\t\t\t// This case should return an error\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"pkcs7Unpad() expected error for invalid padding, got result: %v\", result)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"pkcs7Unpad() unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !bytes.Equal(result, tt.expected) {\n\t\t\t\tt.Errorf(\"pkcs7Unpad() = %v, want %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestWeComBotHandleVerification(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\taesKey := generateTestAESKey()\n\tcfg := config.WeComConfig{\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: aesKey,\n\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\tt.Run(\"valid verification request\", func(t *testing.T) {\n\t\techostr := \"test_echostr_123\"\n\t\tencryptedEchostr, _ := encryptTestMessage(echostr, aesKey)\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignature(\"test_token\", timestamp, nonce, encryptedEchostr)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encryptedEchostr,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t\tif w.Body.String() != echostr {\n\t\t\tt.Errorf(\"response body = %q, want %q\", w.Body.String(), echostr)\n\t\t}\n\t})\n\n\tt.Run(\"missing parameters\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/webhook/wecom?msg_signature=sig&timestamp=ts\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\techostr := \"test_echostr\"\n\t\tencryptedEchostr, _ := encryptTestMessage(echostr, aesKey)\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom?msg_signature=invalid_sig&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encryptedEchostr,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleVerification(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusForbidden {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusForbidden)\n\t\t}\n\t})\n}\n\nfunc TestWeComBotHandleMessageCallback(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\taesKey := generateTestAESKey()\n\tcfg := config.WeComConfig{\n\t\tToken:          \"test_token\",\n\t\tEncodingAESKey: aesKey,\n\t\tWebhookURL:     \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\trunBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {\n\t\tt.Helper()\n\t\tencrypted, _ := encryptTestMessage(jsonMsg, aesKey)\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: encrypted,\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignature(\"test_token\", timestamp, nonce, encrypted)\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\t\treturn w\n\t}\n\n\tt.Run(\"valid direct message callback\", func(t *testing.T) {\n\t\tw := runBotMessageCallback(t, `{\n\t\t\t\"msgid\": \"test_msg_id_123\",\n\t\t\t\"aibotid\": \"test_aibot_id\",\n\t\t\t\"chattype\": \"single\",\n\t\t\t\"from\": {\"userid\": \"user123\"},\n\t\t\t\"response_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\t\"msgtype\": \"text\",\n\t\t\t\"text\": {\"content\": \"Hello World\"}\n\t\t}`)\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t\tif w.Body.String() != \"success\" {\n\t\t\tt.Errorf(\"response body = %q, want %q\", w.Body.String(), \"success\")\n\t\t}\n\t})\n\n\tt.Run(\"valid group message callback\", func(t *testing.T) {\n\t\tw := runBotMessageCallback(t, `{\n\t\t\t\"msgid\": \"test_msg_id_456\",\n\t\t\t\"aibotid\": \"test_aibot_id\",\n\t\t\t\"chatid\": \"group_chat_id_123\",\n\t\t\t\"chattype\": \"group\",\n\t\t\t\"from\": {\"userid\": \"user456\"},\n\t\t\t\"response_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\t\"msgtype\": \"text\",\n\t\t\t\"text\": {\"content\": \"Hello Group\"}\n\t\t}`)\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t\tif w.Body.String() != \"success\" {\n\t\t\tt.Errorf(\"response body = %q, want %q\", w.Body.String(), \"success\")\n\t\t}\n\t})\n\n\tt.Run(\"missing parameters\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/webhook/wecom?msg_signature=sig\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid XML\", func(t *testing.T) {\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignature(\"test_token\", timestamp, nonce, \"\")\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tstrings.NewReader(\"invalid xml\"),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusBadRequest {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusBadRequest)\n\t\t}\n\t})\n\n\tt.Run(\"invalid signature\", func(t *testing.T) {\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: \"encrypted_data\",\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom?msg_signature=invalid_sig&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleMessageCallback(context.Background(), w, req)\n\n\t\tif w.Code != http.StatusForbidden {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusForbidden)\n\t\t}\n\t})\n}\n\nfunc TestWeComBotProcessMessage(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComConfig{\n\t\tToken:      \"test_token\",\n\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\tt.Run(\"process direct text message\", func(t *testing.T) {\n\t\tmsg := WeComBotMessage{\n\t\t\tMsgID:       \"test_msg_id_123\",\n\t\t\tAIBotID:     \"test_aibot_id\",\n\t\t\tChatType:    \"single\",\n\t\t\tResponseURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tMsgType:     \"text\",\n\t\t}\n\t\tmsg.From.UserID = \"user123\"\n\t\tmsg.Text.Content = \"Hello World\"\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"process group text message\", func(t *testing.T) {\n\t\tmsg := WeComBotMessage{\n\t\t\tMsgID:       \"test_msg_id_456\",\n\t\t\tAIBotID:     \"test_aibot_id\",\n\t\t\tChatID:      \"group_chat_id_123\",\n\t\t\tChatType:    \"group\",\n\t\t\tResponseURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tMsgType:     \"text\",\n\t\t}\n\t\tmsg.From.UserID = \"user456\"\n\t\tmsg.Text.Content = \"Hello Group\"\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"process voice message\", func(t *testing.T) {\n\t\tmsg := WeComBotMessage{\n\t\t\tMsgID:       \"test_msg_id_789\",\n\t\t\tAIBotID:     \"test_aibot_id\",\n\t\t\tChatType:    \"single\",\n\t\t\tResponseURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tMsgType:     \"voice\",\n\t\t}\n\t\tmsg.From.UserID = \"user123\"\n\t\tmsg.Voice.Content = \"Voice message text\"\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n\n\tt.Run(\"skip unsupported message type\", func(t *testing.T) {\n\t\tmsg := WeComBotMessage{\n\t\t\tMsgID:       \"test_msg_id_000\",\n\t\t\tAIBotID:     \"test_aibot_id\",\n\t\t\tChatType:    \"single\",\n\t\t\tResponseURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\tMsgType:     \"video\",\n\t\t}\n\t\tmsg.From.UserID = \"user123\"\n\n\t\t// Should not panic\n\t\tch.processMessage(context.Background(), msg)\n\t})\n}\n\nfunc TestWeComBotHandleWebhook(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComConfig{\n\t\tToken:      \"test_token\",\n\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\tt.Run(\"GET request calls verification\", func(t *testing.T) {\n\t\techostr := \"test_echostr\"\n\t\tencoded := base64.StdEncoding.EncodeToString([]byte(echostr))\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignature(\"test_token\", timestamp, nonce, encoded)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodGet,\n\t\t\t\"/webhook/wecom?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce+\"&echostr=\"+encoded,\n\t\t\tnil,\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t})\n\n\tt.Run(\"POST request calls message callback\", func(t *testing.T) {\n\t\tencryptedWrapper := struct {\n\t\t\tXMLName xml.Name `xml:\"xml\"`\n\t\t\tEncrypt string   `xml:\"Encrypt\"`\n\t\t}{\n\t\t\tEncrypt: base64.StdEncoding.EncodeToString([]byte(\"test\")),\n\t\t}\n\t\twrapperData, _ := xml.Marshal(encryptedWrapper)\n\n\t\ttimestamp := \"1234567890\"\n\t\tnonce := \"test_nonce\"\n\t\tsignature := generateSignature(\"test_token\", timestamp, nonce, encryptedWrapper.Encrypt)\n\n\t\treq := httptest.NewRequest(\n\t\t\thttp.MethodPost,\n\t\t\t\"/webhook/wecom?msg_signature=\"+signature+\"&timestamp=\"+timestamp+\"&nonce=\"+nonce,\n\t\t\tbytes.NewReader(wrapperData),\n\t\t)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\t// Should not be method not allowed\n\t\tif w.Code == http.StatusMethodNotAllowed {\n\t\t\tt.Error(\"POST request should not return Method Not Allowed\")\n\t\t}\n\t})\n\n\tt.Run(\"unsupported method\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/webhook/wecom\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\tch.handleWebhook(w, req)\n\n\t\tif w.Code != http.StatusMethodNotAllowed {\n\t\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusMethodNotAllowed)\n\t\t}\n\t})\n}\n\nfunc TestWeComBotHandleHealth(t *testing.T) {\n\tmsgBus := bus.NewMessageBus()\n\tcfg := config.WeComConfig{\n\t\tToken:      \"test_token\",\n\t\tWebhookURL: \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t}\n\tch, _ := NewWeComBotChannel(cfg, msgBus)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/health/wecom\", nil)\n\tw := httptest.NewRecorder()\n\n\tch.handleHealth(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"status code = %d, want %d\", w.Code, http.StatusOK)\n\t}\n\n\tcontentType := w.Header().Get(\"Content-Type\")\n\tif contentType != \"application/json\" {\n\t\tt.Errorf(\"Content-Type = %q, want %q\", contentType, \"application/json\")\n\t}\n\n\tbody := w.Body.String()\n\tif !strings.Contains(body, \"status\") || !strings.Contains(body, \"running\") {\n\t\tt.Errorf(\"response body should contain status and running fields, got: %s\", body)\n\t}\n}\n\nfunc TestWeComBotReplyMessage(t *testing.T) {\n\tmsg := WeComBotReplyMessage{\n\t\tMsgType: \"text\",\n\t}\n\tmsg.Text.Content = \"Hello World\"\n\n\tif msg.MsgType != \"text\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"text\")\n\t}\n\tif msg.Text.Content != \"Hello World\" {\n\t\tt.Errorf(\"Text.Content = %q, want %q\", msg.Text.Content, \"Hello World\")\n\t}\n}\n\nfunc TestWeComBotMessageStructure(t *testing.T) {\n\tjsonData := `{\n\t\t\"msgid\": \"test_msg_id_123\",\n\t\t\"aibotid\": \"test_aibot_id\",\n\t\t\"chatid\": \"group_chat_id_123\",\n\t\t\"chattype\": \"group\",\n\t\t\"from\": {\"userid\": \"user123\"},\n\t\t\"response_url\": \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\",\n\t\t\"msgtype\": \"text\",\n\t\t\"text\": {\"content\": \"Hello World\"}\n\t}`\n\n\tvar msg WeComBotMessage\n\terr := json.Unmarshal([]byte(jsonData), &msg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to unmarshal JSON: %v\", err)\n\t}\n\n\tif msg.MsgID != \"test_msg_id_123\" {\n\t\tt.Errorf(\"MsgID = %q, want %q\", msg.MsgID, \"test_msg_id_123\")\n\t}\n\tif msg.AIBotID != \"test_aibot_id\" {\n\t\tt.Errorf(\"AIBotID = %q, want %q\", msg.AIBotID, \"test_aibot_id\")\n\t}\n\tif msg.ChatID != \"group_chat_id_123\" {\n\t\tt.Errorf(\"ChatID = %q, want %q\", msg.ChatID, \"group_chat_id_123\")\n\t}\n\tif msg.ChatType != \"group\" {\n\t\tt.Errorf(\"ChatType = %q, want %q\", msg.ChatType, \"group\")\n\t}\n\tif msg.From.UserID != \"user123\" {\n\t\tt.Errorf(\"From.UserID = %q, want %q\", msg.From.UserID, \"user123\")\n\t}\n\tif msg.MsgType != \"text\" {\n\t\tt.Errorf(\"MsgType = %q, want %q\", msg.MsgType, \"text\")\n\t}\n\tif msg.Text.Content != \"Hello World\" {\n\t\tt.Errorf(\"Text.Content = %q, want %q\", msg.Text.Content, \"Hello World\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/wecom/common.go",
    "content": "package wecom\n\nimport (\n\t\"bytes\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// blockSize is the PKCS7 block size used by WeCom (32)\nconst blockSize = 32\n\n// computeSignature computes the WeCom message signature from the given parameters.\n// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest.\nfunc computeSignature(token, timestamp, nonce, encrypt string) string {\n\tparams := []string{token, timestamp, nonce, encrypt}\n\tsort.Strings(params)\n\tstr := strings.Join(params, \"\")\n\thash := sha1.Sum([]byte(str))\n\treturn fmt.Sprintf(\"%x\", hash)\n}\n\n// verifySignature verifies the message signature for WeCom\n// This is a common function used by both WeCom Bot and WeCom App\nfunc verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {\n\tif token == \"\" {\n\t\treturn false\n\t}\n\treturn computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature\n}\n\n// decryptMessage decrypts the encrypted message using AES\n// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id\nfunc decryptMessage(encryptedMsg, encodingAESKey string) (string, error) {\n\treturn decryptMessageWithVerify(encryptedMsg, encodingAESKey, \"\")\n}\n\n// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid\n// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.\nfunc decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {\n\tif encodingAESKey == \"\" {\n\t\t// No encryption, return as is (base64 decode)\n\t\tdecoded, err := base64.StdEncoding.DecodeString(encryptedMsg)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn string(decoded), nil\n\t}\n\n\taesKey, err := decodeWeComAESKey(encodingAESKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to decode message: %w\", err)\n\t}\n\n\tplainText, err := decryptAESCBC(aesKey, cipherText)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn unpackWeComFrame(plainText, receiveid)\n}\n\n// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing \"=\" is\n// appended automatically) and validates that the result is exactly 32 bytes.\n// It is the single place that handles this repeated pattern in both encrypt and decrypt paths.\nfunc decodeWeComAESKey(encodingAESKey string) ([]byte, error) {\n\taesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + \"=\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode AES key: %w\", err)\n\t}\n\tif len(aesKey) != 32 {\n\t\treturn nil, fmt.Errorf(\"invalid AES key length: %d\", len(aesKey))\n\t}\n\treturn aesKey, nil\n}\n\n// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring\n// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the\n// plaintext to a multiple of aes.BlockSize before calling.\nfunc encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) {\n\tblock, err := aes.NewCipher(aesKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create cipher: %w\", err)\n\t}\n\tiv := aesKey[:aes.BlockSize]\n\tciphertext := make([]byte, len(plaintext))\n\tcipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext)\n\treturn ciphertext, nil\n}\n\n// packWeComFrame builds the WeCom wire format:\n//\n//\trandom(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid\nfunc packWeComFrame(msg, receiveid string) ([]byte, error) {\n\trandomBytes := make([]byte, 16)\n\tfor i := range 16 {\n\t\tn, err := rand.Int(rand.Reader, big.NewInt(10))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to generate random: %w\", err)\n\t\t}\n\t\trandomBytes[i] = byte('0' + n.Int64())\n\t}\n\tmsgBytes := []byte(msg)\n\tmsgLenBytes := make([]byte, 4)\n\tbinary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes)))\n\tvar buf bytes.Buffer\n\tbuf.Write(randomBytes)\n\tbuf.Write(msgLenBytes)\n\tbuf.Write(msgBytes)\n\tbuf.WriteString(receiveid)\n\treturn buf.Bytes(), nil\n}\n\n// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame.\n// If receiveid is non-empty it verifies the frame's trailing receiveid field.\nfunc unpackWeComFrame(data []byte, receiveid string) (string, error) {\n\tif len(data) < 20 {\n\t\treturn \"\", fmt.Errorf(\"decrypted frame too short: %d bytes\", len(data))\n\t}\n\tmsgLen := binary.BigEndian.Uint32(data[16:20])\n\tif int(msgLen) > len(data)-20 {\n\t\treturn \"\", fmt.Errorf(\"invalid message length: %d\", msgLen)\n\t}\n\tmsg := data[20 : 20+msgLen]\n\tif receiveid != \"\" && len(data) > 20+int(msgLen) {\n\t\tactualReceiveID := string(data[20+msgLen:])\n\t\tif actualReceiveID != receiveid {\n\t\t\treturn \"\", fmt.Errorf(\"receiveid mismatch: expected %s, got %s\", receiveid, actualReceiveID)\n\t\t}\n\t}\n\treturn string(msg), nil\n}\n\n// decryptAESCBC decrypts ciphertext using AES-CBC with the given key.\n// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext.\nfunc decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) {\n\tif len(ciphertext) == 0 {\n\t\treturn nil, fmt.Errorf(\"ciphertext is empty\")\n\t}\n\tif len(ciphertext)%aes.BlockSize != 0 {\n\t\treturn nil, fmt.Errorf(\"ciphertext length %d is not a multiple of block size\", len(ciphertext))\n\t}\n\tblock, err := aes.NewCipher(aesKey)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create cipher: %w\", err)\n\t}\n\tiv := aesKey[:aes.BlockSize]\n\tplaintext := make([]byte, len(ciphertext))\n\tcipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext)\n\tplaintext, err = pkcs7Unpad(plaintext)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unpad: %w\", err)\n\t}\n\treturn plaintext, nil\n}\n\n// pkcs7Pad adds PKCS7 padding\nfunc pkcs7Pad(data []byte, blockSize int) []byte {\n\tpadding := blockSize - (len(data) % blockSize)\n\tif padding == 0 {\n\t\tpadding = blockSize\n\t}\n\tpadText := bytes.Repeat([]byte{byte(padding)}, padding)\n\treturn append(data, padText...)\n}\n\n// pkcs7Unpad removes PKCS7 padding with validation\nfunc pkcs7Unpad(data []byte) ([]byte, error) {\n\tif len(data) == 0 {\n\t\treturn data, nil\n\t}\n\tpadding := int(data[len(data)-1])\n\t// WeCom uses 32-byte block size for PKCS7 padding\n\tif padding == 0 || padding > blockSize {\n\t\treturn nil, fmt.Errorf(\"invalid padding size: %d\", padding)\n\t}\n\tif padding > len(data) {\n\t\treturn nil, fmt.Errorf(\"padding size larger than data\")\n\t}\n\t// Verify all padding bytes\n\tfor i := range padding {\n\t\tif data[len(data)-1-i] != byte(padding) {\n\t\t\treturn nil, fmt.Errorf(\"invalid padding byte at position %d\", i)\n\t\t}\n\t}\n\treturn data[:len(data)-padding], nil\n}\n"
  },
  {
    "path": "pkg/channels/wecom/dedupe.go",
    "content": "package wecom\n\nimport \"sync\"\n\nconst wecomMaxProcessedMessages = 1000\n\n// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer)\n// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest\n// messages without causing \"amnesia cliffs\" when the limit is reached.\ntype MessageDeduplicator struct {\n\tmu   sync.Mutex\n\tmsgs map[string]bool\n\tring []string\n\tidx  int\n\tmax  int\n}\n\n// NewMessageDeduplicator creates a new deduplicator with the specified capacity.\nfunc NewMessageDeduplicator(maxEntries int) *MessageDeduplicator {\n\tif maxEntries <= 0 {\n\t\tmaxEntries = wecomMaxProcessedMessages\n\t}\n\treturn &MessageDeduplicator{\n\t\tmsgs: make(map[string]bool, maxEntries),\n\t\tring: make([]string, maxEntries),\n\t\tmax:  maxEntries,\n\t}\n}\n\n// MarkMessageProcessed marks msgID as processed and returns false for duplicates.\nfunc (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool {\n\td.mu.Lock()\n\tdefer d.mu.Unlock()\n\n\t// 1. Check for duplicate\n\tif d.msgs[msgID] {\n\t\treturn false\n\t}\n\n\t// 2. Evict the oldest message at our current ring position (if any)\n\toldestID := d.ring[d.idx]\n\tif oldestID != \"\" {\n\t\tdelete(d.msgs, oldestID)\n\t}\n\n\t// 3. Store the new message\n\td.msgs[msgID] = true\n\td.ring[d.idx] = msgID\n\n\t// 4. Advance the circle queue index\n\td.idx = (d.idx + 1) % d.max\n\n\treturn true\n}\n"
  },
  {
    "path": "pkg/channels/wecom/dedupe_test.go",
    "content": "package wecom\n\nimport (\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc TestMessageDeduplicator_DuplicateDetection(t *testing.T) {\n\td := NewMessageDeduplicator(wecomMaxProcessedMessages)\n\n\tif ok := d.MarkMessageProcessed(\"msg-1\"); !ok {\n\t\tt.Fatalf(\"first message should be accepted\")\n\t}\n\n\tif ok := d.MarkMessageProcessed(\"msg-1\"); ok {\n\t\tt.Fatalf(\"duplicate message should be rejected\")\n\t}\n}\n\nfunc TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) {\n\td := NewMessageDeduplicator(wecomMaxProcessedMessages)\n\n\tconst goroutines = 64\n\tvar wg sync.WaitGroup\n\twg.Add(goroutines)\n\n\tresults := make(chan bool, goroutines)\n\tfor i := 0; i < goroutines; i++ {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tresults <- d.MarkMessageProcessed(\"msg-concurrent\")\n\t\t}()\n\t}\n\n\twg.Wait()\n\tclose(results)\n\n\tsuccesses := 0\n\tfor ok := range results {\n\t\tif ok {\n\t\t\tsuccesses++\n\t\t}\n\t}\n\n\tif successes != 1 {\n\t\tt.Fatalf(\"expected exactly 1 successful mark, got %d\", successes)\n\t}\n}\n\nfunc TestMessageDeduplicator_CircularQueueEviction(t *testing.T) {\n\t// Create a deduplicator with a very small capacity to test eviction easily.\n\tcapacity := 3\n\td := NewMessageDeduplicator(capacity)\n\n\t// Fill the queue.\n\td.MarkMessageProcessed(\"msg-1\")\n\td.MarkMessageProcessed(\"msg-2\")\n\td.MarkMessageProcessed(\"msg-3\")\n\n\t// At this point, the queue is full. msg-1 is the oldest.\n\tif len(d.msgs) != 3 {\n\t\tt.Fatalf(\"expected map size to be 3, got %d\", len(d.msgs))\n\t}\n\n\t// This should evict msg-1 and add msg-4.\n\tif ok := d.MarkMessageProcessed(\"msg-4\"); !ok {\n\t\tt.Fatalf(\"msg-4 should be accepted\")\n\t}\n\n\tif len(d.msgs) != 3 {\n\t\tt.Fatalf(\"expected map size to remain at max capacity (3), got %d\", len(d.msgs))\n\t}\n\n\t// msg-1 should now be forgotten (evicted).\n\tif ok := d.MarkMessageProcessed(\"msg-1\"); !ok {\n\t\tt.Fatalf(\"msg-1 should be accepted again because it was evicted\")\n\t}\n\n\t// msg-2 should have been evicted when we added msg-1 back.\n\tif ok := d.MarkMessageProcessed(\"msg-2\"); !ok {\n\t\tt.Fatalf(\"msg-2 should be accepted again because it was evicted\")\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/wecom/init.go",
    "content": "package wecom\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"wecom\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewWeComBotChannel(cfg.Channels.WeCom, b)\n\t})\n\tchannels.RegisterFactory(\"wecom_app\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewWeComAppChannel(cfg.Channels.WeComApp, b)\n\t})\n\tchannels.RegisterFactory(\"wecom_aibot\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp/init.go",
    "content": "package whatsapp\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"whatsapp\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\treturn NewWhatsAppChannel(cfg.Channels.WhatsApp, b)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp/whatsapp.go",
    "content": "package whatsapp\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\ntype WhatsAppChannel struct {\n\t*channels.BaseChannel\n\tconn      *websocket.Conn\n\tconfig    config.WhatsAppConfig\n\turl       string\n\tctx       context.Context\n\tcancel    context.CancelFunc\n\tmu        sync.Mutex\n\tconnected bool\n}\n\nfunc NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {\n\tbase := channels.NewBaseChannel(\n\t\t\"whatsapp\",\n\t\tcfg,\n\t\tbus,\n\t\tcfg.AllowFrom,\n\t\tchannels.WithMaxMessageLength(65536),\n\t\tchannels.WithReasoningChannelID(cfg.ReasoningChannelID),\n\t)\n\n\treturn &WhatsAppChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\turl:         cfg.BridgeURL,\n\t\tconnected:   false,\n\t}, nil\n}\n\nfunc (c *WhatsAppChannel) Start(ctx context.Context) error {\n\tlogger.InfoCF(\"whatsapp\", \"Starting WhatsApp channel\", map[string]any{\n\t\t\"bridge_url\": c.url,\n\t})\n\n\tc.ctx, c.cancel = context.WithCancel(ctx)\n\n\tdialer := websocket.DefaultDialer\n\tdialer.HandshakeTimeout = 10 * time.Second\n\n\tconn, resp, err := dialer.Dial(c.url, nil)\n\tif resp != nil {\n\t\tresp.Body.Close()\n\t}\n\tif err != nil {\n\t\tc.cancel()\n\t\treturn fmt.Errorf(\"failed to connect to WhatsApp bridge: %w\", err)\n\t}\n\n\tc.mu.Lock()\n\tc.conn = conn\n\tc.connected = true\n\tc.mu.Unlock()\n\n\tc.SetRunning(true)\n\tlogger.InfoC(\"whatsapp\", \"WhatsApp channel connected\")\n\n\tgo c.listen()\n\n\treturn nil\n}\n\nfunc (c *WhatsAppChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"whatsapp\", \"Stopping WhatsApp channel...\")\n\n\t// Cancel context first to signal listen goroutine to exit\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif c.conn != nil {\n\t\tif err := c.conn.Close(); err != nil {\n\t\t\tlogger.ErrorCF(\"whatsapp\", \"Error closing WhatsApp connection\", map[string]any{\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t\tc.conn = nil\n\t}\n\n\tc.connected = false\n\tc.SetRunning(false)\n\n\treturn nil\n}\n\nfunc (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\n\t// Check ctx before acquiring lock\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tdefault:\n\t}\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif c.conn == nil {\n\t\treturn fmt.Errorf(\"whatsapp connection not established: %w\", channels.ErrTemporary)\n\t}\n\n\tpayload := map[string]any{\n\t\t\"type\":    \"message\",\n\t\t\"to\":      msg.ChatID,\n\t\t\"content\": msg.Content,\n\t}\n\n\tdata, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal message: %w\", err)\n\t}\n\n\t_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))\n\tif err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {\n\t\t_ = c.conn.SetWriteDeadline(time.Time{})\n\t\treturn fmt.Errorf(\"whatsapp send: %w\", channels.ErrTemporary)\n\t}\n\t_ = c.conn.SetWriteDeadline(time.Time{})\n\n\treturn nil\n}\n\nfunc (c *WhatsAppChannel) listen() {\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t\tc.mu.Lock()\n\t\t\tconn := c.conn\n\t\t\tc.mu.Unlock()\n\n\t\t\tif conn == nil {\n\t\t\t\ttime.Sleep(1 * time.Second)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t_, message, err := conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorCF(\"whatsapp\", \"WhatsApp read error\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\ttime.Sleep(2 * time.Second)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tvar msg map[string]any\n\t\t\tif err := json.Unmarshal(message, &msg); err != nil {\n\t\t\t\tlogger.ErrorCF(\"whatsapp\", \"Failed to unmarshal WhatsApp message\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tmsgType, ok := msg[\"type\"].(string)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif msgType == \"message\" {\n\t\t\t\tc.handleIncomingMessage(msg)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {\n\tsenderID, ok := msg[\"from\"].(string)\n\tif !ok {\n\t\treturn\n\t}\n\n\tchatID, ok := msg[\"chat\"].(string)\n\tif !ok {\n\t\tchatID = senderID\n\t}\n\n\tcontent, ok := msg[\"content\"].(string)\n\tif !ok {\n\t\tcontent = \"\"\n\t}\n\n\tvar mediaPaths []string\n\tif mediaData, ok := msg[\"media\"].([]any); ok {\n\t\tmediaPaths = make([]string, 0, len(mediaData))\n\t\tfor _, m := range mediaData {\n\t\t\tif path, ok := m.(string); ok {\n\t\t\t\tmediaPaths = append(mediaPaths, path)\n\t\t\t}\n\t\t}\n\t}\n\n\tmetadata := make(map[string]string)\n\tvar messageID string\n\tif mid, ok := msg[\"id\"].(string); ok {\n\t\tmessageID = mid\n\t}\n\tif userName, ok := msg[\"from_name\"].(string); ok {\n\t\tmetadata[\"user_name\"] = userName\n\t}\n\n\tvar peer bus.Peer\n\tif chatID == senderID {\n\t\tpeer = bus.Peer{Kind: \"direct\", ID: senderID}\n\t} else {\n\t\tpeer = bus.Peer{Kind: \"group\", ID: chatID}\n\t}\n\n\tlogger.InfoCF(\"whatsapp\", \"WhatsApp message received\", map[string]any{\n\t\t\"sender\":  senderID,\n\t\t\"preview\": utils.Truncate(content, 50),\n\t})\n\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"whatsapp\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"whatsapp\", senderID),\n\t}\n\tif display, ok := metadata[\"user_name\"]; ok {\n\t\tsender.DisplayName = display\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\tc.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp/whatsapp_command_test.go",
    "content": "package whatsapp\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &WhatsAppChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"whatsapp\", config.WhatsAppConfig{}, messageBus, nil),\n\t\tctx:         context.Background(),\n\t}\n\n\tch.handleIncomingMessage(map[string]any{\n\t\t\"type\":    \"message\",\n\t\t\"id\":      \"mid1\",\n\t\t\"from\":    \"user1\",\n\t\t\"chat\":    \"chat1\",\n\t\t\"content\": \"/help\",\n\t})\n\n\tinbound, ok := <-messageBus.InboundChan()\n\tif !ok {\n\t\tt.Fatal(\"expected inbound message to be forwarded\")\n\t}\n\tif inbound.Channel != \"whatsapp\" {\n\t\tt.Fatalf(\"channel=%q\", inbound.Channel)\n\t}\n\tif inbound.Content != \"/help\" {\n\t\tt.Fatalf(\"content=%q\", inbound.Content)\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp_native/init.go",
    "content": "package whatsapp\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc init() {\n\tchannels.RegisterFactory(\"whatsapp_native\", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {\n\t\twaCfg := cfg.Channels.WhatsApp\n\t\tstorePath := waCfg.SessionStorePath\n\t\tif storePath == \"\" {\n\t\t\tstorePath = filepath.Join(cfg.WorkspacePath(), \"whatsapp\")\n\t\t}\n\t\treturn NewWhatsAppNativeChannel(waCfg, b, storePath)\n\t})\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp_native/whatsapp_command_test.go",
    "content": "//go:build whatsapp_native\n\npackage whatsapp\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go.mau.fi/whatsmeow/proto/waE2E\"\n\t\"go.mau.fi/whatsmeow/types\"\n\t\"go.mau.fi/whatsmeow/types/events\"\n\t\"google.golang.org/protobuf/proto\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {\n\tmessageBus := bus.NewMessageBus()\n\tch := &WhatsAppNativeChannel{\n\t\tBaseChannel: channels.NewBaseChannel(\"whatsapp_native\", config.WhatsAppConfig{}, messageBus, nil),\n\t\trunCtx:      context.Background(),\n\t}\n\n\tevt := &events.Message{\n\t\tInfo: types.MessageInfo{\n\t\t\tMessageSource: types.MessageSource{\n\t\t\t\tSender: types.NewJID(\"1001\", types.DefaultUserServer),\n\t\t\t\tChat:   types.NewJID(\"1001\", types.DefaultUserServer),\n\t\t\t},\n\t\t\tID:       \"mid1\",\n\t\t\tPushName: \"Alice\",\n\t\t},\n\t\tMessage: &waE2E.Message{\n\t\t\tConversation: proto.String(\"/new\"),\n\t\t},\n\t}\n\n\tch.handleIncoming(evt)\n\n\tctx, cancel := context.WithTimeout(context.Background(), time.Second)\n\tdefer cancel()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tt.Fatal(\"timeout waiting for message to be forwarded\")\n\t\treturn\n\tcase inbound, ok := <-messageBus.InboundChan():\n\t\tif !ok {\n\t\t\tt.Fatal(\"expected inbound message to be forwarded\")\n\t\t}\n\t\tif inbound.Channel != \"whatsapp_native\" {\n\t\t\tt.Fatalf(\"channel=%q\", inbound.Channel)\n\t\t}\n\t\tif inbound.Content != \"/new\" {\n\t\t\tt.Fatalf(\"content=%q\", inbound.Content)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp_native/whatsapp_native.go",
    "content": "//go:build whatsapp_native\n\n// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage whatsapp\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/mdp/qrterminal/v3\"\n\t\"go.mau.fi/whatsmeow\"\n\t\"go.mau.fi/whatsmeow/proto/waE2E\"\n\t\"go.mau.fi/whatsmeow/store/sqlstore\"\n\t\"go.mau.fi/whatsmeow/types\"\n\t\"go.mau.fi/whatsmeow/types/events\"\n\twaLog \"go.mau.fi/whatsmeow/util/log\"\n\t\"google.golang.org/protobuf/proto\"\n\t_ \"modernc.org/sqlite\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/identity\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tsqliteDriver   = \"sqlite\"\n\twhatsappDBName = \"store.db\"\n\n\treconnectInitial    = 5 * time.Second\n\treconnectMax        = 5 * time.Minute\n\treconnectMultiplier = 2.0\n)\n\n// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).\ntype WhatsAppNativeChannel struct {\n\t*channels.BaseChannel\n\tconfig       config.WhatsAppConfig\n\tstorePath    string\n\tclient       *whatsmeow.Client\n\tcontainer    *sqlstore.Container\n\tmu           sync.Mutex\n\trunCtx       context.Context\n\trunCancel    context.CancelFunc\n\treconnectMu  sync.Mutex\n\treconnecting bool\n\tstopping     atomic.Bool    // set once Stop begins; prevents new wg.Add calls\n\twg           sync.WaitGroup // tracks background goroutines (QR handler, reconnect)\n}\n\n// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.\n// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).\nfunc NewWhatsAppNativeChannel(\n\tcfg config.WhatsAppConfig,\n\tbus *bus.MessageBus,\n\tstorePath string,\n) (channels.Channel, error) {\n\tbase := channels.NewBaseChannel(\"whatsapp_native\", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))\n\tif storePath == \"\" {\n\t\tstorePath = \"whatsapp\"\n\t}\n\tc := &WhatsAppNativeChannel{\n\t\tBaseChannel: base,\n\t\tconfig:      cfg,\n\t\tstorePath:   storePath,\n\t}\n\treturn c, nil\n}\n\nfunc (c *WhatsAppNativeChannel) Start(ctx context.Context) error {\n\tlogger.InfoCF(\"whatsapp\", \"Starting WhatsApp native channel (whatsmeow)\", map[string]any{\"store\": c.storePath})\n\n\t// Reset lifecycle state from any previous Stop() so a restarted channel\n\t// behaves correctly.  Use reconnectMu to be consistent with eventHandler\n\t// and Stop() which coordinate under the same lock.\n\tc.reconnectMu.Lock()\n\tc.stopping.Store(false)\n\tc.reconnecting = false\n\tc.reconnectMu.Unlock()\n\n\tif err := os.MkdirAll(c.storePath, 0o700); err != nil {\n\t\treturn fmt.Errorf(\"create session store dir: %w\", err)\n\t}\n\n\tdbPath := filepath.Join(c.storePath, whatsappDBName)\n\tconnStr := \"file:\" + dbPath + \"?_foreign_keys=on\"\n\n\tdb, err := sql.Open(sqliteDriver, connStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open whatsapp store: %w\", err)\n\t}\n\tdb.SetMaxOpenConns(1)\n\tdb.SetMaxIdleConns(1)\n\tif _, err = db.ExecContext(ctx, \"PRAGMA foreign_keys = ON\"); err != nil {\n\t\t_ = db.Close()\n\t\treturn fmt.Errorf(\"enable foreign keys: %w\", err)\n\t}\n\n\twaLogger := waLog.Stdout(\"WhatsApp\", \"WARN\", true)\n\tcontainer := sqlstore.NewWithDB(db, sqliteDriver, waLogger)\n\tif err = container.Upgrade(ctx); err != nil {\n\t\t_ = db.Close()\n\t\treturn fmt.Errorf(\"open whatsapp store: %w\", err)\n\t}\n\n\tdeviceStore, err := container.GetFirstDevice(ctx)\n\tif err != nil {\n\t\t_ = container.Close()\n\t\treturn fmt.Errorf(\"get device store: %w\", err)\n\t}\n\n\tclient := whatsmeow.NewClient(deviceStore, waLogger)\n\n\t// Create runCtx/runCancel BEFORE registering event handler and starting\n\t// goroutines so that Stop() can cancel them at any time, including during\n\t// the QR-login flow.\n\tc.runCtx, c.runCancel = context.WithCancel(ctx)\n\n\tclient.AddEventHandler(c.eventHandler)\n\n\tc.mu.Lock()\n\tc.container = container\n\tc.client = client\n\tc.mu.Unlock()\n\n\t// cleanupOnError clears struct references and releases resources when\n\t// Start() fails after fields are already assigned.  This prevents\n\t// Stop() from operating on stale references (double-close, disconnect\n\t// of a partially-initialized client, or stray event handler callbacks).\n\tstartOK := false\n\tdefer func() {\n\t\tif startOK {\n\t\t\treturn\n\t\t}\n\t\tc.runCancel()\n\t\tclient.Disconnect()\n\t\tc.mu.Lock()\n\t\tc.client = nil\n\t\tc.container = nil\n\t\tc.mu.Unlock()\n\t\t_ = container.Close()\n\t}()\n\n\tif client.Store.ID == nil {\n\t\tqrChan, err := client.GetQRChannel(c.runCtx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"get QR channel: %w\", err)\n\t\t}\n\t\tif err := client.Connect(); err != nil {\n\t\t\treturn fmt.Errorf(\"connect: %w\", err)\n\t\t}\n\t\t// Handle QR events in a background goroutine so Start() returns\n\t\t// promptly.  The goroutine is tracked via c.wg and respects\n\t\t// c.runCtx for cancellation.\n\t\t// Guard wg.Add with reconnectMu + stopping check (same protocol\n\t\t// as eventHandler) so a concurrent Stop() cannot enter wg.Wait()\n\t\t// while we call wg.Add(1).\n\t\tc.reconnectMu.Lock()\n\t\tif c.stopping.Load() {\n\t\t\tc.reconnectMu.Unlock()\n\t\t\treturn fmt.Errorf(\"channel stopped during QR setup\")\n\t\t}\n\t\tc.wg.Add(1)\n\t\tc.reconnectMu.Unlock()\n\t\tgo func() {\n\t\t\tdefer c.wg.Done()\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase <-c.runCtx.Done():\n\t\t\t\t\treturn\n\t\t\t\tcase evt, ok := <-qrChan:\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif evt.Event == \"code\" {\n\t\t\t\t\t\tlogger.InfoCF(\"whatsapp\", \"Scan this QR code with WhatsApp (Linked Devices):\", nil)\n\t\t\t\t\t\tqrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{\n\t\t\t\t\t\t\tLevel:      qrterminal.L,\n\t\t\t\t\t\t\tWriter:     os.Stdout,\n\t\t\t\t\t\t\tHalfBlocks: true,\n\t\t\t\t\t\t})\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.InfoCF(\"whatsapp\", \"WhatsApp login event\", map[string]any{\"event\": evt.Event})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t} else {\n\t\tif err := client.Connect(); err != nil {\n\t\t\treturn fmt.Errorf(\"connect: %w\", err)\n\t\t}\n\t}\n\n\tstartOK = true\n\tc.SetRunning(true)\n\tlogger.InfoC(\"whatsapp\", \"WhatsApp native channel connected\")\n\treturn nil\n}\n\nfunc (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {\n\tlogger.InfoC(\"whatsapp\", \"Stopping WhatsApp native channel\")\n\n\t// Mark as stopping under reconnectMu so the flag is visible to\n\t// eventHandler atomically with respect to its wg.Add(1) call.\n\t// This closes the TOCTOU window where eventHandler could check\n\t// stopping (false), then Stop sets it true + enters wg.Wait,\n\t// then eventHandler calls wg.Add(1) — causing a panic.\n\tc.reconnectMu.Lock()\n\tc.stopping.Store(true)\n\tc.reconnectMu.Unlock()\n\n\tif c.runCancel != nil {\n\t\tc.runCancel()\n\t}\n\n\t// Disconnect the client first so any blocking Connect()/reconnect loops\n\t// can be interrupted before we wait on the goroutines.\n\tc.mu.Lock()\n\tclient := c.client\n\tcontainer := c.container\n\tc.mu.Unlock()\n\n\tif client != nil {\n\t\tclient.Disconnect()\n\t}\n\n\t// Wait for background goroutines (QR handler, reconnect) to finish in a\n\t// context-aware way so Stop can be bounded by ctx.\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tc.wg.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// All goroutines have finished.\n\tcase <-ctx.Done():\n\t\t// Context canceled or timed out; log and proceed with best-effort cleanup.\n\t\tlogger.WarnC(\"whatsapp\", fmt.Sprintf(\"Stop context canceled before all goroutines finished: %v\", ctx.Err()))\n\t}\n\n\t// Now it is safe to clear and close resources.\n\tc.mu.Lock()\n\tc.client = nil\n\tc.container = nil\n\tc.mu.Unlock()\n\n\tif container != nil {\n\t\t_ = container.Close()\n\t}\n\tc.SetRunning(false)\n\treturn nil\n}\n\nfunc (c *WhatsAppNativeChannel) eventHandler(evt any) {\n\tswitch evt.(type) {\n\tcase *events.Message:\n\t\tc.handleIncoming(evt.(*events.Message))\n\tcase *events.Disconnected:\n\t\tlogger.InfoCF(\"whatsapp\", \"WhatsApp disconnected, will attempt reconnection\", nil)\n\t\tc.reconnectMu.Lock()\n\t\tif c.reconnecting {\n\t\t\tc.reconnectMu.Unlock()\n\t\t\treturn\n\t\t}\n\t\t// Check stopping while holding the lock so the check and wg.Add\n\t\t// are atomic with respect to Stop() setting the flag + calling\n\t\t// wg.Wait(). This prevents the TOCTOU race.\n\t\tif c.stopping.Load() {\n\t\t\tc.reconnectMu.Unlock()\n\t\t\treturn\n\t\t}\n\t\tc.reconnecting = true\n\t\tc.wg.Add(1)\n\t\tc.reconnectMu.Unlock()\n\t\tgo func() {\n\t\t\tdefer c.wg.Done()\n\t\t\tc.reconnectWithBackoff()\n\t\t}()\n\t}\n}\n\nfunc (c *WhatsAppNativeChannel) reconnectWithBackoff() {\n\tdefer func() {\n\t\tc.reconnectMu.Lock()\n\t\tc.reconnecting = false\n\t\tc.reconnectMu.Unlock()\n\t}()\n\n\tbackoff := reconnectInitial\n\tfor {\n\t\tselect {\n\t\tcase <-c.runCtx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tc.mu.Lock()\n\t\tclient := c.client\n\t\tc.mu.Unlock()\n\t\tif client == nil {\n\t\t\treturn\n\t\t}\n\n\t\tlogger.InfoCF(\"whatsapp\", \"WhatsApp reconnecting\", map[string]any{\"backoff\": backoff.String()})\n\t\terr := client.Connect()\n\t\tif err == nil {\n\t\t\tlogger.InfoC(\"whatsapp\", \"WhatsApp reconnected\")\n\t\t\treturn\n\t\t}\n\n\t\tlogger.WarnCF(\"whatsapp\", \"WhatsApp reconnect failed\", map[string]any{\"error\": err.Error()})\n\n\t\tselect {\n\t\tcase <-c.runCtx.Done():\n\t\t\treturn\n\t\tcase <-time.After(backoff):\n\t\t\tif backoff < reconnectMax {\n\t\t\t\tnext := time.Duration(float64(backoff) * reconnectMultiplier)\n\t\t\t\tif next > reconnectMax {\n\t\t\t\t\tnext = reconnectMax\n\t\t\t\t}\n\t\t\t\tbackoff = next\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {\n\tif evt.Message == nil {\n\t\treturn\n\t}\n\tsenderID := evt.Info.Sender.String()\n\tchatID := evt.Info.Chat.String()\n\tcontent := evt.Message.GetConversation()\n\tif content == \"\" && evt.Message.ExtendedTextMessage != nil {\n\t\tcontent = evt.Message.ExtendedTextMessage.GetText()\n\t}\n\tcontent = utils.SanitizeMessageContent(content)\n\n\tif content == \"\" {\n\t\treturn\n\t}\n\n\tvar mediaPaths []string\n\n\tmetadata := make(map[string]string)\n\tmetadata[\"message_id\"] = evt.Info.ID\n\tif evt.Info.PushName != \"\" {\n\t\tmetadata[\"user_name\"] = evt.Info.PushName\n\t}\n\tif evt.Info.Chat.Server == types.GroupServer {\n\t\tmetadata[\"peer_kind\"] = \"group\"\n\t\tmetadata[\"peer_id\"] = chatID\n\t} else {\n\t\tmetadata[\"peer_kind\"] = \"direct\"\n\t\tmetadata[\"peer_id\"] = senderID\n\t}\n\n\tpeerKind := \"direct\"\n\tif evt.Info.Chat.Server == types.GroupServer {\n\t\tpeerKind = \"group\"\n\t}\n\tpeer := bus.Peer{Kind: peerKind, ID: chatID}\n\tmessageID := evt.Info.ID\n\tsender := bus.SenderInfo{\n\t\tPlatform:    \"whatsapp\",\n\t\tPlatformID:  senderID,\n\t\tCanonicalID: identity.BuildCanonicalID(\"whatsapp\", senderID),\n\t\tDisplayName: evt.Info.PushName,\n\t}\n\n\tif !c.IsAllowedSender(sender) {\n\t\treturn\n\t}\n\n\tlogger.DebugCF(\n\t\t\"whatsapp\",\n\t\t\"WhatsApp message received\",\n\t\tmap[string]any{\"sender_id\": senderID, \"content_preview\": utils.Truncate(content, 50)},\n\t)\n\tc.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)\n}\n\nfunc (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {\n\tif !c.IsRunning() {\n\t\treturn channels.ErrNotRunning\n\t}\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tdefault:\n\t}\n\n\tc.mu.Lock()\n\tclient := c.client\n\tc.mu.Unlock()\n\n\tif client == nil || !client.IsConnected() {\n\t\treturn fmt.Errorf(\"whatsapp connection not established: %w\", channels.ErrTemporary)\n\t}\n\n\t// Detect unpaired state: the client is connected (to WhatsApp servers)\n\t// but has not completed QR-login yet, so sending would fail.\n\tif client.Store.ID == nil {\n\t\treturn fmt.Errorf(\"whatsapp not yet paired (QR login pending): %w\", channels.ErrTemporary)\n\t}\n\n\tto, err := parseJID(msg.ChatID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid chat id %q: %w\", msg.ChatID, err)\n\t}\n\n\twaMsg := &waE2E.Message{\n\t\tConversation: proto.String(msg.Content),\n\t}\n\n\tif _, err = client.SendMessage(ctx, to, waMsg); err != nil {\n\t\treturn fmt.Errorf(\"whatsapp send: %w\", channels.ErrTemporary)\n\t}\n\treturn nil\n}\n\n// parseJID converts a chat ID (phone number or JID string) to types.JID.\nfunc parseJID(s string) (types.JID, error) {\n\ts = strings.TrimSpace(s)\n\tif s == \"\" {\n\t\treturn types.JID{}, fmt.Errorf(\"empty chat id\")\n\t}\n\tif strings.Contains(s, \"@\") {\n\t\treturn types.ParseJID(s)\n\t}\n\treturn types.NewJID(s, types.DefaultUserServer), nil\n}\n"
  },
  {
    "path": "pkg/channels/whatsapp_native/whatsapp_native_stub.go",
    "content": "//go:build !whatsapp_native\n\npackage whatsapp\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.\n// Build with: go build -tags whatsapp_native ./cmd/...\nfunc NewWhatsAppNativeChannel(\n\tcfg config.WhatsAppConfig,\n\tbus *bus.MessageBus,\n\tstorePath string,\n) (channels.Channel, error) {\n\treturn nil, fmt.Errorf(\"whatsapp native not compiled in; build with -tags whatsapp_native\")\n}\n"
  },
  {
    "path": "pkg/commands/builtin.go",
    "content": "package commands\n\n// BuiltinDefinitions returns all built-in command definitions.\n// Each command group is defined in its own cmd_*.go file.\n// Definitions are stateless — runtime dependencies are provided\n// via the Runtime parameter passed to handlers at execution time.\nfunc BuiltinDefinitions() []Definition {\n\treturn []Definition{\n\t\tstartCommand(),\n\t\thelpCommand(),\n\t\tshowCommand(),\n\t\tlistCommand(),\n\t\tswitchCommand(),\n\t\tcheckCommand(),\n\t\tclearCommand(),\n\t\treloadCommand(),\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/builtin_test.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc findDefinitionByName(t *testing.T, defs []Definition, name string) Definition {\n\tt.Helper()\n\tfor _, def := range defs {\n\t\tif def.Name == name {\n\t\t\treturn def\n\t\t}\n\t}\n\tt.Fatalf(\"missing /%s definition\", name)\n\treturn Definition{}\n}\n\nfunc TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {\n\tdefs := BuiltinDefinitions()\n\thelpDef := findDefinitionByName(t, defs, \"help\")\n\tif helpDef.Handler == nil {\n\t\tt.Fatalf(\"/help handler should not be nil\")\n\t}\n\n\tvar reply string\n\terr := helpDef.Handler(context.Background(), Request{\n\t\tText: \"/help\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"/help handler error: %v\", err)\n\t}\n\t// Now uses auto-generated EffectiveUsage which includes agents\n\tif !strings.Contains(reply, \"/show [model|channel|agents]\") {\n\t\tt.Fatalf(\"/help reply missing /show usage, got %q\", reply)\n\t}\n\tif !strings.Contains(reply, \"/list [models|channels|agents]\") {\n\t\tt.Fatalf(\"/help reply missing /list usage, got %q\", reply)\n\t}\n}\n\nfunc TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {\n\tdefs := BuiltinDefinitions()\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tcases := []string{\"telegram\", \"whatsapp\"}\n\tfor _, channel := range cases {\n\t\tvar reply string\n\t\tres := ex.Execute(context.Background(), Request{\n\t\t\tChannel: channel,\n\t\t\tText:    \"/show channel\",\n\t\t\tReply: func(text string) error {\n\t\t\t\treply = text\n\t\t\t\treturn nil\n\t\t\t},\n\t\t})\n\t\tif res.Outcome != OutcomeHandled {\n\t\t\tt.Fatalf(\"/show channel on %s: outcome=%v, want=%v\", channel, res.Outcome, OutcomeHandled)\n\t\t}\n\t\twant := \"Current Channel: \" + channel\n\t\tif reply != want {\n\t\t\tt.Fatalf(\"/show channel reply=%q, want=%q\", reply, want)\n\t\t}\n\t}\n}\n\nfunc TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {\n\trt := &Runtime{\n\t\tGetEnabledChannels: func() []string {\n\t\t\treturn []string{\"telegram\", \"slack\"}\n\t\t},\n\t}\n\tdefs := BuiltinDefinitions()\n\tex := NewExecutor(NewRegistry(defs), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/list channels\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"/list channels: outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !strings.Contains(reply, \"telegram\") || !strings.Contains(reply, \"slack\") {\n\t\tt.Fatalf(\"/list channels reply=%q, want telegram and slack\", reply)\n\t}\n}\n\nfunc TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {\n\trt := &Runtime{\n\t\tListAgentIDs: func() []string {\n\t\t\treturn []string{\"default\", \"coder\"}\n\t\t},\n\t}\n\tdefs := BuiltinDefinitions()\n\tex := NewExecutor(NewRegistry(defs), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/show agents\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"/show agents: outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !strings.Contains(reply, \"default\") || !strings.Contains(reply, \"coder\") {\n\t\tt.Fatalf(\"/show agents reply=%q, want agent IDs\", reply)\n\t}\n}\n\nfunc TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {\n\trt := &Runtime{\n\t\tListAgentIDs: func() []string {\n\t\t\treturn []string{\"default\", \"coder\"}\n\t\t},\n\t}\n\tdefs := BuiltinDefinitions()\n\tex := NewExecutor(NewRegistry(defs), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/list agents\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"/list agents: outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !strings.Contains(reply, \"default\") || !strings.Contains(reply, \"coder\") {\n\t\tt.Fatalf(\"/list agents reply=%q, want agent IDs\", reply)\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_check.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\nfunc checkCommand() Definition {\n\treturn Definition{\n\t\tName:        \"check\",\n\t\tDescription: \"Check channel availability\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{\n\t\t\t\tName:        \"channel\",\n\t\t\t\tDescription: \"Check if a channel is available\",\n\t\t\t\tArgsUsage:   \"<name>\",\n\t\t\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\t\t\tif rt == nil || rt.SwitchChannel == nil {\n\t\t\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t\t\t}\n\t\t\t\t\tvalue := nthToken(req.Text, 2)\n\t\t\t\t\tif value == \"\" {\n\t\t\t\t\t\treturn req.Reply(\"Usage: /check channel <name>\")\n\t\t\t\t\t}\n\t\t\t\t\tif err := rt.SwitchChannel(value); err != nil {\n\t\t\t\t\t\treturn req.Reply(err.Error())\n\t\t\t\t\t}\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\"Channel '%s' is available and enabled\", value))\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_clear.go",
    "content": "package commands\n\nimport \"context\"\n\nfunc clearCommand() Definition {\n\treturn Definition{\n\t\tName:        \"clear\",\n\t\tDescription: \"Clear the chat history\",\n\t\tUsage:       \"/clear\",\n\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\tif rt == nil || rt.ClearHistory == nil {\n\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t}\n\t\t\tif err := rt.ClearHistory(); err != nil {\n\t\t\t\treturn req.Reply(\"Failed to clear chat history: \" + err.Error())\n\t\t\t}\n\t\t\treturn req.Reply(\"Chat history cleared!\")\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_help.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc helpCommand() Definition {\n\treturn Definition{\n\t\tName:        \"help\",\n\t\tDescription: \"Show this help message\",\n\t\tUsage:       \"/help\",\n\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\tvar defs []Definition\n\t\t\tif rt != nil && rt.ListDefinitions != nil {\n\t\t\t\tdefs = rt.ListDefinitions()\n\t\t\t} else {\n\t\t\t\tdefs = BuiltinDefinitions()\n\t\t\t}\n\t\t\treturn req.Reply(formatHelpMessage(defs))\n\t\t},\n\t}\n}\n\nfunc formatHelpMessage(defs []Definition) string {\n\tif len(defs) == 0 {\n\t\treturn \"No commands available.\"\n\t}\n\n\tlines := make([]string, 0, len(defs))\n\tfor _, def := range defs {\n\t\tusage := def.EffectiveUsage()\n\t\tif usage == \"\" {\n\t\t\tusage = \"/\" + def.Name\n\t\t}\n\t\tdesc := def.Description\n\t\tif desc == \"\" {\n\t\t\tdesc = \"No description\"\n\t\t}\n\t\tlines = append(lines, fmt.Sprintf(\"%s - %s\", usage, desc))\n\t}\n\treturn strings.Join(lines, \"\\n\")\n}\n"
  },
  {
    "path": "pkg/commands/cmd_list.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc listCommand() Definition {\n\treturn Definition{\n\t\tName:        \"list\",\n\t\tDescription: \"List available options\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{\n\t\t\t\tName:        \"models\",\n\t\t\t\tDescription: \"Configured models\",\n\t\t\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\t\t\tif rt == nil || rt.GetModelInfo == nil {\n\t\t\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t\t\t}\n\t\t\t\t\tname, provider := rt.GetModelInfo()\n\t\t\t\t\tif provider == \"\" {\n\t\t\t\t\t\tprovider = \"configured default\"\n\t\t\t\t\t}\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\n\t\t\t\t\t\t\"Configured Model: %s\\nProvider: %s\\n\\nTo change models, update config.json\",\n\t\t\t\t\t\tname, provider,\n\t\t\t\t\t))\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:        \"channels\",\n\t\t\t\tDescription: \"Enabled channels\",\n\t\t\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\t\t\tif rt == nil || rt.GetEnabledChannels == nil {\n\t\t\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t\t\t}\n\t\t\t\t\tenabled := rt.GetEnabledChannels()\n\t\t\t\t\tif len(enabled) == 0 {\n\t\t\t\t\t\treturn req.Reply(\"No channels enabled\")\n\t\t\t\t\t}\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\"Enabled Channels:\\n- %s\", strings.Join(enabled, \"\\n- \")))\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:        \"agents\",\n\t\t\t\tDescription: \"Registered agents\",\n\t\t\t\tHandler:     agentsHandler(),\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_reload.go",
    "content": "package commands\n\nimport \"context\"\n\nfunc reloadCommand() Definition {\n\treturn Definition{\n\t\tName:        \"reload\",\n\t\tDescription: \"Reload the configuration file\",\n\t\tUsage:       \"/reload\",\n\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\tif rt == nil || rt.ReloadConfig == nil {\n\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t}\n\t\t\tif err := rt.ReloadConfig(); err != nil {\n\t\t\t\treturn req.Reply(\"Failed to reload configuration: \" + err.Error())\n\t\t\t}\n\t\t\treturn req.Reply(\"Config reload triggered!\")\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_show.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\nfunc showCommand() Definition {\n\treturn Definition{\n\t\tName:        \"show\",\n\t\tDescription: \"Show current configuration\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{\n\t\t\t\tName:        \"model\",\n\t\t\t\tDescription: \"Current model and provider\",\n\t\t\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\t\t\tif rt == nil || rt.GetModelInfo == nil {\n\t\t\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t\t\t}\n\t\t\t\t\tname, provider := rt.GetModelInfo()\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\"Current Model: %s (Provider: %s)\", name, provider))\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:        \"channel\",\n\t\t\t\tDescription: \"Current channel\",\n\t\t\t\tHandler: func(_ context.Context, req Request, _ *Runtime) error {\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\"Current Channel: %s\", req.Channel))\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:        \"agents\",\n\t\t\t\tDescription: \"Registered agents\",\n\t\t\t\tHandler:     agentsHandler(),\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_start.go",
    "content": "package commands\n\nimport \"context\"\n\nfunc startCommand() Definition {\n\treturn Definition{\n\t\tName:        \"start\",\n\t\tDescription: \"Start the bot\",\n\t\tUsage:       \"/start\",\n\t\tHandler: func(_ context.Context, req Request, _ *Runtime) error {\n\t\t\treturn req.Reply(\"Hello! I am PicoClaw 🦞\")\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_switch.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\nfunc switchCommand() Definition {\n\treturn Definition{\n\t\tName:        \"switch\",\n\t\tDescription: \"Switch model\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{\n\t\t\t\tName:        \"model\",\n\t\t\t\tDescription: \"Switch to a different model\",\n\t\t\t\tArgsUsage:   \"to <name>\",\n\t\t\t\tHandler: func(_ context.Context, req Request, rt *Runtime) error {\n\t\t\t\t\tif rt == nil || rt.SwitchModel == nil {\n\t\t\t\t\t\treturn req.Reply(unavailableMsg)\n\t\t\t\t\t}\n\t\t\t\t\t// Parse: /switch model to <value>\n\t\t\t\t\tvalue := nthToken(req.Text, 3) // tokens: [/switch, model, to, <value>]\n\t\t\t\t\tif nthToken(req.Text, 2) != \"to\" || value == \"\" {\n\t\t\t\t\t\treturn req.Reply(\"Usage: /switch model to <name>\")\n\t\t\t\t\t}\n\t\t\t\t\toldModel, err := rt.SwitchModel(value)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn req.Reply(err.Error())\n\t\t\t\t\t}\n\t\t\t\t\treturn req.Reply(fmt.Sprintf(\"Switched model from %s to %s\", oldModel, value))\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:        \"channel\",\n\t\t\t\tDescription: \"Moved to /check channel\",\n\t\t\t\tHandler: func(_ context.Context, req Request, _ *Runtime) error {\n\t\t\t\t\treturn req.Reply(\"This command has moved. Please use: /check channel <name>\")\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/cmd_switch_test.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestSwitchModel_Success(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchModel: func(value string) (string, error) {\n\t\t\treturn \"old-model\", nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch model to gpt-4\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\twant := \"Switched model from old-model to gpt-4\"\n\tif reply != want {\n\t\tt.Fatalf(\"reply=%q, want=%q\", reply, want)\n\t}\n}\n\nfunc TestSwitchModel_MissingToKeyword(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchModel: func(value string) (string, error) {\n\t\t\treturn \"old\", nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch model gpt-4\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Usage: /switch model to <name>\" {\n\t\tt.Fatalf(\"reply=%q, want usage message\", reply)\n\t}\n}\n\nfunc TestSwitchModel_MissingValue(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchModel: func(value string) (string, error) {\n\t\t\treturn \"old\", nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch model to\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Usage: /switch model to <name>\" {\n\t\tt.Fatalf(\"reply=%q, want usage message\", reply)\n\t}\n}\n\nfunc TestSwitchModel_Error(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchModel: func(value string) (string, error) {\n\t\t\treturn \"\", fmt.Errorf(\"model not found\")\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch model to bad-model\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"model not found\" {\n\t\tt.Fatalf(\"reply=%q, want error message\", reply)\n\t}\n}\n\nfunc TestSwitchModel_NilDep(t *testing.T) {\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch model to gpt-4\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Command unavailable in current context.\" {\n\t\tt.Fatalf(\"reply=%q, want unavailable message\", reply)\n\t}\n}\n\nfunc TestSwitchChannel_Redirect(t *testing.T) {\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch channel to telegram\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\twant := \"This command has moved. Please use: /check channel <name>\"\n\tif reply != want {\n\t\tt.Fatalf(\"reply=%q, want=%q\", reply, want)\n\t}\n}\n\nfunc TestCheckChannel_Success(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchChannel: func(value string) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/check channel telegram\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\twant := \"Channel 'telegram' is available and enabled\"\n\tif reply != want {\n\t\tt.Fatalf(\"reply=%q, want=%q\", reply, want)\n\t}\n}\n\nfunc TestCheckChannel_Error(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchChannel: func(value string) error {\n\t\t\treturn fmt.Errorf(\"channel '%s' not found\", value)\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/check channel unknown\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"channel 'unknown' not found\" {\n\t\tt.Fatalf(\"reply=%q, want error message\", reply)\n\t}\n}\n\nfunc TestCheckChannel_NilDep(t *testing.T) {\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/check channel telegram\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Command unavailable in current context.\" {\n\t\tt.Fatalf(\"reply=%q, want unavailable message\", reply)\n\t}\n}\n\nfunc TestCheckChannel_MissingValue(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchChannel: func(value string) error {\n\t\t\treturn nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/check channel\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Usage: /check channel <name>\" {\n\t\tt.Fatalf(\"reply=%q, want usage message\", reply)\n\t}\n}\n\nfunc TestSwitch_BangPrefix(t *testing.T) {\n\trt := &Runtime{\n\t\tSwitchModel: func(value string) (string, error) {\n\t\t\treturn \"old\", nil\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"!switch model to gpt-4\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"! prefix: outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Switched model from old to gpt-4\" {\n\t\tt.Fatalf(\"! prefix: reply=%q, want success message\", reply)\n\t}\n}\n\nfunc TestSwitch_NoSubCommand(t *testing.T) {\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText: \"/switch\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\t// Should get usage message from executor's sub-command routing\n\tif reply == \"\" {\n\t\tt.Fatal(\"expected usage reply for bare /switch\")\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/definition.go",
    "content": "package commands\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// SubCommand defines a single sub-command within a parent command.\ntype SubCommand struct {\n\tName        string\n\tDescription string\n\tArgsUsage   string // optional, e.g. \"<session-id>\"\n\tHandler     Handler\n}\n\n// Definition is the single-source metadata and behavior contract for a slash command.\n//\n// Design notes (phase 1):\n//   - Every channel reads command shape from this type instead of keeping local copies.\n//   - Visibility is global: all definitions are considered available to all channels.\n//   - Platform menu registration (for example Telegram BotCommand) also derives from this\n//     same definition so UI labels and runtime behavior stay aligned.\ntype Definition struct {\n\tName        string\n\tDescription string\n\tUsage       string // for simple commands; ignored when SubCommands is set\n\tAliases     []string\n\tSubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers\n\tHandler     Handler      // for simple commands without sub-commands\n}\n\n// EffectiveUsage returns the usage string. When SubCommands are present,\n// it is auto-generated from sub-command names so metadata and behavior\n// cannot drift.\nfunc (d Definition) EffectiveUsage() string {\n\tif len(d.SubCommands) == 0 {\n\t\treturn d.Usage\n\t}\n\tnames := make([]string, 0, len(d.SubCommands))\n\tfor _, sc := range d.SubCommands {\n\t\tname := sc.Name\n\t\tif sc.ArgsUsage != \"\" {\n\t\t\tname += \" \" + sc.ArgsUsage\n\t\t}\n\t\tnames = append(names, name)\n\t}\n\treturn fmt.Sprintf(\"/%s [%s]\", d.Name, strings.Join(names, \"|\"))\n}\n"
  },
  {
    "path": "pkg/commands/definition_test.go",
    "content": "package commands\n\nimport (\n\t\"testing\"\n)\n\nfunc TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) {\n\td := Definition{Name: \"start\", Usage: \"/start\"}\n\tif got := d.EffectiveUsage(); got != \"/start\" {\n\t\tt.Fatalf(\"EffectiveUsage()=%q, want %q\", got, \"/start\")\n\t}\n}\n\nfunc TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) {\n\td := Definition{\n\t\tName: \"show\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{Name: \"model\"},\n\t\t\t{Name: \"channel\"},\n\t\t\t{Name: \"agents\"},\n\t\t},\n\t}\n\twant := \"/show [model|channel|agents]\"\n\tif got := d.EffectiveUsage(); got != want {\n\t\tt.Fatalf(\"EffectiveUsage()=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) {\n\td := Definition{\n\t\tName: \"session\",\n\t\tSubCommands: []SubCommand{\n\t\t\t{Name: \"list\"},\n\t\t\t{Name: \"resume\", ArgsUsage: \"<id>\"},\n\t\t},\n\t}\n\twant := \"/session [list|resume <id>]\"\n\tif got := d.EffectiveUsage(); got != want {\n\t\tt.Fatalf(\"EffectiveUsage()=%q, want %q\", got, want)\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/executor.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\ntype Outcome int\n\nconst (\n\t// OutcomePassthrough means this input should continue through normal agent flow.\n\tOutcomePassthrough Outcome = iota\n\t// OutcomeHandled means a command handler executed (with or without handler error).\n\tOutcomeHandled\n)\n\ntype ExecuteResult struct {\n\tOutcome Outcome\n\tCommand string\n\tErr     error\n}\n\ntype Executor struct {\n\treg *Registry\n\trt  *Runtime\n}\n\nfunc NewExecutor(reg *Registry, rt *Runtime) *Executor {\n\treturn &Executor{reg: reg, rt: rt}\n}\n\n// Execute implements a two-state command decision:\n// 1) handled: execute command immediately;\n// 2) passthrough: not a command or intentionally deferred to agent logic.\nfunc (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {\n\tcmdName, ok := parseCommandName(req.Text)\n\tif !ok {\n\t\treturn ExecuteResult{Outcome: OutcomePassthrough}\n\t}\n\n\tif e == nil || e.reg == nil {\n\t\treturn ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}\n\t}\n\n\tdef, found := e.reg.Lookup(cmdName)\n\tif !found {\n\t\treturn ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}\n\t}\n\n\treturn e.executeDefinition(ctx, req, def)\n}\n\nfunc (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult {\n\t// Ensure Reply is always non-nil so handlers don't need to check.\n\tif req.Reply == nil {\n\t\treq.Reply = func(string) error { return nil }\n\t}\n\n\t// Simple command — no sub-commands\n\tif len(def.SubCommands) == 0 {\n\t\tif def.Handler == nil {\n\t\t\treturn ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}\n\t\t}\n\t\terr := def.Handler(ctx, req, e.rt)\n\t\treturn ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}\n\t}\n\n\t// Sub-command routing\n\tsubName := nthToken(req.Text, 1)\n\tif subName == \"\" {\n\t\terr := req.Reply(\"Usage: \" + def.EffectiveUsage())\n\t\treturn ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}\n\t}\n\n\tnormalized := normalizeCommandName(subName)\n\tfor _, sc := range def.SubCommands {\n\t\tif normalizeCommandName(sc.Name) == normalized {\n\t\t\tif sc.Handler == nil {\n\t\t\t\treturn ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}\n\t\t\t}\n\t\t\terr := sc.Handler(ctx, req, e.rt)\n\t\t\treturn ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}\n\t\t}\n\t}\n\n\t// Unknown sub-command\n\terr := req.Reply(fmt.Sprintf(\"Unknown option: %s. Usage: %s\", subName, def.EffectiveUsage()))\n\treturn ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}\n}\n"
  },
  {
    "path": "pkg/commands/executor_test.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {\n\tdefs := []Definition{{Name: \"show\"}}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"whatsapp\", Text: \"/show\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n}\n\nfunc TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {\n\tdefs := []Definition{{Name: \"show\"}}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/unknown\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n}\n\nfunc TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) {\n\tcalled := false\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"help\",\n\t\t\tHandler: func(context.Context, Request, *Runtime) error {\n\t\t\t\tcalled = true\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/help@my_bot\"})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !called {\n\t\tt.Fatalf(\"expected handler to be called\")\n\t}\n}\n\nfunc TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {\n\tdefs := []Definition{\n\t\t{\n\t\t\tName:    \"show\",\n\t\t\tAliases: []string{\"display\"},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"whatsapp\", Text: \"/display\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n\tif res.Command != \"show\" {\n\t\tt.Fatalf(\"command=%q, want=%q\", res.Command, \"show\")\n\t}\n}\n\nfunc TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {\n\tcalled := false\n\tdefs := []Definition{\n\t\t{\n\t\t\tName:    \"clear\",\n\t\t\tAliases: []string{\"reset\"},\n\t\t\tHandler: func(context.Context, Request, *Runtime) error {\n\t\t\t\tcalled = true\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/reset\"})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif res.Command != \"clear\" {\n\t\tt.Fatalf(\"command=%q, want=%q\", res.Command, \"clear\")\n\t}\n\tif !called {\n\t\tt.Fatalf(\"expected handler to be called\")\n\t}\n}\n\nfunc TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) {\n\tdefs := []Definition{\n\t\t{Name: \"placeholder\"},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/placeholder list\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n\tif res.Command != \"placeholder\" {\n\t\tt.Fatalf(\"command=%q, want=%q\", res.Command, \"placeholder\")\n\t}\n}\n\nfunc TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {\n\t// With Lookup-based dispatch, the first registered definition for a name wins.\n\t// A definition with nil Handler and no SubCommands returns Passthrough.\n\tdefs := []Definition{\n\t\t{Name: \"placeholder\"},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/placeholder\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n\tif res.Command != \"placeholder\" {\n\t\tt.Fatalf(\"command=%q, want=%q\", res.Command, \"placeholder\")\n\t}\n}\n\nfunc TestExecutor_HandlerErrorIsPropagated(t *testing.T) {\n\twantErr := errors.New(\"handler failed\")\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"help\",\n\t\t\tHandler: func(context.Context, Request, *Runtime) error {\n\t\t\t\treturn wantErr\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"/help\"})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !errors.Is(res.Err, wantErr) {\n\t\tt.Fatalf(\"err=%v, want=%v\", res.Err, wantErr)\n\t}\n}\n\nfunc TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {\n\tcalled := false\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"help\",\n\t\t\tHandler: func(context.Context, Request, *Runtime) error {\n\t\t\t\tcalled = true\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Channel: \"telegram\", Text: \"!HELP\"})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !called {\n\t\tt.Fatalf(\"expected handler to be called\")\n\t}\n}\n\nfunc TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {\n\tmodelCalled := false\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"show\",\n\t\t\tSubCommands: []SubCommand{\n\t\t\t\t{Name: \"model\", Handler: func(_ context.Context, _ Request, _ *Runtime) error {\n\t\t\t\t\tmodelCalled = true\n\t\t\t\t\treturn nil\n\t\t\t\t}},\n\t\t\t\t{Name: \"channel\"},\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Text: \"/show model\"})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !modelCalled {\n\t\tt.Fatal(\"model sub-command handler was not called\")\n\t}\n}\n\nfunc TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"show\",\n\t\t\tSubCommands: []SubCommand{\n\t\t\t\t{Name: \"model\"},\n\t\t\t\t{Name: \"channel\"},\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText:  \"/show\",\n\t\tReply: func(text string) error { reply = text; return nil },\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif reply != \"Usage: /show [model|channel]\" {\n\t\tt.Fatalf(\"reply=%q, want usage message\", reply)\n\t}\n}\n\nfunc TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"show\",\n\t\t\tSubCommands: []SubCommand{\n\t\t\t\t{Name: \"model\"},\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tText:  \"/show foobar\",\n\t\tReply: func(text string) error { reply = text; return nil },\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif !strings.Contains(reply, \"foobar\") {\n\t\tt.Fatalf(\"reply=%q, should mention unknown sub-command\", reply)\n\t}\n}\n\nfunc TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {\n\tdefs := []Definition{\n\t\t{\n\t\t\tName: \"show\",\n\t\t\tSubCommands: []SubCommand{\n\t\t\t\t{Name: \"model\"}, // nil Handler\n\t\t\t},\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(defs), nil)\n\n\tres := ex.Execute(context.Background(), Request{Text: \"/show model\"})\n\tif res.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"outcome=%v, want=%v\", res.Outcome, OutcomePassthrough)\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/handler_agents.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// agentsHandler returns a shared handler for both /show agents and /list agents.\nfunc agentsHandler() Handler {\n\treturn func(_ context.Context, req Request, rt *Runtime) error {\n\t\tif rt == nil || rt.ListAgentIDs == nil {\n\t\t\treturn req.Reply(unavailableMsg)\n\t\t}\n\t\tids := rt.ListAgentIDs()\n\t\tif len(ids) == 0 {\n\t\t\treturn req.Reply(\"No agents registered\")\n\t\t}\n\t\treturn req.Reply(fmt.Sprintf(\"Registered agents: %s\", strings.Join(ids, \", \")))\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/registry.go",
    "content": "package commands\n\ntype Registry struct {\n\tdefs  []Definition\n\tindex map[string]int\n}\n\n// NewRegistry stores the canonical command set used by both dispatch and\n// optional platform registration adapters.\nfunc NewRegistry(defs []Definition) *Registry {\n\tstored := make([]Definition, len(defs))\n\tcopy(stored, defs)\n\n\tindex := make(map[string]int, len(stored)*2)\n\tfor i, def := range stored {\n\t\tregisterCommandName(index, def.Name, i)\n\t\tfor _, alias := range def.Aliases {\n\t\t\tregisterCommandName(index, alias, i)\n\t\t}\n\t}\n\n\treturn &Registry{defs: stored, index: index}\n}\n\n// Definitions returns all registered command definitions.\n// Command availability is global and no longer channel-scoped.\nfunc (r *Registry) Definitions() []Definition {\n\tout := make([]Definition, len(r.defs))\n\tcopy(out, r.defs)\n\treturn out\n}\n\n// Lookup returns a command definition by normalized command name or alias.\nfunc (r *Registry) Lookup(name string) (Definition, bool) {\n\tkey := normalizeCommandName(name)\n\tif key == \"\" {\n\t\treturn Definition{}, false\n\t}\n\tidx, ok := r.index[key]\n\tif !ok {\n\t\treturn Definition{}, false\n\t}\n\treturn r.defs[idx], true\n}\n\nfunc registerCommandName(index map[string]int, name string, defIndex int) {\n\tkey := normalizeCommandName(name)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tif _, exists := index[key]; exists {\n\t\treturn\n\t}\n\tindex[key] = defIndex\n}\n"
  },
  {
    "path": "pkg/commands/registry_test.go",
    "content": "package commands\n\nimport \"testing\"\n\nfunc TestRegistry_Definitions_ReturnsCopy(t *testing.T) {\n\tdefs := []Definition{\n\t\t{Name: \"help\", Description: \"Show help\"},\n\t\t{Name: \"admin\", Description: \"Admin command\"},\n\t}\n\tr := NewRegistry(defs)\n\n\tgot := r.Definitions()\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"definitions len = %d, want 2\", len(got))\n\t}\n\n\tgot[0].Name = \"mutated\"\n\tagain := r.Definitions()\n\tif again[0].Name != \"help\" {\n\t\tt.Fatalf(\"registry should not be mutated by caller, got first name %q\", again[0].Name)\n\t}\n}\n\nfunc TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) {\n\tr := NewRegistry([]Definition{\n\t\t{Name: \"Help\", Aliases: []string{\"Assist\"}},\n\t\t{Name: \"List\"},\n\t})\n\n\tdef, ok := r.Lookup(\"help\")\n\tif !ok || def.Name != \"Help\" {\n\t\tt.Fatalf(\"lookup by lowercase name failed: ok=%v def=%+v\", ok, def)\n\t}\n\n\tdef, ok = r.Lookup(\"HELP\")\n\tif !ok || def.Name != \"Help\" {\n\t\tt.Fatalf(\"lookup by uppercase name failed: ok=%v def=%+v\", ok, def)\n\t}\n\n\tdef, ok = r.Lookup(\"assist\")\n\tif !ok || def.Name != \"Help\" {\n\t\tt.Fatalf(\"lookup by lowercase alias failed: ok=%v def=%+v\", ok, def)\n\t}\n\n\tdef, ok = r.Lookup(\"ASSIST\")\n\tif !ok || def.Name != \"Help\" {\n\t\tt.Fatalf(\"lookup by uppercase alias failed: ok=%v def=%+v\", ok, def)\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/request.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"strings\"\n)\n\ntype Handler func(ctx context.Context, req Request, rt *Runtime) error\n\ntype Request struct {\n\tChannel  string\n\tChatID   string\n\tSenderID string\n\tText     string\n\tReply    func(text string) error\n}\n\nconst unavailableMsg = \"Command unavailable in current context.\"\n\nvar commandPrefixes = []string{\"/\", \"!\"}\n\n// parseCommandName accepts \"/name\", \"!name\", and Telegram's \"/name@bot\", then\n// normalizes to lowercase command names.\nfunc parseCommandName(input string) (string, bool) {\n\ttoken := nthToken(input, 0)\n\tif token == \"\" {\n\t\treturn \"\", false\n\t}\n\n\tname, ok := trimCommandPrefix(token)\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\tif i := strings.Index(name, \"@\"); i >= 0 {\n\t\tname = name[:i]\n\t}\n\tname = normalizeCommandName(name)\n\tif name == \"\" {\n\t\treturn \"\", false\n\t}\n\treturn name, true\n}\n\nfunc trimCommandPrefix(token string) (string, bool) {\n\tfor _, prefix := range commandPrefixes {\n\t\tif strings.HasPrefix(token, prefix) {\n\t\t\treturn strings.TrimPrefix(token, prefix), true\n\t\t}\n\t}\n\treturn \"\", false\n}\n\n// HasCommandPrefix returns true if the input starts with a recognized\n// command prefix (e.g. \"/\" or \"!\").\nfunc HasCommandPrefix(input string) bool {\n\ttoken := nthToken(input, 0)\n\tif token == \"\" {\n\t\treturn false\n\t}\n\t_, ok := trimCommandPrefix(token)\n\treturn ok\n}\n\n// nthToken returns the 0-indexed token from whitespace-split input.\nfunc nthToken(input string, n int) string {\n\tparts := strings.Fields(strings.TrimSpace(input))\n\tif n >= len(parts) {\n\t\treturn \"\"\n\t}\n\treturn parts[n]\n}\n\nfunc normalizeCommandName(name string) string {\n\treturn strings.ToLower(strings.TrimSpace(name))\n}\n"
  },
  {
    "path": "pkg/commands/request_test.go",
    "content": "package commands\n\nimport \"testing\"\n\nfunc TestHasCommandPrefix(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  bool\n\t}{\n\t\t{\"/help\", true},\n\t\t{\"!help\", true},\n\t\t{\"/switch model to gpt-4\", true},\n\t\t{\"!switch model to gpt-4\", true},\n\t\t{\"hello\", false},\n\t\t{\"\", false},\n\t\t{\"   \", false},\n\t\t{\"hello /world\", false},\n\t\t{\"/\", true},\n\t\t{\"!\", true},\n\t\t{\"  /help\", true},\n\t}\n\tfor _, tt := range tests {\n\t\tgot := HasCommandPrefix(tt.input)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"HasCommandPrefix(%q) = %v, want %v\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/commands/runtime.go",
    "content": "package commands\n\nimport \"github.com/sipeed/picoclaw/pkg/config\"\n\n// Runtime provides runtime dependencies to command handlers. It is constructed\n// per-request by the agent loop so that per-request state (like session scope)\n// can coexist with long-lived callbacks (like GetModelInfo).\ntype Runtime struct {\n\tConfig             *config.Config\n\tGetModelInfo       func() (name, provider string)\n\tListAgentIDs       func() []string\n\tListDefinitions    func() []Definition\n\tGetEnabledChannels func() []string\n\tSwitchModel        func(value string) (oldModel string, err error)\n\tSwitchChannel      func(value string) error\n\tClearHistory       func() error\n\tReloadConfig       func() error\n}\n"
  },
  {
    "path": "pkg/commands/show_list_handlers_test.go",
    "content": "package commands\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestShowListHandlers_ChannelPolicy(t *testing.T) {\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil)\n\n\tvar telegramReply string\n\thandled := ex.Execute(context.Background(), Request{\n\t\tChannel: \"telegram\",\n\t\tText:    \"/show channel\",\n\t\tReply: func(text string) error {\n\t\t\ttelegramReply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif handled.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"telegram /show outcome=%v, want=%v\", handled.Outcome, OutcomeHandled)\n\t}\n\tif telegramReply != \"Current Channel: telegram\" {\n\t\tt.Fatalf(\"telegram /show reply=%q, want=%q\", telegramReply, \"Current Channel: telegram\")\n\t}\n\n\tvar whatsappReply string\n\thandledWhatsApp := ex.Execute(context.Background(), Request{\n\t\tChannel: \"whatsapp\",\n\t\tText:    \"/show channel\",\n\t\tReply: func(text string) error {\n\t\t\twhatsappReply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif handledWhatsApp.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"whatsapp /show outcome=%v, want=%v\", handledWhatsApp.Outcome, OutcomeHandled)\n\t}\n\tif handledWhatsApp.Command != \"show\" {\n\t\tt.Fatalf(\"whatsapp /show command=%q, want=%q\", handledWhatsApp.Command, \"show\")\n\t}\n\tif whatsappReply != \"Current Channel: whatsapp\" {\n\t\tt.Fatalf(\"whatsapp /show reply=%q, want=%q\", whatsappReply, \"Current Channel: whatsapp\")\n\t}\n\n\tpassthrough := ex.Execute(context.Background(), Request{\n\t\tChannel: \"whatsapp\",\n\t\tText:    \"/foo\",\n\t})\n\tif passthrough.Outcome != OutcomePassthrough {\n\t\tt.Fatalf(\"whatsapp /foo outcome=%v, want=%v\", passthrough.Outcome, OutcomePassthrough)\n\t}\n\tif passthrough.Command != \"foo\" {\n\t\tt.Fatalf(\"whatsapp /foo command=%q, want=%q\", passthrough.Command, \"foo\")\n\t}\n}\n\nfunc TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {\n\trt := &Runtime{\n\t\tGetEnabledChannels: func() []string {\n\t\t\treturn []string{\"telegram\"}\n\t\t},\n\t}\n\tex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)\n\n\tvar reply string\n\tres := ex.Execute(context.Background(), Request{\n\t\tChannel: \"whatsapp\",\n\t\tText:    \"/list channels\",\n\t\tReply: func(text string) error {\n\t\t\treply = text\n\t\t\treturn nil\n\t\t},\n\t})\n\tif res.Outcome != OutcomeHandled {\n\t\tt.Fatalf(\"whatsapp /list outcome=%v, want=%v\", res.Outcome, OutcomeHandled)\n\t}\n\tif res.Command != \"list\" {\n\t\tt.Fatalf(\"whatsapp /list command=%q, want=%q\", res.Command, \"list\")\n\t}\n\tif !strings.Contains(reply, \"telegram\") {\n\t\tt.Fatalf(\"whatsapp /list reply=%q, expected enabled channels content\", reply)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/config.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync/atomic\"\n\n\t\"github.com/caarlos0/env/v11\"\n\n\t\"github.com/sipeed/picoclaw/pkg/credential\"\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n)\n\n// rrCounter is a global counter for round-robin load balancing across models.\nvar rrCounter atomic.Uint64\n\n// FlexibleStringSlice is a []string that also accepts JSON numbers,\n// so allow_from can contain both \"123\" and 123.\n// It also supports parsing comma-separated strings from environment variables,\n// including both English (,) and Chinese (，) commas.\ntype FlexibleStringSlice []string\n\nfunc (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {\n\t// Try []string first\n\tvar ss []string\n\tif err := json.Unmarshal(data, &ss); err == nil {\n\t\t*f = ss\n\t\treturn nil\n\t}\n\n\t// Try []interface{} to handle mixed types\n\tvar raw []any\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\n\tresult := make([]string, 0, len(raw))\n\tfor _, v := range raw {\n\t\tswitch val := v.(type) {\n\t\tcase string:\n\t\t\tresult = append(result, val)\n\t\tcase float64:\n\t\t\tresult = append(result, fmt.Sprintf(\"%.0f\", val))\n\t\tdefault:\n\t\t\tresult = append(result, fmt.Sprintf(\"%v\", val))\n\t\t}\n\t}\n\t*f = result\n\treturn nil\n}\n\n// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.\n// It handles comma-separated values with both English (,) and Chinese (，) commas.\nfunc (f *FlexibleStringSlice) UnmarshalText(text []byte) error {\n\tif len(text) == 0 {\n\t\t*f = nil\n\t\treturn nil\n\t}\n\n\ts := string(text)\n\t// Replace Chinese comma with English comma, then split\n\ts = strings.ReplaceAll(s, \"，\", \",\")\n\tparts := strings.Split(s, \",\")\n\n\tresult := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part != \"\" {\n\t\t\tresult = append(result, part)\n\t\t}\n\t}\n\t*f = result\n\treturn nil\n}\n\ntype Config struct {\n\tAgents    AgentsConfig    `json:\"agents\"`\n\tBindings  []AgentBinding  `json:\"bindings,omitempty\"`\n\tSession   SessionConfig   `json:\"session,omitempty\"`\n\tChannels  ChannelsConfig  `json:\"channels\"`\n\tProviders ProvidersConfig `json:\"providers,omitempty\"`\n\tModelList []ModelConfig   `json:\"model_list\"` // New model-centric provider configuration\n\tGateway   GatewayConfig   `json:\"gateway\"`\n\tTools     ToolsConfig     `json:\"tools\"`\n\tHeartbeat HeartbeatConfig `json:\"heartbeat\"`\n\tDevices   DevicesConfig   `json:\"devices\"`\n\tVoice     VoiceConfig     `json:\"voice\"`\n\t// BuildInfo contains build-time version information\n\tBuildInfo BuildInfo `json:\"build_info,omitempty\"`\n}\n\n// BuildInfo contains build-time version information\ntype BuildInfo struct {\n\tVersion   string `json:\"version\"`\n\tGitCommit string `json:\"git_commit\"`\n\tBuildTime string `json:\"build_time\"`\n\tGoVersion string `json:\"go_version\"`\n}\n\n// MarshalJSON implements custom JSON marshaling for Config\n// to omit providers section when empty and session when empty\nfunc (c Config) MarshalJSON() ([]byte, error) {\n\ttype Alias Config\n\taux := &struct {\n\t\tProviders *ProvidersConfig `json:\"providers,omitempty\"`\n\t\tSession   *SessionConfig   `json:\"session,omitempty\"`\n\t\t*Alias\n\t}{\n\t\tAlias: (*Alias)(&c),\n\t}\n\n\t// Only include providers if not empty\n\tif !c.Providers.IsEmpty() {\n\t\taux.Providers = &c.Providers\n\t}\n\n\t// Only include session if not empty\n\tif c.Session.DMScope != \"\" || len(c.Session.IdentityLinks) > 0 {\n\t\taux.Session = &c.Session\n\t}\n\n\treturn json.Marshal(aux)\n}\n\ntype AgentsConfig struct {\n\tDefaults AgentDefaults `json:\"defaults\"`\n\tList     []AgentConfig `json:\"list,omitempty\"`\n}\n\n// AgentModelConfig supports both string and structured model config.\n// String format: \"gpt-4\" (just primary, no fallbacks)\n// Object format: {\"primary\": \"gpt-4\", \"fallbacks\": [\"claude-haiku\"]}\ntype AgentModelConfig struct {\n\tPrimary   string   `json:\"primary,omitempty\"`\n\tFallbacks []string `json:\"fallbacks,omitempty\"`\n}\n\nfunc (m *AgentModelConfig) UnmarshalJSON(data []byte) error {\n\tvar s string\n\tif err := json.Unmarshal(data, &s); err == nil {\n\t\tm.Primary = s\n\t\tm.Fallbacks = nil\n\t\treturn nil\n\t}\n\ttype raw struct {\n\t\tPrimary   string   `json:\"primary\"`\n\t\tFallbacks []string `json:\"fallbacks\"`\n\t}\n\tvar r raw\n\tif err := json.Unmarshal(data, &r); err != nil {\n\t\treturn err\n\t}\n\tm.Primary = r.Primary\n\tm.Fallbacks = r.Fallbacks\n\treturn nil\n}\n\nfunc (m AgentModelConfig) MarshalJSON() ([]byte, error) {\n\tif len(m.Fallbacks) == 0 && m.Primary != \"\" {\n\t\treturn json.Marshal(m.Primary)\n\t}\n\ttype raw struct {\n\t\tPrimary   string   `json:\"primary,omitempty\"`\n\t\tFallbacks []string `json:\"fallbacks,omitempty\"`\n\t}\n\treturn json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})\n}\n\ntype AgentConfig struct {\n\tID        string            `json:\"id\"`\n\tDefault   bool              `json:\"default,omitempty\"`\n\tName      string            `json:\"name,omitempty\"`\n\tWorkspace string            `json:\"workspace,omitempty\"`\n\tModel     *AgentModelConfig `json:\"model,omitempty\"`\n\tSkills    []string          `json:\"skills,omitempty\"`\n\tSubagents *SubagentsConfig  `json:\"subagents,omitempty\"`\n}\n\ntype SubagentsConfig struct {\n\tAllowAgents []string          `json:\"allow_agents,omitempty\"`\n\tModel       *AgentModelConfig `json:\"model,omitempty\"`\n}\n\ntype PeerMatch struct {\n\tKind string `json:\"kind\"`\n\tID   string `json:\"id\"`\n}\n\ntype BindingMatch struct {\n\tChannel   string     `json:\"channel\"`\n\tAccountID string     `json:\"account_id,omitempty\"`\n\tPeer      *PeerMatch `json:\"peer,omitempty\"`\n\tGuildID   string     `json:\"guild_id,omitempty\"`\n\tTeamID    string     `json:\"team_id,omitempty\"`\n}\n\ntype AgentBinding struct {\n\tAgentID string       `json:\"agent_id\"`\n\tMatch   BindingMatch `json:\"match\"`\n}\n\ntype SessionConfig struct {\n\tDMScope       string              `json:\"dm_scope,omitempty\"`\n\tIdentityLinks map[string][]string `json:\"identity_links,omitempty\"`\n}\n\n// RoutingConfig controls the intelligent model routing feature.\n// When enabled, each incoming message is scored against structural features\n// (message length, code blocks, tool call history, conversation depth, attachments).\n// Messages scoring below Threshold are sent to LightModel; all others use the\n// agent's primary model. This reduces cost and latency for simple tasks without\n// requiring any keyword matching — all scoring is language-agnostic.\ntype RoutingConfig struct {\n\tEnabled    bool    `json:\"enabled\"`\n\tLightModel string  `json:\"light_model\"` // model_name from model_list to use for simple tasks\n\tThreshold  float64 `json:\"threshold\"`   // complexity score in [0,1]; score >= threshold → primary model\n}\n\n// ToolFeedbackConfig controls whether tool execution details are sent to the\n// chat channel as real-time feedback messages. When enabled, every tool call\n// produces a short notification with the tool name and its parameters.\ntype ToolFeedbackConfig struct {\n\tEnabled       bool `json:\"enabled\"         env:\"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED\"`\n\tMaxArgsLength int  `json:\"max_args_length\" env:\"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH\"`\n}\n\ntype AgentDefaults struct {\n\tWorkspace                 string             `json:\"workspace\"                       env:\"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE\"`\n\tRestrictToWorkspace       bool               `json:\"restrict_to_workspace\"           env:\"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE\"`\n\tAllowReadOutsideWorkspace bool               `json:\"allow_read_outside_workspace\"    env:\"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE\"`\n\tProvider                  string             `json:\"provider\"                        env:\"PICOCLAW_AGENTS_DEFAULTS_PROVIDER\"`\n\tModelName                 string             `json:\"model_name\"                      env:\"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME\"`\n\tModel                     string             `json:\"model,omitempty\"                 env:\"PICOCLAW_AGENTS_DEFAULTS_MODEL\"` // Deprecated: use model_name instead\n\tModelFallbacks            []string           `json:\"model_fallbacks,omitempty\"`\n\tImageModel                string             `json:\"image_model,omitempty\"           env:\"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL\"`\n\tImageModelFallbacks       []string           `json:\"image_model_fallbacks,omitempty\"`\n\tMaxTokens                 int                `json:\"max_tokens\"                      env:\"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS\"`\n\tTemperature               *float64           `json:\"temperature,omitempty\"           env:\"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE\"`\n\tMaxToolIterations         int                `json:\"max_tool_iterations\"             env:\"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS\"`\n\tSummarizeMessageThreshold int                `json:\"summarize_message_threshold\"     env:\"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD\"`\n\tSummarizeTokenPercent     int                `json:\"summarize_token_percent\"         env:\"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT\"`\n\tMaxMediaSize              int                `json:\"max_media_size,omitempty\"        env:\"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE\"`\n\tRouting                   *RoutingConfig     `json:\"routing,omitempty\"`\n\tToolFeedback              ToolFeedbackConfig `json:\"tool_feedback,omitempty\"`\n}\n\nconst (\n\tDefaultMaxMediaSize                = 20 * 1024 * 1024 // 20 MB\n\tDefaultWeComAIBotProcessingMessage = \"⏳ Processing, please wait. The results will be sent shortly.\"\n)\n\nfunc (d *AgentDefaults) GetMaxMediaSize() int {\n\tif d.MaxMediaSize > 0 {\n\t\treturn d.MaxMediaSize\n\t}\n\treturn DefaultMaxMediaSize\n}\n\n// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages.\nfunc (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {\n\tif d.ToolFeedback.MaxArgsLength > 0 {\n\t\treturn d.ToolFeedback.MaxArgsLength\n\t}\n\treturn 300\n}\n\n// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat.\nfunc (d *AgentDefaults) IsToolFeedbackEnabled() bool {\n\treturn d.ToolFeedback.Enabled\n}\n\n// GetModelName returns the effective model name for the agent defaults.\n// It prefers the new \"model_name\" field but falls back to \"model\" for backward compatibility.\nfunc (d *AgentDefaults) GetModelName() string {\n\tif d.ModelName != \"\" {\n\t\treturn d.ModelName\n\t}\n\treturn d.Model\n}\n\ntype ChannelsConfig struct {\n\tWhatsApp   WhatsAppConfig   `json:\"whatsapp\"`\n\tTelegram   TelegramConfig   `json:\"telegram\"`\n\tFeishu     FeishuConfig     `json:\"feishu\"`\n\tDiscord    DiscordConfig    `json:\"discord\"`\n\tMaixCam    MaixCamConfig    `json:\"maixcam\"`\n\tQQ         QQConfig         `json:\"qq\"`\n\tDingTalk   DingTalkConfig   `json:\"dingtalk\"`\n\tSlack      SlackConfig      `json:\"slack\"`\n\tMatrix     MatrixConfig     `json:\"matrix\"`\n\tLINE       LINEConfig       `json:\"line\"`\n\tOneBot     OneBotConfig     `json:\"onebot\"`\n\tWeCom      WeComConfig      `json:\"wecom\"`\n\tWeComApp   WeComAppConfig   `json:\"wecom_app\"`\n\tWeComAIBot WeComAIBotConfig `json:\"wecom_aibot\"`\n\tPico       PicoConfig       `json:\"pico\"`\n\tIRC        IRCConfig        `json:\"irc\"`\n}\n\n// GroupTriggerConfig controls when the bot responds in group chats.\ntype GroupTriggerConfig struct {\n\tMentionOnly bool     `json:\"mention_only,omitempty\"`\n\tPrefixes    []string `json:\"prefixes,omitempty\"`\n}\n\n// TypingConfig controls typing indicator behavior (Phase 10).\ntype TypingConfig struct {\n\tEnabled bool `json:\"enabled,omitempty\"`\n}\n\n// PlaceholderConfig controls placeholder message behavior (Phase 10).\ntype PlaceholderConfig struct {\n\tEnabled bool   `json:\"enabled,omitempty\"`\n\tText    string `json:\"text,omitempty\"`\n}\n\ntype WhatsAppConfig struct {\n\tEnabled            bool                `json:\"enabled\"              env:\"PICOCLAW_CHANNELS_WHATSAPP_ENABLED\"`\n\tBridgeURL          string              `json:\"bridge_url\"           env:\"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL\"`\n\tUseNative          bool                `json:\"use_native\"           env:\"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE\"`\n\tSessionStorePath   string              `json:\"session_store_path\"   env:\"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"           env:\"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\" env:\"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID\"`\n}\n\ntype TelegramConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_TELEGRAM_ENABLED\"`\n\tToken              string              `json:\"token\"                   env:\"PICOCLAW_CHANNELS_TELEGRAM_TOKEN\"`\n\tBaseURL            string              `json:\"base_url\"                env:\"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL\"`\n\tProxy              string              `json:\"proxy\"                   env:\"PICOCLAW_CHANNELS_TELEGRAM_PROXY\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID\"`\n\tUseMarkdownV2      bool                `json:\"use_markdown_v2\"         env:\"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2\"`\n}\n\ntype FeishuConfig struct {\n\tEnabled             bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_FEISHU_ENABLED\"`\n\tAppID               string              `json:\"app_id\"                  env:\"PICOCLAW_CHANNELS_FEISHU_APP_ID\"`\n\tAppSecret           string              `json:\"app_secret\"              env:\"PICOCLAW_CHANNELS_FEISHU_APP_SECRET\"`\n\tEncryptKey          string              `json:\"encrypt_key\"             env:\"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY\"`\n\tVerificationToken   string              `json:\"verification_token\"      env:\"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN\"`\n\tAllowFrom           FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM\"`\n\tGroupTrigger        GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tPlaceholder         PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID  string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID\"`\n\tRandomReactionEmoji FlexibleStringSlice `json:\"random_reaction_emoji\"   env:\"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI\"`\n\tIsLark              bool                `json:\"is_lark\"                 env:\"PICOCLAW_CHANNELS_FEISHU_IS_LARK\"`\n}\n\ntype DiscordConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_DISCORD_ENABLED\"`\n\tToken              string              `json:\"token\"                   env:\"PICOCLAW_CHANNELS_DISCORD_TOKEN\"`\n\tProxy              string              `json:\"proxy\"                   env:\"PICOCLAW_CHANNELS_DISCORD_PROXY\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM\"`\n\tMentionOnly        bool                `json:\"mention_only\"            env:\"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID\"`\n}\n\ntype MaixCamConfig struct {\n\tEnabled            bool                `json:\"enabled\"              env:\"PICOCLAW_CHANNELS_MAIXCAM_ENABLED\"`\n\tHost               string              `json:\"host\"                 env:\"PICOCLAW_CHANNELS_MAIXCAM_HOST\"`\n\tPort               int                 `json:\"port\"                 env:\"PICOCLAW_CHANNELS_MAIXCAM_PORT\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"           env:\"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\" env:\"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID\"`\n}\n\ntype QQConfig struct {\n\tEnabled              bool                `json:\"enabled\"                  env:\"PICOCLAW_CHANNELS_QQ_ENABLED\"`\n\tAppID                string              `json:\"app_id\"                   env:\"PICOCLAW_CHANNELS_QQ_APP_ID\"`\n\tAppSecret            string              `json:\"app_secret\"               env:\"PICOCLAW_CHANNELS_QQ_APP_SECRET\"`\n\tAllowFrom            FlexibleStringSlice `json:\"allow_from\"               env:\"PICOCLAW_CHANNELS_QQ_ALLOW_FROM\"`\n\tGroupTrigger         GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tMaxMessageLength     int                 `json:\"max_message_length\"       env:\"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH\"`\n\tMaxBase64FileSizeMiB int64               `json:\"max_base64_file_size_mib\" env:\"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB\"`\n\tSendMarkdown         bool                `json:\"send_markdown\"            env:\"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN\"`\n\tReasoningChannelID   string              `json:\"reasoning_channel_id\"     env:\"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID\"`\n}\n\ntype DingTalkConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_DINGTALK_ENABLED\"`\n\tClientID           string              `json:\"client_id\"               env:\"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID\"`\n\tClientSecret       string              `json:\"client_secret\"           env:\"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID\"`\n}\n\ntype SlackConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_SLACK_ENABLED\"`\n\tBotToken           string              `json:\"bot_token\"               env:\"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN\"`\n\tAppToken           string              `json:\"app_token\"               env:\"PICOCLAW_CHANNELS_SLACK_APP_TOKEN\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID\"`\n}\n\ntype MatrixConfig struct {\n\tEnabled            bool                `json:\"enabled\"                  env:\"PICOCLAW_CHANNELS_MATRIX_ENABLED\"`\n\tHomeserver         string              `json:\"homeserver\"               env:\"PICOCLAW_CHANNELS_MATRIX_HOMESERVER\"`\n\tUserID             string              `json:\"user_id\"                  env:\"PICOCLAW_CHANNELS_MATRIX_USER_ID\"`\n\tAccessToken        string              `json:\"access_token\"             env:\"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN\"`\n\tDeviceID           string              `json:\"device_id,omitempty\"      env:\"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID\"`\n\tJoinOnInvite       bool                `json:\"join_on_invite\"           env:\"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE\"`\n\tMessageFormat      string              `json:\"message_format,omitempty\" env:\"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"               env:\"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"     env:\"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID\"`\n}\n\ntype LINEConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_LINE_ENABLED\"`\n\tChannelSecret      string              `json:\"channel_secret\"          env:\"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET\"`\n\tChannelAccessToken string              `json:\"channel_access_token\"    env:\"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN\"`\n\tWebhookHost        string              `json:\"webhook_host\"            env:\"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST\"`\n\tWebhookPort        int                 `json:\"webhook_port\"            env:\"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT\"`\n\tWebhookPath        string              `json:\"webhook_path\"            env:\"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_LINE_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID\"`\n}\n\ntype OneBotConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_ONEBOT_ENABLED\"`\n\tWSUrl              string              `json:\"ws_url\"                  env:\"PICOCLAW_CHANNELS_ONEBOT_WS_URL\"`\n\tAccessToken        string              `json:\"access_token\"            env:\"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN\"`\n\tReconnectInterval  int                 `json:\"reconnect_interval\"      env:\"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL\"`\n\tGroupTriggerPrefix []string            `json:\"group_trigger_prefix\"    env:\"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tPlaceholder        PlaceholderConfig   `json:\"placeholder,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID\"`\n}\n\ntype WeComConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_WECOM_ENABLED\"`\n\tToken              string              `json:\"token\"                   env:\"PICOCLAW_CHANNELS_WECOM_TOKEN\"`\n\tEncodingAESKey     string              `json:\"encoding_aes_key\"        env:\"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY\"`\n\tWebhookURL         string              `json:\"webhook_url\"             env:\"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL\"`\n\tWebhookHost        string              `json:\"webhook_host\"            env:\"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST\"`\n\tWebhookPort        int                 `json:\"webhook_port\"            env:\"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT\"`\n\tWebhookPath        string              `json:\"webhook_path\"            env:\"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM\"`\n\tReplyTimeout       int                 `json:\"reply_timeout\"           env:\"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID\"`\n}\n\ntype WeComAppConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_WECOM_APP_ENABLED\"`\n\tCorpID             string              `json:\"corp_id\"                 env:\"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID\"`\n\tCorpSecret         string              `json:\"corp_secret\"             env:\"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET\"`\n\tAgentID            int64               `json:\"agent_id\"                env:\"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID\"`\n\tToken              string              `json:\"token\"                   env:\"PICOCLAW_CHANNELS_WECOM_APP_TOKEN\"`\n\tEncodingAESKey     string              `json:\"encoding_aes_key\"        env:\"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY\"`\n\tWebhookHost        string              `json:\"webhook_host\"            env:\"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST\"`\n\tWebhookPort        int                 `json:\"webhook_port\"            env:\"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT\"`\n\tWebhookPath        string              `json:\"webhook_path\"            env:\"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM\"`\n\tReplyTimeout       int                 `json:\"reply_timeout\"           env:\"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID\"`\n}\n\ntype WeComAIBotConfig struct {\n\tEnabled            bool                `json:\"enabled\"                      env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED\"`\n\tBotID              string              `json:\"bot_id,omitempty\"             env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID\"`\n\tSecret             string              `json:\"secret,omitempty\"             env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET\"`\n\tToken              string              `json:\"token,omitempty\"              env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN\"`\n\tEncodingAESKey     string              `json:\"encoding_aes_key,omitempty\"   env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY\"`\n\tWebhookPath        string              `json:\"webhook_path,omitempty\"       env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"                   env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM\"`\n\tReplyTimeout       int                 `json:\"reply_timeout\"                env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT\"`\n\tMaxSteps           int                 `json:\"max_steps\"                    env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS\"`       // Maximum streaming steps\n\tWelcomeMessage     string              `json:\"welcome_message\"              env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE\"` // Sent on enter_chat event; empty = no welcome\n\tProcessingMessage  string              `json:\"processing_message,omitempty\" env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"         env:\"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID\"`\n}\n\ntype PicoConfig struct {\n\tEnabled         bool                `json:\"enabled\"                     env:\"PICOCLAW_CHANNELS_PICO_ENABLED\"`\n\tToken           string              `json:\"token\"                       env:\"PICOCLAW_CHANNELS_PICO_TOKEN\"`\n\tAllowTokenQuery bool                `json:\"allow_token_query,omitempty\"`\n\tAllowOrigins    []string            `json:\"allow_origins,omitempty\"`\n\tPingInterval    int                 `json:\"ping_interval,omitempty\"`\n\tReadTimeout     int                 `json:\"read_timeout,omitempty\"`\n\tWriteTimeout    int                 `json:\"write_timeout,omitempty\"`\n\tMaxConnections  int                 `json:\"max_connections,omitempty\"`\n\tAllowFrom       FlexibleStringSlice `json:\"allow_from\"                  env:\"PICOCLAW_CHANNELS_PICO_ALLOW_FROM\"`\n\tPlaceholder     PlaceholderConfig   `json:\"placeholder,omitempty\"`\n}\n\ntype IRCConfig struct {\n\tEnabled            bool                `json:\"enabled\"                 env:\"PICOCLAW_CHANNELS_IRC_ENABLED\"`\n\tServer             string              `json:\"server\"                  env:\"PICOCLAW_CHANNELS_IRC_SERVER\"`\n\tTLS                bool                `json:\"tls\"                     env:\"PICOCLAW_CHANNELS_IRC_TLS\"`\n\tNick               string              `json:\"nick\"                    env:\"PICOCLAW_CHANNELS_IRC_NICK\"`\n\tUser               string              `json:\"user,omitempty\"          env:\"PICOCLAW_CHANNELS_IRC_USER\"`\n\tRealName           string              `json:\"real_name,omitempty\"     env:\"PICOCLAW_CHANNELS_IRC_REAL_NAME\"`\n\tPassword           string              `json:\"password\"                env:\"PICOCLAW_CHANNELS_IRC_PASSWORD\"`\n\tNickServPassword   string              `json:\"nickserv_password\"       env:\"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD\"`\n\tSASLUser           string              `json:\"sasl_user\"               env:\"PICOCLAW_CHANNELS_IRC_SASL_USER\"`\n\tSASLPassword       string              `json:\"sasl_password\"           env:\"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD\"`\n\tChannels           FlexibleStringSlice `json:\"channels\"                env:\"PICOCLAW_CHANNELS_IRC_CHANNELS\"`\n\tRequestCaps        FlexibleStringSlice `json:\"request_caps,omitempty\"  env:\"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS\"`\n\tAllowFrom          FlexibleStringSlice `json:\"allow_from\"              env:\"PICOCLAW_CHANNELS_IRC_ALLOW_FROM\"`\n\tGroupTrigger       GroupTriggerConfig  `json:\"group_trigger,omitempty\"`\n\tTyping             TypingConfig        `json:\"typing,omitempty\"`\n\tReasoningChannelID string              `json:\"reasoning_channel_id\"    env:\"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID\"`\n}\n\ntype HeartbeatConfig struct {\n\tEnabled  bool `json:\"enabled\"  env:\"PICOCLAW_HEARTBEAT_ENABLED\"`\n\tInterval int  `json:\"interval\" env:\"PICOCLAW_HEARTBEAT_INTERVAL\"` // minutes, min 5\n}\n\ntype DevicesConfig struct {\n\tEnabled    bool `json:\"enabled\"     env:\"PICOCLAW_DEVICES_ENABLED\"`\n\tMonitorUSB bool `json:\"monitor_usb\" env:\"PICOCLAW_DEVICES_MONITOR_USB\"`\n}\n\ntype VoiceConfig struct {\n\tEchoTranscription bool `json:\"echo_transcription\" env:\"PICOCLAW_VOICE_ECHO_TRANSCRIPTION\"`\n}\n\ntype ProvidersConfig struct {\n\tAnthropic     ProviderConfig       `json:\"anthropic\"`\n\tOpenAI        OpenAIProviderConfig `json:\"openai\"`\n\tLiteLLM       ProviderConfig       `json:\"litellm\"`\n\tOpenRouter    ProviderConfig       `json:\"openrouter\"`\n\tGroq          ProviderConfig       `json:\"groq\"`\n\tZhipu         ProviderConfig       `json:\"zhipu\"`\n\tVLLM          ProviderConfig       `json:\"vllm\"`\n\tGemini        ProviderConfig       `json:\"gemini\"`\n\tNvidia        ProviderConfig       `json:\"nvidia\"`\n\tOllama        ProviderConfig       `json:\"ollama\"`\n\tMoonshot      ProviderConfig       `json:\"moonshot\"`\n\tShengSuanYun  ProviderConfig       `json:\"shengsuanyun\"`\n\tDeepSeek      ProviderConfig       `json:\"deepseek\"`\n\tCerebras      ProviderConfig       `json:\"cerebras\"`\n\tVivgrid       ProviderConfig       `json:\"vivgrid\"`\n\tVolcEngine    ProviderConfig       `json:\"volcengine\"`\n\tGitHubCopilot ProviderConfig       `json:\"github_copilot\"`\n\tAntigravity   ProviderConfig       `json:\"antigravity\"`\n\tQwen          ProviderConfig       `json:\"qwen\"`\n\tMistral       ProviderConfig       `json:\"mistral\"`\n\tAvian         ProviderConfig       `json:\"avian\"`\n\tMinimax       ProviderConfig       `json:\"minimax\"`\n\tLongCat       ProviderConfig       `json:\"longcat\"`\n\tModelScope    ProviderConfig       `json:\"modelscope\"`\n\tNovita        ProviderConfig       `json:\"novita\"`\n}\n\n// IsEmpty checks if all provider configs are empty (no API keys or API bases set)\n// Note: WebSearch is an optimization option and doesn't count as \"non-empty\"\nfunc (p ProvidersConfig) IsEmpty() bool {\n\treturn p.Anthropic.APIKey == \"\" && p.Anthropic.APIBase == \"\" &&\n\t\tp.OpenAI.APIKey == \"\" && p.OpenAI.APIBase == \"\" &&\n\t\tp.LiteLLM.APIKey == \"\" && p.LiteLLM.APIBase == \"\" &&\n\t\tp.OpenRouter.APIKey == \"\" && p.OpenRouter.APIBase == \"\" &&\n\t\tp.Groq.APIKey == \"\" && p.Groq.APIBase == \"\" &&\n\t\tp.Zhipu.APIKey == \"\" && p.Zhipu.APIBase == \"\" &&\n\t\tp.VLLM.APIKey == \"\" && p.VLLM.APIBase == \"\" &&\n\t\tp.Gemini.APIKey == \"\" && p.Gemini.APIBase == \"\" &&\n\t\tp.Nvidia.APIKey == \"\" && p.Nvidia.APIBase == \"\" &&\n\t\tp.Ollama.APIKey == \"\" && p.Ollama.APIBase == \"\" &&\n\t\tp.Moonshot.APIKey == \"\" && p.Moonshot.APIBase == \"\" &&\n\t\tp.ShengSuanYun.APIKey == \"\" && p.ShengSuanYun.APIBase == \"\" &&\n\t\tp.DeepSeek.APIKey == \"\" && p.DeepSeek.APIBase == \"\" &&\n\t\tp.Cerebras.APIKey == \"\" && p.Cerebras.APIBase == \"\" &&\n\t\tp.Vivgrid.APIKey == \"\" && p.Vivgrid.APIBase == \"\" &&\n\t\tp.VolcEngine.APIKey == \"\" && p.VolcEngine.APIBase == \"\" &&\n\t\tp.GitHubCopilot.APIKey == \"\" && p.GitHubCopilot.APIBase == \"\" &&\n\t\tp.Antigravity.APIKey == \"\" && p.Antigravity.APIBase == \"\" &&\n\t\tp.Qwen.APIKey == \"\" && p.Qwen.APIBase == \"\" &&\n\t\tp.Mistral.APIKey == \"\" && p.Mistral.APIBase == \"\" &&\n\t\tp.Avian.APIKey == \"\" && p.Avian.APIBase == \"\" &&\n\t\tp.Minimax.APIKey == \"\" && p.Minimax.APIBase == \"\" &&\n\t\tp.LongCat.APIKey == \"\" && p.LongCat.APIBase == \"\" &&\n\t\tp.ModelScope.APIKey == \"\" && p.ModelScope.APIBase == \"\" &&\n\t\tp.Novita.APIKey == \"\" && p.Novita.APIBase == \"\"\n}\n\n// MarshalJSON implements custom JSON marshaling for ProvidersConfig\n// to omit the entire section when empty\nfunc (p ProvidersConfig) MarshalJSON() ([]byte, error) {\n\tif p.IsEmpty() {\n\t\treturn []byte(\"null\"), nil\n\t}\n\ttype Alias ProvidersConfig\n\treturn json.Marshal((*Alias)(&p))\n}\n\ntype ProviderConfig struct {\n\tAPIKey         string `json:\"api_key\"                   env:\"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY\"`\n\tAPIBase        string `json:\"api_base\"                  env:\"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE\"`\n\tProxy          string `json:\"proxy,omitempty\"           env:\"PICOCLAW_PROVIDERS_{{.Name}}_PROXY\"`\n\tRequestTimeout int    `json:\"request_timeout,omitempty\" env:\"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT\"`\n\tAuthMethod     string `json:\"auth_method,omitempty\"     env:\"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD\"`\n\tConnectMode    string `json:\"connect_mode,omitempty\"    env:\"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE\"` // only for Github Copilot, `stdio` or `grpc`\n}\n\ntype OpenAIProviderConfig struct {\n\tProviderConfig\n\tWebSearch bool `json:\"web_search\" env:\"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH\"`\n}\n\n// ModelConfig represents a model-centric provider configuration.\n// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.\n// The model field uses protocol prefix format: [protocol/]model-identifier\n// Supported protocols include openai, anthropic, antigravity, claude-cli,\n// codex-cli, github-copilot, and named OpenAI-compatible protocols such as\n// groq, deepseek, modelscope, and novita.\n// Default protocol is \"openai\" if no prefix is specified.\ntype ModelConfig struct {\n\t// Required fields\n\tModelName string `json:\"model_name\"` // User-facing alias for the model\n\tModel     string `json:\"model\"`      // Protocol/model-identifier (e.g., \"openai/gpt-4o\", \"anthropic/claude-sonnet-4.6\")\n\n\t// HTTP-based providers\n\tAPIBase   string   `json:\"api_base,omitempty\"`  // API endpoint URL\n\tAPIKey    string   `json:\"api_key\"`             // API authentication key (single key)\n\tAPIKeys   []string `json:\"api_keys,omitempty\"`  // API authentication keys (multiple keys for failover)\n\tProxy     string   `json:\"proxy,omitempty\"`     // HTTP proxy URL\n\tFallbacks []string `json:\"fallbacks,omitempty\"` // Fallback model names for failover\n\n\t// Special providers (CLI-based, OAuth, etc.)\n\tAuthMethod  string `json:\"auth_method,omitempty\"`  // Authentication method: oauth, token\n\tConnectMode string `json:\"connect_mode,omitempty\"` // Connection mode: stdio, grpc\n\tWorkspace   string `json:\"workspace,omitempty\"`    // Workspace path for CLI-based providers\n\n\t// Optional optimizations\n\tRPM            int    `json:\"rpm,omitempty\"`              // Requests per minute limit\n\tMaxTokensField string `json:\"max_tokens_field,omitempty\"` // Field name for max tokens (e.g., \"max_completion_tokens\")\n\tRequestTimeout int    `json:\"request_timeout,omitempty\"`\n\tThinkingLevel  string `json:\"thinking_level,omitempty\"` // Extended thinking: off|low|medium|high|xhigh|adaptive\n}\n\n// Validate checks if the ModelConfig has all required fields.\nfunc (c *ModelConfig) Validate() error {\n\tif c.ModelName == \"\" {\n\t\treturn fmt.Errorf(\"model_name is required\")\n\t}\n\tif c.Model == \"\" {\n\t\treturn fmt.Errorf(\"model is required\")\n\t}\n\treturn nil\n}\n\ntype GatewayConfig struct {\n\tHost      string `json:\"host\"       env:\"PICOCLAW_GATEWAY_HOST\"`\n\tPort      int    `json:\"port\"       env:\"PICOCLAW_GATEWAY_PORT\"`\n\tHotReload bool   `json:\"hot_reload\" env:\"PICOCLAW_GATEWAY_HOT_RELOAD\"`\n}\n\ntype ToolDiscoveryConfig struct {\n\tEnabled          bool `json:\"enabled\"            env:\"PICOCLAW_TOOLS_DISCOVERY_ENABLED\"`\n\tTTL              int  `json:\"ttl\"                env:\"PICOCLAW_TOOLS_DISCOVERY_TTL\"`\n\tMaxSearchResults int  `json:\"max_search_results\" env:\"PICOCLAW_MAX_SEARCH_RESULTS\"`\n\tUseBM25          bool `json:\"use_bm25\"           env:\"PICOCLAW_TOOLS_DISCOVERY_USE_BM25\"`\n\tUseRegex         bool `json:\"use_regex\"          env:\"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX\"`\n}\n\ntype ToolConfig struct {\n\tEnabled bool `json:\"enabled\" env:\"ENABLED\"`\n}\n\ntype BraveConfig struct {\n\tEnabled    bool     `json:\"enabled\"     env:\"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED\"`\n\tAPIKey     string   `json:\"api_key\"     env:\"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY\"`\n\tAPIKeys    []string `json:\"api_keys\"    env:\"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS\"`\n\tMaxResults int      `json:\"max_results\" env:\"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS\"`\n}\n\ntype TavilyConfig struct {\n\tEnabled    bool     `json:\"enabled\"     env:\"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED\"`\n\tAPIKey     string   `json:\"api_key\"     env:\"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY\"`\n\tAPIKeys    []string `json:\"api_keys\"    env:\"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS\"`\n\tBaseURL    string   `json:\"base_url\"    env:\"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL\"`\n\tMaxResults int      `json:\"max_results\" env:\"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS\"`\n}\n\ntype DuckDuckGoConfig struct {\n\tEnabled    bool `json:\"enabled\"     env:\"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED\"`\n\tMaxResults int  `json:\"max_results\" env:\"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS\"`\n}\n\ntype PerplexityConfig struct {\n\tEnabled    bool     `json:\"enabled\"     env:\"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED\"`\n\tAPIKey     string   `json:\"api_key\"     env:\"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY\"`\n\tAPIKeys    []string `json:\"api_keys\"    env:\"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS\"`\n\tMaxResults int      `json:\"max_results\" env:\"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS\"`\n}\n\ntype SearXNGConfig struct {\n\tEnabled    bool   `json:\"enabled\"     env:\"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED\"`\n\tBaseURL    string `json:\"base_url\"    env:\"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL\"`\n\tMaxResults int    `json:\"max_results\" env:\"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS\"`\n}\n\ntype GLMSearchConfig struct {\n\tEnabled bool   `json:\"enabled\"  env:\"PICOCLAW_TOOLS_WEB_GLM_ENABLED\"`\n\tAPIKey  string `json:\"api_key\"  env:\"PICOCLAW_TOOLS_WEB_GLM_API_KEY\"`\n\tBaseURL string `json:\"base_url\" env:\"PICOCLAW_TOOLS_WEB_GLM_BASE_URL\"`\n\t// SearchEngine specifies the search backend: \"search_std\" (default),\n\t// \"search_pro\", \"search_pro_sogou\", or \"search_pro_quark\".\n\tSearchEngine string `json:\"search_engine\" env:\"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE\"`\n\tMaxResults   int    `json:\"max_results\"   env:\"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS\"`\n}\n\ntype WebToolsConfig struct {\n\tToolConfig `                 envPrefix:\"PICOCLAW_TOOLS_WEB_\"`\n\tBrave      BraveConfig      `                                json:\"brave\"`\n\tTavily     TavilyConfig     `                                json:\"tavily\"`\n\tDuckDuckGo DuckDuckGoConfig `                                json:\"duckduckgo\"`\n\tPerplexity PerplexityConfig `                                json:\"perplexity\"`\n\tSearXNG    SearXNGConfig    `                                json:\"searxng\"`\n\tGLMSearch  GLMSearchConfig  `                                json:\"glm_search\"`\n\t// PreferNative controls whether to use provider-native web search when\n\t// the active LLM supports it (e.g. OpenAI web_search_preview). When true,\n\t// the client-side web_search tool is hidden to avoid duplicate search surfaces,\n\t// and the provider's built-in search is used instead. Falls back to client-side\n\t// search when the provider does not support native search.\n\tPreferNative bool `json:\"prefer_native\" env:\"PICOCLAW_TOOLS_WEB_PREFER_NATIVE\"`\n\t// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).\n\t// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.\n\tProxy                string              `json:\"proxy,omitempty\"                  env:\"PICOCLAW_TOOLS_WEB_PROXY\"`\n\tFetchLimitBytes      int64               `json:\"fetch_limit_bytes,omitempty\"      env:\"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES\"`\n\tFormat               string              `json:\"format,omitempty\"                 env:\"PICOCLAW_TOOLS_WEB_FORMAT\"`\n\tPrivateHostWhitelist FlexibleStringSlice `json:\"private_host_whitelist,omitempty\" env:\"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST\"`\n}\n\ntype CronToolsConfig struct {\n\tToolConfig         `     envPrefix:\"PICOCLAW_TOOLS_CRON_\"`\n\tExecTimeoutMinutes int  `                                 env:\"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES\" json:\"exec_timeout_minutes\"` // 0 means no timeout\n\tAllowCommand       bool `                                 env:\"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND\"        json:\"allow_command\"`\n}\n\ntype ExecConfig struct {\n\tToolConfig          `         envPrefix:\"PICOCLAW_TOOLS_EXEC_\"`\n\tEnableDenyPatterns  bool     `                                 env:\"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS\"  json:\"enable_deny_patterns\"`\n\tAllowRemote         bool     `                                 env:\"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE\"          json:\"allow_remote\"`\n\tCustomDenyPatterns  []string `                                 env:\"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS\"  json:\"custom_deny_patterns\"`\n\tCustomAllowPatterns []string `                                 env:\"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS\" json:\"custom_allow_patterns\"`\n\tTimeoutSeconds      int      `                                 env:\"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS\"       json:\"timeout_seconds\"` // 0 means use default (60s)\n}\n\ntype SkillsToolsConfig struct {\n\tToolConfig            `                       envPrefix:\"PICOCLAW_TOOLS_SKILLS_\"`\n\tRegistries            SkillsRegistriesConfig `                                   json:\"registries\"`\n\tGithub                SkillsGithubConfig     `                                   json:\"github\"`\n\tMaxConcurrentSearches int                    `                                   json:\"max_concurrent_searches\" env:\"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES\"`\n\tSearchCache           SearchCacheConfig      `                                   json:\"search_cache\"`\n}\n\ntype MediaCleanupConfig struct {\n\tToolConfig `    envPrefix:\"PICOCLAW_MEDIA_CLEANUP_\"`\n\tMaxAge     int `                                    env:\"PICOCLAW_MEDIA_CLEANUP_MAX_AGE\"  json:\"max_age_minutes\"`\n\tInterval   int `                                    env:\"PICOCLAW_MEDIA_CLEANUP_INTERVAL\" json:\"interval_minutes\"`\n}\n\ntype ReadFileToolConfig struct {\n\tEnabled         bool `json:\"enabled\"`\n\tMaxReadFileSize int  `json:\"max_read_file_size\"`\n}\n\ntype ToolsConfig struct {\n\tAllowReadPaths  []string           `json:\"allow_read_paths\"  env:\"PICOCLAW_TOOLS_ALLOW_READ_PATHS\"`\n\tAllowWritePaths []string           `json:\"allow_write_paths\" env:\"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS\"`\n\tWeb             WebToolsConfig     `json:\"web\"`\n\tCron            CronToolsConfig    `json:\"cron\"`\n\tExec            ExecConfig         `json:\"exec\"`\n\tSkills          SkillsToolsConfig  `json:\"skills\"`\n\tMediaCleanup    MediaCleanupConfig `json:\"media_cleanup\"`\n\tMCP             MCPConfig          `json:\"mcp\"`\n\tAppendFile      ToolConfig         `json:\"append_file\"                                              envPrefix:\"PICOCLAW_TOOLS_APPEND_FILE_\"`\n\tEditFile        ToolConfig         `json:\"edit_file\"                                                envPrefix:\"PICOCLAW_TOOLS_EDIT_FILE_\"`\n\tFindSkills      ToolConfig         `json:\"find_skills\"                                              envPrefix:\"PICOCLAW_TOOLS_FIND_SKILLS_\"`\n\tI2C             ToolConfig         `json:\"i2c\"                                                      envPrefix:\"PICOCLAW_TOOLS_I2C_\"`\n\tInstallSkill    ToolConfig         `json:\"install_skill\"                                            envPrefix:\"PICOCLAW_TOOLS_INSTALL_SKILL_\"`\n\tListDir         ToolConfig         `json:\"list_dir\"                                                 envPrefix:\"PICOCLAW_TOOLS_LIST_DIR_\"`\n\tMessage         ToolConfig         `json:\"message\"                                                  envPrefix:\"PICOCLAW_TOOLS_MESSAGE_\"`\n\tReadFile        ReadFileToolConfig `json:\"read_file\"                                                envPrefix:\"PICOCLAW_TOOLS_READ_FILE_\"`\n\tSendFile        ToolConfig         `json:\"send_file\"                                                envPrefix:\"PICOCLAW_TOOLS_SEND_FILE_\"`\n\tSpawn           ToolConfig         `json:\"spawn\"                                                    envPrefix:\"PICOCLAW_TOOLS_SPAWN_\"`\n\tSpawnStatus     ToolConfig         `json:\"spawn_status\"                                             envPrefix:\"PICOCLAW_TOOLS_SPAWN_STATUS_\"`\n\tSPI             ToolConfig         `json:\"spi\"                                                      envPrefix:\"PICOCLAW_TOOLS_SPI_\"`\n\tSubagent        ToolConfig         `json:\"subagent\"                                                 envPrefix:\"PICOCLAW_TOOLS_SUBAGENT_\"`\n\tWebFetch        ToolConfig         `json:\"web_fetch\"                                                envPrefix:\"PICOCLAW_TOOLS_WEB_FETCH_\"`\n\tWriteFile       ToolConfig         `json:\"write_file\"                                               envPrefix:\"PICOCLAW_TOOLS_WRITE_FILE_\"`\n}\n\ntype SearchCacheConfig struct {\n\tMaxSize    int `json:\"max_size\"    env:\"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE\"`\n\tTTLSeconds int `json:\"ttl_seconds\" env:\"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS\"`\n}\n\ntype SkillsRegistriesConfig struct {\n\tClawHub ClawHubRegistryConfig `json:\"clawhub\"`\n}\n\ntype SkillsGithubConfig struct {\n\tToken string `json:\"token,omitempty\" env:\"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN\"`\n\tProxy string `json:\"proxy,omitempty\" env:\"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY\"`\n}\n\ntype ClawHubRegistryConfig struct {\n\tEnabled         bool   `json:\"enabled\"           env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED\"`\n\tBaseURL         string `json:\"base_url\"          env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL\"`\n\tAuthToken       string `json:\"auth_token\"        env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN\"`\n\tSearchPath      string `json:\"search_path\"       env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH\"`\n\tSkillsPath      string `json:\"skills_path\"       env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH\"`\n\tDownloadPath    string `json:\"download_path\"     env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH\"`\n\tTimeout         int    `json:\"timeout\"           env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT\"`\n\tMaxZipSize      int    `json:\"max_zip_size\"      env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE\"`\n\tMaxResponseSize int    `json:\"max_response_size\" env:\"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE\"`\n}\n\n// MCPServerConfig defines configuration for a single MCP server\ntype MCPServerConfig struct {\n\t// Enabled indicates whether this MCP server is active\n\tEnabled bool `json:\"enabled\"`\n\t// Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode).\n\t// When nil, the global Discovery.Enabled setting applies.\n\t// When explicitly set to true or false, it overrides the global setting for this server only.\n\tDeferred *bool `json:\"deferred,omitempty\"`\n\t// Command is the executable to run (e.g., \"npx\", \"python\", \"/path/to/server\")\n\tCommand string `json:\"command\"`\n\t// Args are the arguments to pass to the command\n\tArgs []string `json:\"args,omitempty\"`\n\t// Env are environment variables to set for the server process (stdio only)\n\tEnv map[string]string `json:\"env,omitempty\"`\n\t// EnvFile is the path to a file containing environment variables (stdio only)\n\tEnvFile string `json:\"env_file,omitempty\"`\n\t// Type is \"stdio\", \"sse\", or \"http\" (default: stdio if command is set, sse if url is set)\n\tType string `json:\"type,omitempty\"`\n\t// URL is used for SSE/HTTP transport\n\tURL string `json:\"url,omitempty\"`\n\t// Headers are HTTP headers to send with requests (sse/http only)\n\tHeaders map[string]string `json:\"headers,omitempty\"`\n}\n\n// MCPConfig defines configuration for all MCP servers\ntype MCPConfig struct {\n\tToolConfig `                    envPrefix:\"PICOCLAW_TOOLS_MCP_\"`\n\tDiscovery  ToolDiscoveryConfig `                                json:\"discovery\"`\n\t// Servers is a map of server name to server configuration\n\tServers map[string]MCPServerConfig `json:\"servers,omitempty\"`\n}\n\nfunc LoadConfig(path string) (*Config, error) {\n\tcfg := DefaultConfig()\n\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn cfg, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// Pre-scan the JSON to check how many model_list entries the user provided.\n\t// Go's JSON decoder reuses existing slice backing-array elements rather than\n\t// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)\n\t// would silently inherit values from the DefaultConfig template at the same\n\t// index position. We only reset cfg.ModelList when the user actually provides\n\t// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.\n\tvar tmp Config\n\tif err := json.Unmarshal(data, &tmp); err != nil {\n\t\treturn nil, err\n\t}\n\tif len(tmp.ModelList) > 0 {\n\t\tcfg.ModelList = nil\n\t}\n\n\tif err := json.Unmarshal(data, cfg); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif passphrase := credential.PassphraseProvider(); passphrase != \"\" {\n\t\tfor _, m := range cfg.ModelList {\n\t\t\tif m.APIKey != \"\" && !strings.HasPrefix(m.APIKey, \"enc://\") && !strings.HasPrefix(m.APIKey, \"file://\") {\n\t\t\t\tfmt.Fprintf(os.Stderr,\n\t\t\t\t\t\"picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\\n\",\n\t\t\t\t\tm.ModelName)\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := env.Parse(cfg); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Expand multi-key configs into separate entries for key-level failover\n\tcfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)\n\n\t// Migrate legacy channel config fields to new unified structures\n\tcfg.migrateChannelConfigs()\n\n\t// Auto-migrate: if only legacy providers config exists, convert to model_list\n\tif len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {\n\t\tcfg.ModelList = ConvertProvidersToModelList(cfg)\n\t}\n\n\t// Inherit credentials from providers to model_list entries (#1635).\n\t// When both providers and model_list are present, model_list entries\n\t// whose api_key/api_base are empty will inherit from the matching\n\t// provider (matched by protocol prefix).  Explicit model_list values\n\t// always take precedence.\n\tif cfg.HasProvidersConfig() {\n\t\tInheritProviderCredentials(cfg.ModelList, cfg.Providers)\n\t}\n\n\t// Validate model_list for uniqueness and required fields\n\tif err := cfg.ValidateModelList(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn cfg, nil\n}\n\n// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values\n// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or\n// empty). Returns (nil, error) if any key fails to encrypt — callers must treat\n// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.\n// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig\n// and leave JSON marshaling to the caller.\nfunc encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {\n\tsealed := make([]ModelConfig, len(models))\n\tcopy(sealed, models)\n\tchanged := false\n\tfor i := range sealed {\n\t\tm := &sealed[i]\n\t\tif m.APIKey == \"\" || strings.HasPrefix(m.APIKey, \"enc://\") || strings.HasPrefix(m.APIKey, \"file://\") {\n\t\t\tcontinue\n\t\t}\n\t\tencrypted, err := credential.Encrypt(passphrase, \"\", m.APIKey)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"cannot seal api_key for model %q: %w\", m.ModelName, err)\n\t\t}\n\t\tm.APIKey = encrypted\n\t\tchanged = true\n\t}\n\tif !changed {\n\t\treturn nil, nil\n\t}\n\treturn sealed, nil\n}\n\n// resolveAPIKeys decrypts or dereferences each api_key in models in-place.\n// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).\n// Also resolves api_keys array if present.\nfunc resolveAPIKeys(models []ModelConfig, configDir string) error {\n\tcr := credential.NewResolver(configDir)\n\tfor i := range models {\n\t\t// Resolve single APIKey\n\t\tresolved, err := cr.Resolve(models[i].APIKey)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"model_list[%d] (%s): %w\", i, models[i].ModelName, err)\n\t\t}\n\t\tmodels[i].APIKey = resolved\n\n\t\t// Resolve APIKeys array\n\t\tfor j, key := range models[i].APIKeys {\n\t\t\tresolved, err := cr.Resolve(key)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"model_list[%d] (%s): api_keys[%d]: %w\", i, models[i].ModelName, j, err)\n\t\t\t}\n\t\t\tmodels[i].APIKeys[j] = resolved\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *Config) migrateChannelConfigs() {\n\t// Discord: mention_only -> group_trigger.mention_only\n\tif c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {\n\t\tc.Channels.Discord.GroupTrigger.MentionOnly = true\n\t}\n\n\t// OneBot: group_trigger_prefix -> group_trigger.prefixes\n\tif len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&\n\t\tlen(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {\n\t\tc.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix\n\t}\n}\n\nfunc SaveConfig(path string, cfg *Config) error {\n\tif passphrase := credential.PassphraseProvider(); passphrase != \"\" {\n\t\tsealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif sealed != nil {\n\t\t\ttmp := *cfg\n\t\t\ttmp.ModelList = sealed\n\t\t\tcfg = &tmp\n\t\t}\n\t}\n\n\tdata, err := json.MarshalIndent(cfg, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn fileutil.WriteFileAtomic(path, data, 0o600)\n}\n\nfunc (c *Config) WorkspacePath() string {\n\treturn expandHome(c.Agents.Defaults.Workspace)\n}\n\nfunc (c *Config) GetAPIKey() string {\n\tif c.Providers.OpenRouter.APIKey != \"\" {\n\t\treturn c.Providers.OpenRouter.APIKey\n\t}\n\tif c.Providers.Anthropic.APIKey != \"\" {\n\t\treturn c.Providers.Anthropic.APIKey\n\t}\n\tif c.Providers.OpenAI.APIKey != \"\" {\n\t\treturn c.Providers.OpenAI.APIKey\n\t}\n\tif c.Providers.Gemini.APIKey != \"\" {\n\t\treturn c.Providers.Gemini.APIKey\n\t}\n\tif c.Providers.Zhipu.APIKey != \"\" {\n\t\treturn c.Providers.Zhipu.APIKey\n\t}\n\tif c.Providers.Groq.APIKey != \"\" {\n\t\treturn c.Providers.Groq.APIKey\n\t}\n\tif c.Providers.VLLM.APIKey != \"\" {\n\t\treturn c.Providers.VLLM.APIKey\n\t}\n\tif c.Providers.ShengSuanYun.APIKey != \"\" {\n\t\treturn c.Providers.ShengSuanYun.APIKey\n\t}\n\tif c.Providers.Cerebras.APIKey != \"\" {\n\t\treturn c.Providers.Cerebras.APIKey\n\t}\n\treturn \"\"\n}\n\nfunc (c *Config) GetAPIBase() string {\n\tif c.Providers.OpenRouter.APIKey != \"\" {\n\t\tif c.Providers.OpenRouter.APIBase != \"\" {\n\t\t\treturn c.Providers.OpenRouter.APIBase\n\t\t}\n\t\treturn \"https://openrouter.ai/api/v1\"\n\t}\n\tif c.Providers.Zhipu.APIKey != \"\" {\n\t\treturn c.Providers.Zhipu.APIBase\n\t}\n\tif c.Providers.VLLM.APIKey != \"\" && c.Providers.VLLM.APIBase != \"\" {\n\t\treturn c.Providers.VLLM.APIBase\n\t}\n\treturn \"\"\n}\n\nfunc expandHome(path string) string {\n\tif path == \"\" {\n\t\treturn path\n\t}\n\tif path[0] == '~' {\n\t\thome, _ := os.UserHomeDir()\n\t\tif len(path) > 1 && path[1] == '/' {\n\t\t\treturn home + path[1:]\n\t\t}\n\t\treturn home\n\t}\n\treturn path\n}\n\n// GetModelConfig returns the ModelConfig for the given model name.\n// If multiple configs exist with the same model_name, it uses round-robin\n// selection for load balancing. Returns an error if the model is not found.\nfunc (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {\n\tmatches := c.findMatches(modelName)\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"model %q not found in model_list or providers\", modelName)\n\t}\n\tif len(matches) == 1 {\n\t\treturn &matches[0], nil\n\t}\n\n\t// Multiple configs - use round-robin for load balancing\n\tidx := (rrCounter.Add(1) - 1) % uint64(len(matches))\n\treturn &matches[idx], nil\n}\n\n// findMatches finds all ModelConfig entries with the given model_name.\nfunc (c *Config) findMatches(modelName string) []ModelConfig {\n\tvar matches []ModelConfig\n\tfor i := range c.ModelList {\n\t\tif c.ModelList[i].ModelName == modelName {\n\t\t\tmatches = append(matches, c.ModelList[i])\n\t\t}\n\t}\n\treturn matches\n}\n\n// HasProvidersConfig checks if any provider in the old providers config has configuration.\nfunc (c *Config) HasProvidersConfig() bool {\n\treturn !c.Providers.IsEmpty()\n}\n\n// ValidateModelList validates all ModelConfig entries in the model_list.\n// It checks that each model config is valid.\n// Note: Multiple entries with the same model_name are allowed for load balancing.\nfunc (c *Config) ValidateModelList() error {\n\tfor i := range c.ModelList {\n\t\tif err := c.ModelList[i].Validate(); err != nil {\n\t\t\treturn fmt.Errorf(\"model_list[%d]: %w\", i, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc MergeAPIKeys(apiKey string, apiKeys []string) []string {\n\tseen := make(map[string]struct{})\n\tvar all []string\n\n\tif k := strings.TrimSpace(apiKey); k != \"\" {\n\t\tif _, exists := seen[k]; !exists {\n\t\t\tseen[k] = struct{}{}\n\t\t\tall = append(all, k)\n\t\t}\n\t}\n\n\tfor _, k := range apiKeys {\n\t\tif trimmed := strings.TrimSpace(k); trimmed != \"\" {\n\t\t\tif _, exists := seen[trimmed]; !exists {\n\t\t\t\tseen[trimmed] = struct{}{}\n\t\t\t\tall = append(all, trimmed)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn all\n}\n\n// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into\n// separate entries for key-level failover. Each key gets its own ModelConfig entry,\n// and the original entry's fallbacks are set up to chain through the expanded entries.\n//\n// Example: {\"model_name\": \"gpt-4\", \"api_keys\": [\"k1\", \"k2\", \"k3\"]}\n// Becomes:\n//   - {\"model_name\": \"gpt-4\", \"api_key\": \"k1\", \"fallbacks\": [\"gpt-4__key_1\", \"gpt-4__key_2\"]}\n//   - {\"model_name\": \"gpt-4__key_1\", \"api_key\": \"k2\"}\n//   - {\"model_name\": \"gpt-4__key_2\", \"api_key\": \"k3\"}\nfunc ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {\n\tvar expanded []ModelConfig\n\n\tfor _, m := range models {\n\t\tkeys := MergeAPIKeys(m.APIKey, m.APIKeys)\n\n\t\t// Single key or no keys: keep as-is\n\t\tif len(keys) <= 1 {\n\t\t\t// Ensure APIKey is set from APIKeys if needed\n\t\t\tif m.APIKey == \"\" && len(keys) == 1 {\n\t\t\t\tm.APIKey = keys[0]\n\t\t\t}\n\t\t\tm.APIKeys = nil // Clear APIKeys to avoid confusion\n\t\t\texpanded = append(expanded, m)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Multiple keys: expand\n\t\toriginalName := m.ModelName\n\n\t\t// Create entries for additional keys (key_1, key_2, ...)\n\t\tvar fallbackNames []string\n\t\tfor i := 1; i < len(keys); i++ {\n\t\t\tsuffix := fmt.Sprintf(\"__key_%d\", i)\n\t\t\texpandedName := originalName + suffix\n\n\t\t\t// Create a copy for the additional key\n\t\t\tadditionalEntry := ModelConfig{\n\t\t\t\tModelName:      expandedName,\n\t\t\t\tModel:          m.Model,\n\t\t\t\tAPIBase:        m.APIBase,\n\t\t\t\tAPIKey:         keys[i],\n\t\t\t\tProxy:          m.Proxy,\n\t\t\t\tAuthMethod:     m.AuthMethod,\n\t\t\t\tConnectMode:    m.ConnectMode,\n\t\t\t\tWorkspace:      m.Workspace,\n\t\t\t\tRPM:            m.RPM,\n\t\t\t\tMaxTokensField: m.MaxTokensField,\n\t\t\t\tRequestTimeout: m.RequestTimeout,\n\t\t\t\tThinkingLevel:  m.ThinkingLevel,\n\t\t\t}\n\t\t\texpanded = append(expanded, additionalEntry)\n\t\t\tfallbackNames = append(fallbackNames, expandedName)\n\t\t}\n\n\t\t// Create the primary entry with first key and fallbacks\n\t\tprimaryEntry := ModelConfig{\n\t\t\tModelName:      originalName,\n\t\t\tModel:          m.Model,\n\t\t\tAPIBase:        m.APIBase,\n\t\t\tAPIKey:         keys[0],\n\t\t\tProxy:          m.Proxy,\n\t\t\tAuthMethod:     m.AuthMethod,\n\t\t\tConnectMode:    m.ConnectMode,\n\t\t\tWorkspace:      m.Workspace,\n\t\t\tRPM:            m.RPM,\n\t\t\tMaxTokensField: m.MaxTokensField,\n\t\t\tRequestTimeout: m.RequestTimeout,\n\t\t\tThinkingLevel:  m.ThinkingLevel,\n\t\t}\n\n\t\t// Prepend new fallbacks to existing ones\n\t\tif len(fallbackNames) > 0 {\n\t\t\tprimaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)\n\t\t} else if len(m.Fallbacks) > 0 {\n\t\t\tprimaryEntry.Fallbacks = m.Fallbacks\n\t\t}\n\n\t\texpanded = append(expanded, primaryEntry)\n\t}\n\n\treturn expanded\n}\n\nfunc (t *ToolsConfig) IsToolEnabled(name string) bool {\n\tswitch name {\n\tcase \"web\":\n\t\treturn t.Web.Enabled\n\tcase \"cron\":\n\t\treturn t.Cron.Enabled\n\tcase \"exec\":\n\t\treturn t.Exec.Enabled\n\tcase \"skills\":\n\t\treturn t.Skills.Enabled\n\tcase \"media_cleanup\":\n\t\treturn t.MediaCleanup.Enabled\n\tcase \"append_file\":\n\t\treturn t.AppendFile.Enabled\n\tcase \"edit_file\":\n\t\treturn t.EditFile.Enabled\n\tcase \"find_skills\":\n\t\treturn t.FindSkills.Enabled\n\tcase \"i2c\":\n\t\treturn t.I2C.Enabled\n\tcase \"install_skill\":\n\t\treturn t.InstallSkill.Enabled\n\tcase \"list_dir\":\n\t\treturn t.ListDir.Enabled\n\tcase \"message\":\n\t\treturn t.Message.Enabled\n\tcase \"read_file\":\n\t\treturn t.ReadFile.Enabled\n\tcase \"spawn\":\n\t\treturn t.Spawn.Enabled\n\tcase \"spawn_status\":\n\t\treturn t.SpawnStatus.Enabled\n\tcase \"spi\":\n\t\treturn t.SPI.Enabled\n\tcase \"subagent\":\n\t\treturn t.Subagent.Enabled\n\tcase \"web_fetch\":\n\t\treturn t.WebFetch.Enabled\n\tcase \"send_file\":\n\t\treturn t.SendFile.Enabled\n\tcase \"write_file\":\n\t\treturn t.WriteFile.Enabled\n\tcase \"mcp\":\n\t\treturn t.MCP.Enabled\n\tdefault:\n\t\treturn true\n\t}\n}\n"
  },
  {
    "path": "pkg/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/credential\"\n)\n\n// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets\n// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required\n// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig.\nfunc mustSetupSSHKey(t *testing.T) {\n\tt.Helper()\n\tkeyPath := filepath.Join(t.TempDir(), \"picoclaw_ed25519.key\")\n\tif err := credential.GenerateSSHKey(keyPath); err != nil {\n\t\tt.Fatalf(\"mustSetupSSHKey: %v\", err)\n\t}\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", keyPath)\n}\n\nfunc TestAgentModelConfig_UnmarshalString(t *testing.T) {\n\tvar m AgentModelConfig\n\tif err := json.Unmarshal([]byte(`\"gpt-4\"`), &m); err != nil {\n\t\tt.Fatalf(\"unmarshal string: %v\", err)\n\t}\n\tif m.Primary != \"gpt-4\" {\n\t\tt.Errorf(\"Primary = %q, want 'gpt-4'\", m.Primary)\n\t}\n\tif m.Fallbacks != nil {\n\t\tt.Errorf(\"Fallbacks = %v, want nil\", m.Fallbacks)\n\t}\n}\n\nfunc TestAgentModelConfig_UnmarshalObject(t *testing.T) {\n\tvar m AgentModelConfig\n\tdata := `{\"primary\": \"claude-opus\", \"fallbacks\": [\"gpt-4o-mini\", \"haiku\"]}`\n\tif err := json.Unmarshal([]byte(data), &m); err != nil {\n\t\tt.Fatalf(\"unmarshal object: %v\", err)\n\t}\n\tif m.Primary != \"claude-opus\" {\n\t\tt.Errorf(\"Primary = %q, want 'claude-opus'\", m.Primary)\n\t}\n\tif len(m.Fallbacks) != 2 {\n\t\tt.Fatalf(\"Fallbacks len = %d, want 2\", len(m.Fallbacks))\n\t}\n\tif m.Fallbacks[0] != \"gpt-4o-mini\" || m.Fallbacks[1] != \"haiku\" {\n\t\tt.Errorf(\"Fallbacks = %v\", m.Fallbacks)\n\t}\n}\n\nfunc TestAgentModelConfig_MarshalString(t *testing.T) {\n\tm := AgentModelConfig{Primary: \"gpt-4\"}\n\tdata, err := json.Marshal(m)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif string(data) != `\"gpt-4\"` {\n\t\tt.Errorf(\"marshal = %s, want '\\\"gpt-4\\\"'\", string(data))\n\t}\n}\n\nfunc TestAgentModelConfig_MarshalObject(t *testing.T) {\n\tm := AgentModelConfig{Primary: \"claude-opus\", Fallbacks: []string{\"haiku\"}}\n\tdata, err := json.Marshal(m)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar result map[string]any\n\tjson.Unmarshal(data, &result)\n\tif result[\"primary\"] != \"claude-opus\" {\n\t\tt.Errorf(\"primary = %v\", result[\"primary\"])\n\t}\n}\n\nfunc TestProvidersConfig_IsEmpty(t *testing.T) {\n\tvar empty ProvidersConfig\n\tif !empty.IsEmpty() {\n\t\tt.Fatal(\"empty ProvidersConfig should report empty\")\n\t}\n\n\tnovita := ProvidersConfig{\n\t\tNovita: ProviderConfig{\n\t\t\tAPIKey: \"test-key\",\n\t\t},\n\t}\n\tif novita.IsEmpty() {\n\t\tt.Fatal(\"ProvidersConfig with novita settings should not report empty\")\n\t}\n}\n\nfunc TestAgentConfig_FullParse(t *testing.T) {\n\tjsonData := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\",\n\t\t\t\t\"model\": \"glm-4.7\",\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t\t\"max_tool_iterations\": 20\n\t\t\t},\n\t\t\t\"list\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": \"sales\",\n\t\t\t\t\t\"default\": true,\n\t\t\t\t\t\"name\": \"Sales Bot\",\n\t\t\t\t\t\"model\": \"gpt-4\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"id\": \"support\",\n\t\t\t\t\t\"name\": \"Support Bot\",\n\t\t\t\t\t\"model\": {\n\t\t\t\t\t\t\"primary\": \"claude-opus\",\n\t\t\t\t\t\t\"fallbacks\": [\"haiku\"]\n\t\t\t\t\t},\n\t\t\t\t\t\"subagents\": {\n\t\t\t\t\t\t\"allow_agents\": [\"sales\"]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t\"bindings\": [\n\t\t\t{\n\t\t\t\t\"agent_id\": \"support\",\n\t\t\t\t\"match\": {\n\t\t\t\t\t\"channel\": \"telegram\",\n\t\t\t\t\t\"account_id\": \"*\",\n\t\t\t\t\t\"peer\": {\"kind\": \"direct\", \"id\": \"user123\"}\n\t\t\t\t}\n\t\t\t}\n\t\t],\n\t\t\"session\": {\n\t\t\t\"dm_scope\": \"per-peer\",\n\t\t\t\"identity_links\": {\n\t\t\t\t\"john\": [\"telegram:123\", \"discord:john#1234\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\tcfg := DefaultConfig()\n\tif err := json.Unmarshal([]byte(jsonData), cfg); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\n\tif len(cfg.Agents.List) != 2 {\n\t\tt.Fatalf(\"agents.list len = %d, want 2\", len(cfg.Agents.List))\n\t}\n\n\tsales := cfg.Agents.List[0]\n\tif sales.ID != \"sales\" || !sales.Default || sales.Name != \"Sales Bot\" {\n\t\tt.Errorf(\"sales = %+v\", sales)\n\t}\n\tif sales.Model == nil || sales.Model.Primary != \"gpt-4\" {\n\t\tt.Errorf(\"sales.Model = %+v\", sales.Model)\n\t}\n\n\tsupport := cfg.Agents.List[1]\n\tif support.ID != \"support\" || support.Name != \"Support Bot\" {\n\t\tt.Errorf(\"support = %+v\", support)\n\t}\n\tif support.Model == nil || support.Model.Primary != \"claude-opus\" {\n\t\tt.Errorf(\"support.Model = %+v\", support.Model)\n\t}\n\tif len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != \"haiku\" {\n\t\tt.Errorf(\"support.Model.Fallbacks = %v\", support.Model.Fallbacks)\n\t}\n\tif support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {\n\t\tt.Errorf(\"support.Subagents = %+v\", support.Subagents)\n\t}\n\n\tif len(cfg.Bindings) != 1 {\n\t\tt.Fatalf(\"bindings len = %d, want 1\", len(cfg.Bindings))\n\t}\n\tbinding := cfg.Bindings[0]\n\tif binding.AgentID != \"support\" || binding.Match.Channel != \"telegram\" {\n\t\tt.Errorf(\"binding = %+v\", binding)\n\t}\n\tif binding.Match.Peer == nil || binding.Match.Peer.Kind != \"direct\" || binding.Match.Peer.ID != \"user123\" {\n\t\tt.Errorf(\"binding.Match.Peer = %+v\", binding.Match.Peer)\n\t}\n\n\tif cfg.Session.DMScope != \"per-peer\" {\n\t\tt.Errorf(\"Session.DMScope = %q\", cfg.Session.DMScope)\n\t}\n\tif len(cfg.Session.IdentityLinks) != 1 {\n\t\tt.Errorf(\"Session.IdentityLinks = %v\", cfg.Session.IdentityLinks)\n\t}\n\tlinks := cfg.Session.IdentityLinks[\"john\"]\n\tif len(links) != 2 {\n\t\tt.Errorf(\"john links = %v\", links)\n\t}\n}\n\nfunc TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {\n\tjsonData := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\",\n\t\t\t\t\"model\": \"glm-4.7\",\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t\t\"max_tool_iterations\": 20\n\t\t\t}\n\t\t}\n\t}`\n\n\tcfg := DefaultConfig()\n\tif err := json.Unmarshal([]byte(jsonData), cfg); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\n\tif len(cfg.Agents.List) != 0 {\n\t\tt.Errorf(\"agents.list should be empty for backward compat, got %d\", len(cfg.Agents.List))\n\t}\n\tif len(cfg.Bindings) != 0 {\n\t\tt.Errorf(\"bindings should be empty, got %d\", len(cfg.Bindings))\n\t}\n}\n\n// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default\nfunc TestDefaultConfig_HeartbeatEnabled(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif !cfg.Heartbeat.Enabled {\n\t\tt.Error(\"Heartbeat should be enabled by default\")\n\t}\n}\n\n// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set\nfunc TestDefaultConfig_WorkspacePath(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.Workspace == \"\" {\n\t\tt.Error(\"Workspace should not be empty\")\n\t}\n}\n\n// TestDefaultConfig_Model verifies model is set\nfunc TestDefaultConfig_Model(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.Model != \"\" {\n\t\tt.Error(\"Model should be empty\")\n\t}\n}\n\n// TestDefaultConfig_MaxTokens verifies max tokens has default value\nfunc TestDefaultConfig_MaxTokens(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.MaxTokens == 0 {\n\t\tt.Error(\"MaxTokens should not be zero\")\n\t}\n}\n\n// TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value\nfunc TestDefaultConfig_MaxToolIterations(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.MaxToolIterations == 0 {\n\t\tt.Error(\"MaxToolIterations should not be zero\")\n\t}\n}\n\n// TestDefaultConfig_Temperature verifies temperature has default value\nfunc TestDefaultConfig_Temperature(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.Temperature != nil {\n\t\tt.Error(\"Temperature should be nil when not provided\")\n\t}\n}\n\n// TestDefaultConfig_Gateway verifies gateway defaults\nfunc TestDefaultConfig_Gateway(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Gateway.Host != \"127.0.0.1\" {\n\t\tt.Error(\"Gateway host should have default value\")\n\t}\n\tif cfg.Gateway.Port == 0 {\n\t\tt.Error(\"Gateway port should have default value\")\n\t}\n\tif cfg.Gateway.HotReload {\n\t\tt.Error(\"Gateway hot reload should be disabled by default\")\n\t}\n}\n\n// TestDefaultConfig_Providers verifies provider structure\nfunc TestDefaultConfig_Providers(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Providers.Anthropic.APIKey != \"\" {\n\t\tt.Error(\"Anthropic API key should be empty by default\")\n\t}\n\tif cfg.Providers.OpenAI.APIKey != \"\" {\n\t\tt.Error(\"OpenAI API key should be empty by default\")\n\t}\n\tif cfg.Providers.OpenRouter.APIKey != \"\" {\n\t\tt.Error(\"OpenRouter API key should be empty by default\")\n\t}\n}\n\n// TestDefaultConfig_Channels verifies channels are disabled by default\nfunc TestDefaultConfig_Channels(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Channels.Telegram.Enabled {\n\t\tt.Error(\"Telegram should be disabled by default\")\n\t}\n\tif cfg.Channels.Discord.Enabled {\n\t\tt.Error(\"Discord should be disabled by default\")\n\t}\n\tif cfg.Channels.Slack.Enabled {\n\t\tt.Error(\"Slack should be disabled by default\")\n\t}\n\tif cfg.Channels.Matrix.Enabled {\n\t\tt.Error(\"Matrix should be disabled by default\")\n\t}\n}\n\n// TestDefaultConfig_WebTools verifies web tools config\nfunc TestDefaultConfig_WebTools(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\t// Verify web tools defaults\n\tif cfg.Tools.Web.Brave.MaxResults != 5 {\n\t\tt.Error(\"Expected Brave MaxResults 5, got \", cfg.Tools.Web.Brave.MaxResults)\n\t}\n\tif len(cfg.Tools.Web.Brave.APIKeys) != 0 {\n\t\tt.Error(\"Brave API key should be empty by default\")\n\t}\n\tif cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {\n\t\tt.Error(\"Expected DuckDuckGo MaxResults 5, got \", cfg.Tools.Web.DuckDuckGo.MaxResults)\n\t}\n}\n\nfunc TestSaveConfig_FilePermissions(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"file permission bits are not enforced on Windows\")\n\t}\n\n\ttmpDir := t.TempDir()\n\tpath := filepath.Join(tmpDir, \"config.json\")\n\n\tcfg := DefaultConfig()\n\tif err := SaveConfig(path, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig failed: %v\", err)\n\t}\n\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatalf(\"Stat failed: %v\", err)\n\t}\n\n\tperm := info.Mode().Perm()\n\tif perm != 0o600 {\n\t\tt.Errorf(\"config file has permission %04o, want 0600\", perm)\n\t}\n}\n\nfunc TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tpath := filepath.Join(tmpDir, \"config.json\")\n\n\tcfg := DefaultConfig()\n\tif err := SaveConfig(path, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig failed: %v\", err)\n\t}\n\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadFile failed: %v\", err)\n\t}\n\n\tif !strings.Contains(string(data), `\"model_name\": \"\"`) {\n\t\tt.Fatalf(\"saved config should include empty legacy model_name field, got: %s\", string(data))\n\t}\n}\n\n// TestConfig_Complete verifies all config fields are set\nfunc TestConfig_Complete(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.Workspace == \"\" {\n\t\tt.Error(\"Workspace should not be empty\")\n\t}\n\tif cfg.Agents.Defaults.Model != \"\" {\n\t\tt.Error(\"Model should be empty\")\n\t}\n\tif cfg.Agents.Defaults.Temperature != nil {\n\t\tt.Error(\"Temperature should be nil when not provided\")\n\t}\n\tif cfg.Agents.Defaults.MaxTokens == 0 {\n\t\tt.Error(\"MaxTokens should not be zero\")\n\t}\n\tif cfg.Agents.Defaults.MaxToolIterations == 0 {\n\t\tt.Error(\"MaxToolIterations should not be zero\")\n\t}\n\tif cfg.Gateway.Host != \"127.0.0.1\" {\n\t\tt.Error(\"Gateway host should have default value\")\n\t}\n\tif cfg.Gateway.Port == 0 {\n\t\tt.Error(\"Gateway port should have default value\")\n\t}\n\tif !cfg.Heartbeat.Enabled {\n\t\tt.Error(\"Heartbeat should be enabled by default\")\n\t}\n}\n\nfunc TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {\n\tcfg := DefaultConfig()\n\tif !cfg.Providers.OpenAI.WebSearch {\n\t\tt.Fatal(\"DefaultConfig().Providers.OpenAI.WebSearch should be true\")\n\t}\n}\n\nfunc TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {\n\tcfg := DefaultConfig()\n\tif !cfg.Tools.Web.PreferNative {\n\t\tt.Fatal(\"DefaultConfig().Tools.Web.PreferNative should be true\")\n\t}\n}\n\nfunc TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"tools\":{\"web\":{\"enabled\":true}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif !cfg.Tools.Web.PreferNative {\n\t\tt.Fatal(\"PreferNative should remain true when unset in config file\")\n\t}\n}\n\nfunc TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"tools\":{\"web\":{\"prefer_native\":false}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif cfg.Tools.Web.PreferNative {\n\t\tt.Fatal(\"PreferNative should be false when disabled in config file\")\n\t}\n}\n\nfunc TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {\n\tcfg := DefaultConfig()\n\tif !cfg.Tools.Exec.AllowRemote {\n\t\tt.Fatal(\"DefaultConfig().Tools.Exec.AllowRemote should be true\")\n\t}\n}\n\nfunc TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {\n\tcfg := DefaultConfig()\n\tif !cfg.Tools.Cron.AllowCommand {\n\t\tt.Fatal(\"DefaultConfig().Tools.Cron.AllowCommand should be true\")\n\t}\n}\n\nfunc TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"providers\":{\"openai\":{\"api_base\":\"\"}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif !cfg.Providers.OpenAI.WebSearch {\n\t\tt.Fatal(\"OpenAI codex web search should remain true when unset in config file\")\n\t}\n}\n\nfunc TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"tools\":{\"exec\":{\"enable_deny_patterns\":true}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif !cfg.Tools.Exec.AllowRemote {\n\t\tt.Fatal(\"tools.exec.allow_remote should remain true when unset in config file\")\n\t}\n}\n\nfunc TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"tools\":{\"cron\":{\"exec_timeout_minutes\":5}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif !cfg.Tools.Cron.AllowCommand {\n\t\tt.Fatal(\"tools.cron.allow_command should remain true when unset in config file\")\n\t}\n}\n\nfunc TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{\"providers\":{\"openai\":{\"web_search\":false}}}`), 0o600); err != nil {\n\t\tt.Fatalf(\"WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif cfg.Providers.OpenAI.WebSearch {\n\t\tt.Fatal(\"OpenAI codex web search should be false when disabled in config file\")\n\t}\n}\n\nfunc TestLoadConfig_WebToolsProxy(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.json\")\n\tconfigJSON := `{\n  \"agents\": {\"defaults\":{\"workspace\":\"./workspace\",\"model\":\"gpt4\",\"max_tokens\":8192,\"max_tool_iterations\":20}},\n  \"model_list\": [{\"model_name\":\"gpt4\",\"model\":\"openai/gpt-5.4\",\"api_key\":\"x\"}],\n  \"tools\": {\"web\":{\"proxy\":\"http://127.0.0.1:7890\"}}\n}`\n\tif err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {\n\t\tt.Fatalf(\"os.WriteFile() error: %v\", err)\n\t}\n\n\tcfg, err := LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error: %v\", err)\n\t}\n\tif cfg.Tools.Web.Proxy != \"http://127.0.0.1:7890\" {\n\t\tt.Fatalf(\"Tools.Web.Proxy = %q, want %q\", cfg.Tools.Web.Proxy, \"http://127.0.0.1:7890\")\n\t}\n}\n\n// TestDefaultConfig_DMScope verifies the default dm_scope value\n// TestDefaultConfig_SummarizationThresholds verifies summarization defaults\nfunc TestDefaultConfig_SummarizationThresholds(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Agents.Defaults.SummarizeMessageThreshold != 20 {\n\t\tt.Errorf(\"SummarizeMessageThreshold = %d, want 20\", cfg.Agents.Defaults.SummarizeMessageThreshold)\n\t}\n\tif cfg.Agents.Defaults.SummarizeTokenPercent != 75 {\n\t\tt.Errorf(\"SummarizeTokenPercent = %d, want 75\", cfg.Agents.Defaults.SummarizeTokenPercent)\n\t}\n}\n\nfunc TestDefaultConfig_DMScope(t *testing.T) {\n\tcfg := DefaultConfig()\n\n\tif cfg.Session.DMScope != \"per-channel-peer\" {\n\t\tt.Errorf(\"Session.DMScope = %q, want 'per-channel-peer'\", cfg.Session.DMScope)\n\t}\n}\n\nfunc TestDefaultConfig_WorkspacePath_Default(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_HOME\", \"\")\n\n\tvar fakeHome string\n\tif runtime.GOOS == \"windows\" {\n\t\tfakeHome = `C:\\tmp\\home`\n\t\tt.Setenv(\"USERPROFILE\", fakeHome)\n\t} else {\n\t\tfakeHome = \"/tmp/home\"\n\t\tt.Setenv(\"HOME\", fakeHome)\n\t}\n\n\tcfg := DefaultConfig()\n\twant := filepath.Join(fakeHome, \".picoclaw\", \"workspace\")\n\n\tif cfg.Agents.Defaults.Workspace != want {\n\t\tt.Errorf(\"Default workspace path = %q, want %q\", cfg.Agents.Defaults.Workspace, want)\n\t}\n}\n\nfunc TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_HOME\", \"/custom/picoclaw/home\")\n\n\tcfg := DefaultConfig()\n\twant := filepath.Join(\"/custom/picoclaw/home\", \"workspace\")\n\n\tif cfg.Agents.Defaults.Workspace != want {\n\t\tt.Errorf(\"Workspace path with PICOCLAW_HOME = %q, want %q\", cfg.Agents.Defaults.Workspace, want)\n\t}\n}\n\n// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators\nfunc TestFlexibleStringSlice_UnmarshalText(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\tname:     \"English commas only\",\n\t\t\tinput:    \"123,456,789\",\n\t\t\texpected: []string{\"123\", \"456\", \"789\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Chinese commas only\",\n\t\t\tinput:    \"123，456，789\",\n\t\t\texpected: []string{\"123\", \"456\", \"789\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Mixed English and Chinese commas\",\n\t\t\tinput:    \"123,456，789\",\n\t\t\texpected: []string{\"123\", \"456\", \"789\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Single value\",\n\t\t\tinput:    \"123\",\n\t\t\texpected: []string{\"123\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Values with whitespace\",\n\t\t\tinput:    \" 123 , 456 , 789 \",\n\t\t\texpected: []string{\"123\", \"456\", \"789\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"Only commas - English\",\n\t\t\tinput:    \",,\",\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tname:     \"Only commas - Chinese\",\n\t\t\tinput:    \"，，\",\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tname:     \"Mixed commas with empty parts\",\n\t\t\tinput:    \"123,,456，，789\",\n\t\t\texpected: []string{\"123\", \"456\", \"789\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"Complex mixed values\",\n\t\t\tinput:    \"user1@example.com，user2@test.com, admin@domain.org\",\n\t\t\texpected: []string{\"user1@example.com\", \"user2@test.com\", \"admin@domain.org\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar f FlexibleStringSlice\n\t\t\terr := f.UnmarshalText([]byte(tt.input))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UnmarshalText(%q) error = %v\", tt.input, err)\n\t\t\t}\n\n\t\t\tif tt.expected == nil {\n\t\t\t\tif f != nil {\n\t\t\t\t\tt.Errorf(\"UnmarshalText(%q) = %v, want nil\", tt.input, f)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(f) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"UnmarshalText(%q) length = %d, want %d\", tt.input, len(f), len(tt.expected))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfor i, v := range tt.expected {\n\t\t\t\tif f[i] != v {\n\t\t\t\t\tt.Errorf(\"UnmarshalText(%q)[%d] = %q, want %q\", tt.input, i, f[i], v)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior\nfunc TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {\n\tt.Run(\"Empty string returns nil\", func(t *testing.T) {\n\t\tvar f FlexibleStringSlice\n\t\terr := f.UnmarshalText([]byte(\"\"))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"UnmarshalText error = %v\", err)\n\t\t}\n\t\tif f != nil {\n\t\t\tt.Errorf(\"Empty string should return nil, got %v\", f)\n\t\t}\n\t})\n\n\tt.Run(\"Commas only returns empty slice\", func(t *testing.T) {\n\t\tvar f FlexibleStringSlice\n\t\terr := f.UnmarshalText([]byte(\",,,\"))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"UnmarshalText error = %v\", err)\n\t\t}\n\t\tif f == nil {\n\t\t\tt.Error(\"Commas only should return empty slice, not nil\")\n\t\t}\n\t\tif len(f) != 0 {\n\t\t\tt.Errorf(\"Expected empty slice, got %v\", f)\n\t\t}\n\t})\n}\n\n// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext\n// api_key into memory but does NOT rewrite the config file. File writes are the sole\n// responsibility of SaveConfig.\nfunc TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\tconst original = `{\"model_list\":[{\"model_name\":\"test\",\"model\":\"openai/gpt-4\",\"api_key\":\"sk-plaintext\"}]}`\n\tif err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"test-passphrase\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\n\tcfg, err := LoadConfig(cfgPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig: %v\", err)\n\t}\n\t// In-memory value must be the resolved plaintext.\n\tif cfg.ModelList[0].APIKey != \"sk-plaintext\" {\n\t\tt.Errorf(\"in-memory api_key = %q, want %q\", cfg.ModelList[0].APIKey, \"sk-plaintext\")\n\t}\n\t// The file on disk must remain unchanged — LoadConfig must not write anything.\n\traw, _ := os.ReadFile(cfgPath)\n\tif string(raw) != original {\n\t\tt.Errorf(\"LoadConfig must not modify the config file; got:\\n%s\", string(raw))\n\t}\n}\n\n// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext\n// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext.\nfunc TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"test-passphrase\")\n\tmustSetupSSHKey(t)\n\n\tcfg := DefaultConfig()\n\tcfg.ModelList = []ModelConfig{\n\t\t{ModelName: \"test\", Model: \"openai/gpt-4\", APIKey: \"sk-plaintext\"},\n\t}\n\tif err := SaveConfig(cfgPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig: %v\", err)\n\t}\n\n\t// Disk must contain enc://, not the raw key.\n\traw, _ := os.ReadFile(cfgPath)\n\tif !strings.Contains(string(raw), \"enc://\") {\n\t\tt.Errorf(\"saved file should contain enc://, got:\\n%s\", string(raw))\n\t}\n\tif strings.Contains(string(raw), \"sk-plaintext\") {\n\t\tt.Errorf(\"saved file must not contain the plaintext key\")\n\t}\n\n\t// A fresh load must decrypt back to the original plaintext.\n\tcfg2, err := LoadConfig(cfgPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig after SaveConfig: %v\", err)\n\t}\n\tif cfg2.ModelList[0].APIKey != \"sk-plaintext\" {\n\t\tt.Errorf(\"loaded api_key = %q, want %q\", cfg2.ModelList[0].APIKey, \"sk-plaintext\")\n\t}\n}\n\n// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left\n// unchanged when PICOCLAW_KEY_PASSPHRASE is not set.\nfunc TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\"model_list\":[{\"model_name\":\"test\",\"model\":\"openai/gpt-4\",\"api_key\":\"sk-plaintext\"}]}`\n\tif err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\n\tif _, err := LoadConfig(cfgPath); err != nil {\n\t\tt.Fatalf(\"LoadConfig: %v\", err)\n\t}\n\n\traw, _ := os.ReadFile(cfgPath)\n\tif strings.Contains(string(raw), \"enc://\") {\n\t\tt.Error(\"config file must not be modified when no passphrase is set\")\n\t}\n}\n\n// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not\n// converted to enc:// values (they are resolved at runtime by the Resolver).\nfunc TestLoadConfig_FileRefNotSealed(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\tkeyFile := filepath.Join(dir, \"openai.key\")\n\tif err := os.WriteFile(keyFile, []byte(\"sk-from-file\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tdata := `{\"model_list\":[{\"model_name\":\"test\",\"model\":\"openai/gpt-4\",\"api_key\":\"file://openai.key\"}]}`\n\tif err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"test-passphrase\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\n\tif _, err := LoadConfig(cfgPath); err != nil {\n\t\tt.Fatalf(\"LoadConfig: %v\", err)\n\t}\n\n\traw, _ := os.ReadFile(cfgPath)\n\tif !strings.Contains(string(raw), \"file://openai.key\") {\n\t\tt.Error(\"file:// reference should be preserved unchanged in the config file\")\n\t}\n\tif strings.Contains(string(raw), \"enc://\") {\n\t\tt.Error(\"file:// reference must not be converted to enc://\")\n\t}\n}\n\n// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys\n// and leaves already-encrypted (enc://) and file:// entries unchanged.\nfunc TestSaveConfig_MixedKeys(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"test-passphrase\")\n\tmustSetupSSHKey(t)\n\n\t// Pre-encrypt one key so we have a genuine enc:// value to put in the config.\n\tif err := SaveConfig(cfgPath, &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"pre\", Model: \"openai/gpt-4\", APIKey: \"sk-already-plain\"},\n\t\t},\n\t}); err != nil {\n\t\tt.Fatalf(\"setup SaveConfig: %v\", err)\n\t}\n\traw, _ := os.ReadFile(cfgPath)\n\t// Extract the enc:// value from the saved file.\n\tvar tmp struct {\n\t\tModelList []struct {\n\t\t\tAPIKey string `json:\"api_key\"`\n\t\t} `json:\"model_list\"`\n\t}\n\tif err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {\n\t\tt.Fatalf(\"setup: could not parse saved config: %v\", err)\n\t}\n\talreadyEncrypted := tmp.ModelList[0].APIKey\n\tif !strings.HasPrefix(alreadyEncrypted, \"enc://\") {\n\t\tt.Fatalf(\"setup: expected enc:// key, got %q\", alreadyEncrypted)\n\t}\n\n\t// Build a config with three models:\n\t//   1. plaintext   → must be encrypted by SaveConfig\n\t//   2. enc://      → must be left unchanged (already encrypted)\n\t//   3. file://     → must be left unchanged (file reference)\n\tkeyFile := filepath.Join(dir, \"api.key\")\n\tif err := os.WriteFile(keyFile, []byte(\"sk-from-file\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"plain\", Model: \"openai/gpt-4\", APIKey: \"sk-new-plaintext\"},\n\t\t\t{ModelName: \"enc\", Model: \"openai/gpt-4\", APIKey: alreadyEncrypted},\n\t\t\t{ModelName: \"file\", Model: \"openai/gpt-4\", APIKey: \"file://api.key\"},\n\t\t},\n\t}\n\tif err := SaveConfig(cfgPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig: %v\", err)\n\t}\n\n\traw, _ = os.ReadFile(cfgPath)\n\ts := string(raw)\n\n\t// 1. Plaintext must be encrypted.\n\tif strings.Contains(s, \"sk-new-plaintext\") {\n\t\tt.Error(\"plaintext key must not appear in saved file\")\n\t}\n\t// 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged).\n\tif !strings.Contains(s, alreadyEncrypted) {\n\t\tt.Error(\"pre-existing enc:// entry must be preserved unchanged\")\n\t}\n\t// 3. file:// must be preserved.\n\tif !strings.Contains(s, \"file://api.key\") {\n\t\tt.Error(\"file:// reference must be preserved unchanged\")\n\t}\n\n\t// Now load and verify all three decrypt/resolve correctly.\n\tcfg2, err := LoadConfig(cfgPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig after SaveConfig: %v\", err)\n\t}\n\tbyName := make(map[string]string)\n\tfor _, m := range cfg2.ModelList {\n\t\tbyName[m.ModelName] = m.APIKey\n\t}\n\tif byName[\"plain\"] != \"sk-new-plaintext\" {\n\t\tt.Errorf(\"plain model api_key = %q, want %q\", byName[\"plain\"], \"sk-new-plaintext\")\n\t}\n\tif byName[\"enc\"] != \"sk-already-plain\" {\n\t\tt.Errorf(\"enc model api_key = %q, want %q\", byName[\"enc\"], \"sk-already-plain\")\n\t}\n\tif byName[\"file\"] != \"sk-from-file\" {\n\t\tt.Errorf(\"file model api_key = %q, want %q\", byName[\"file\"], \"sk-from-file\")\n\t}\n}\n\n// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE\n// is not set, enc:// entries cause LoadConfig to return an error, while plaintext\n// and file:// entries in the same config are not affected.\nfunc TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\n\t// First encrypt a key so we have a real enc:// value.\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"test-passphrase\")\n\tmustSetupSSHKey(t)\n\tif err := SaveConfig(cfgPath, &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"m\", Model: \"openai/gpt-4\", APIKey: \"sk-secret\"},\n\t\t},\n\t}); err != nil {\n\t\tt.Fatalf(\"setup SaveConfig: %v\", err)\n\t}\n\traw, _ := os.ReadFile(cfgPath)\n\tvar tmp struct {\n\t\tModelList []struct {\n\t\t\tAPIKey string `json:\"api_key\"`\n\t\t} `json:\"model_list\"`\n\t}\n\tif err := json.Unmarshal(raw, &tmp); err != nil {\n\t\tt.Fatalf(\"setup parse: %v\", err)\n\t}\n\tencValue := tmp.ModelList[0].APIKey\n\n\t// Write a mixed config: enc:// + plaintext + file://\n\tkeyFile := filepath.Join(dir, \"api.key\")\n\tif err := os.WriteFile(keyFile, []byte(\"sk-from-file\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tmixed, _ := json.Marshal(map[string]any{\n\t\t\"model_list\": []map[string]any{\n\t\t\t{\"model_name\": \"enc\", \"model\": \"openai/gpt-4\", \"api_key\": encValue},\n\t\t\t{\"model_name\": \"plain\", \"model\": \"openai/gpt-4\", \"api_key\": \"sk-plain\"},\n\t\t\t{\"model_name\": \"file\", \"model\": \"openai/gpt-4\", \"api_key\": \"file://api.key\"},\n\t\t},\n\t})\n\tif err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {\n\t\tt.Fatalf(\"setup write: %v\", err)\n\t}\n\n\t// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"\")\n\n\t_, err := LoadConfig(cfgPath)\n\tif err == nil {\n\t\tt.Fatal(\"LoadConfig should fail when enc:// key is present and no passphrase is set\")\n\t}\n\tif !strings.Contains(err.Error(), \"passphrase required\") {\n\t\tt.Errorf(\"error should mention passphrase required, got: %v\", err)\n\t}\n}\n\n// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext\n// api_keys using credential.PassphraseProvider() rather than os.Getenv directly.\n// This matters for the launcher, which clears the environment variable and redirects\n// PassphraseProvider to an in-memory SecureStore.\nfunc TestSaveConfig_UsesPassphraseProvider(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\n\t// Ensure the env var is empty — passphrase must come from PassphraseProvider only.\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"\")\n\tmustSetupSSHKey(t)\n\n\t// Replace PassphraseProvider with an in-memory function (simulating SecureStore).\n\tconst testPassphrase = \"provider-passphrase\"\n\torig := credential.PassphraseProvider\n\tcredential.PassphraseProvider = func() string { return testPassphrase }\n\tt.Cleanup(func() { credential.PassphraseProvider = orig })\n\n\tcfg := DefaultConfig()\n\tcfg.ModelList = []ModelConfig{\n\t\t{ModelName: \"test\", Model: \"openai/gpt-4\", APIKey: \"sk-plaintext\"},\n\t}\n\tif err := SaveConfig(cfgPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig: %v\", err)\n\t}\n\n\traw, _ := os.ReadFile(cfgPath)\n\tif !strings.Contains(string(raw), \"enc://\") {\n\t\tt.Errorf(\"SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\\n%s\", raw)\n\t}\n}\n\n// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys\n// using credential.PassphraseProvider() rather than os.Getenv directly.\nfunc TestLoadConfig_UsesPassphraseProvider(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\n\t// Ensure the env var is empty throughout.\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"\")\n\tmustSetupSSHKey(t)\n\n\tconst testPassphrase = \"provider-passphrase\"\n\tconst plainKey = \"sk-secret\"\n\n\t// First, encrypt the key using the same passphrase.\n\tencrypted, err := credential.Encrypt(testPassphrase, \"\", plainKey)\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\traw, _ := json.Marshal(map[string]any{\n\t\t\"model_list\": []map[string]any{\n\t\t\t{\"model_name\": \"test\", \"model\": \"openai/gpt-4\", \"api_key\": encrypted},\n\t\t},\n\t})\n\tif err = os.WriteFile(cfgPath, raw, 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\t// Redirect PassphraseProvider — env var is empty, so without this the load would fail.\n\torig := credential.PassphraseProvider\n\tcredential.PassphraseProvider = func() string { return testPassphrase }\n\tt.Cleanup(func() { credential.PassphraseProvider = orig })\n\n\tcfg, err := LoadConfig(cfgPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig: %v\", err)\n\t}\n\tif cfg.ModelList[0].APIKey != plainKey {\n\t\tt.Errorf(\"api_key = %q, want %q\", cfg.ModelList[0].APIKey, plainKey)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/defaults.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\n// DefaultConfig returns the default configuration for PicoClaw.\nfunc DefaultConfig() *Config {\n\t// Determine the base path for the workspace.\n\t// Priority: $PICOCLAW_HOME > ~/.picoclaw\n\tvar homePath string\n\tif picoclawHome := os.Getenv(EnvHome); picoclawHome != \"\" {\n\t\thomePath = picoclawHome\n\t} else {\n\t\tuserHome, _ := os.UserHomeDir()\n\t\thomePath = filepath.Join(userHome, \".picoclaw\")\n\t}\n\tworkspacePath := filepath.Join(homePath, \"workspace\")\n\n\treturn &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tWorkspace:                 workspacePath,\n\t\t\t\tRestrictToWorkspace:       true,\n\t\t\t\tProvider:                  \"\",\n\t\t\t\tModel:                     \"\",\n\t\t\t\tMaxTokens:                 32768,\n\t\t\t\tTemperature:               nil, // nil means use provider default\n\t\t\t\tMaxToolIterations:         50,\n\t\t\t\tSummarizeMessageThreshold: 20,\n\t\t\t\tSummarizeTokenPercent:     75,\n\t\t\t\tToolFeedback: ToolFeedbackConfig{\n\t\t\t\t\tEnabled:       true,\n\t\t\t\t\tMaxArgsLength: 300,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tBindings: []AgentBinding{},\n\t\tSession: SessionConfig{\n\t\t\tDMScope: \"per-channel-peer\",\n\t\t},\n\t\tChannels: ChannelsConfig{\n\t\t\tWhatsApp: WhatsAppConfig{\n\t\t\t\tEnabled:          false,\n\t\t\t\tBridgeURL:        \"ws://localhost:3001\",\n\t\t\t\tUseNative:        false,\n\t\t\t\tSessionStorePath: \"\",\n\t\t\t\tAllowFrom:        FlexibleStringSlice{},\n\t\t\t},\n\t\t\tTelegram: TelegramConfig{\n\t\t\t\tEnabled:   false,\n\t\t\t\tToken:     \"\",\n\t\t\t\tAllowFrom: FlexibleStringSlice{},\n\t\t\t\tTyping:    TypingConfig{Enabled: true},\n\t\t\t\tPlaceholder: PlaceholderConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t\tText:    \"Thinking... 💭\",\n\t\t\t\t},\n\t\t\t\tUseMarkdownV2: false,\n\t\t\t},\n\t\t\tFeishu: FeishuConfig{\n\t\t\t\tEnabled:           false,\n\t\t\t\tAppID:             \"\",\n\t\t\t\tAppSecret:         \"\",\n\t\t\t\tEncryptKey:        \"\",\n\t\t\t\tVerificationToken: \"\",\n\t\t\t\tAllowFrom:         FlexibleStringSlice{},\n\t\t\t},\n\t\t\tDiscord: DiscordConfig{\n\t\t\t\tEnabled:     false,\n\t\t\t\tToken:       \"\",\n\t\t\t\tAllowFrom:   FlexibleStringSlice{},\n\t\t\t\tMentionOnly: false,\n\t\t\t},\n\t\t\tMaixCam: MaixCamConfig{\n\t\t\t\tEnabled:   false,\n\t\t\t\tHost:      \"0.0.0.0\",\n\t\t\t\tPort:      18790,\n\t\t\t\tAllowFrom: FlexibleStringSlice{},\n\t\t\t},\n\t\t\tQQ: QQConfig{\n\t\t\t\tEnabled:              false,\n\t\t\t\tAppID:                \"\",\n\t\t\t\tAppSecret:            \"\",\n\t\t\t\tAllowFrom:            FlexibleStringSlice{},\n\t\t\t\tMaxMessageLength:     2000,\n\t\t\t\tMaxBase64FileSizeMiB: 0,\n\t\t\t},\n\t\t\tDingTalk: DingTalkConfig{\n\t\t\t\tEnabled:      false,\n\t\t\t\tClientID:     \"\",\n\t\t\t\tClientSecret: \"\",\n\t\t\t\tAllowFrom:    FlexibleStringSlice{},\n\t\t\t},\n\t\t\tSlack: SlackConfig{\n\t\t\t\tEnabled:   false,\n\t\t\t\tBotToken:  \"\",\n\t\t\t\tAppToken:  \"\",\n\t\t\t\tAllowFrom: FlexibleStringSlice{},\n\t\t\t},\n\t\t\tMatrix: MatrixConfig{\n\t\t\t\tEnabled:      false,\n\t\t\t\tHomeserver:   \"https://matrix.org\",\n\t\t\t\tUserID:       \"\",\n\t\t\t\tAccessToken:  \"\",\n\t\t\t\tDeviceID:     \"\",\n\t\t\t\tJoinOnInvite: true,\n\t\t\t\tAllowFrom:    FlexibleStringSlice{},\n\t\t\t\tGroupTrigger: GroupTriggerConfig{\n\t\t\t\t\tMentionOnly: true,\n\t\t\t\t},\n\t\t\t\tPlaceholder: PlaceholderConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t\tText:    \"Thinking... 💭\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tLINE: LINEConfig{\n\t\t\t\tEnabled:            false,\n\t\t\t\tChannelSecret:      \"\",\n\t\t\t\tChannelAccessToken: \"\",\n\t\t\t\tWebhookHost:        \"0.0.0.0\",\n\t\t\t\tWebhookPort:        18791,\n\t\t\t\tWebhookPath:        \"/webhook/line\",\n\t\t\t\tAllowFrom:          FlexibleStringSlice{},\n\t\t\t\tGroupTrigger:       GroupTriggerConfig{MentionOnly: true},\n\t\t\t},\n\t\t\tOneBot: OneBotConfig{\n\t\t\t\tEnabled:            false,\n\t\t\t\tWSUrl:              \"ws://127.0.0.1:3001\",\n\t\t\t\tAccessToken:        \"\",\n\t\t\t\tReconnectInterval:  5,\n\t\t\t\tGroupTriggerPrefix: []string{},\n\t\t\t\tAllowFrom:          FlexibleStringSlice{},\n\t\t\t},\n\t\t\tWeCom: WeComConfig{\n\t\t\t\tEnabled:        false,\n\t\t\t\tToken:          \"\",\n\t\t\t\tEncodingAESKey: \"\",\n\t\t\t\tWebhookURL:     \"\",\n\t\t\t\tWebhookHost:    \"0.0.0.0\",\n\t\t\t\tWebhookPort:    18793,\n\t\t\t\tWebhookPath:    \"/webhook/wecom\",\n\t\t\t\tAllowFrom:      FlexibleStringSlice{},\n\t\t\t\tReplyTimeout:   5,\n\t\t\t},\n\t\t\tWeComApp: WeComAppConfig{\n\t\t\t\tEnabled:        false,\n\t\t\t\tCorpID:         \"\",\n\t\t\t\tCorpSecret:     \"\",\n\t\t\t\tAgentID:        0,\n\t\t\t\tToken:          \"\",\n\t\t\t\tEncodingAESKey: \"\",\n\t\t\t\tWebhookHost:    \"0.0.0.0\",\n\t\t\t\tWebhookPort:    18792,\n\t\t\t\tWebhookPath:    \"/webhook/wecom-app\",\n\t\t\t\tAllowFrom:      FlexibleStringSlice{},\n\t\t\t\tReplyTimeout:   5,\n\t\t\t},\n\t\t\tWeComAIBot: WeComAIBotConfig{\n\t\t\t\tEnabled:           false,\n\t\t\t\tToken:             \"\",\n\t\t\t\tEncodingAESKey:    \"\",\n\t\t\t\tWebhookPath:       \"/webhook/wecom-aibot\",\n\t\t\t\tAllowFrom:         FlexibleStringSlice{},\n\t\t\t\tReplyTimeout:      5,\n\t\t\t\tMaxSteps:          10,\n\t\t\t\tWelcomeMessage:    \"Hello! I'm your AI assistant. How can I help you today?\",\n\t\t\t\tProcessingMessage: DefaultWeComAIBotProcessingMessage,\n\t\t\t},\n\t\t\tPico: PicoConfig{\n\t\t\t\tEnabled:        false,\n\t\t\t\tToken:          \"\",\n\t\t\t\tPingInterval:   30,\n\t\t\t\tReadTimeout:    60,\n\t\t\t\tWriteTimeout:   10,\n\t\t\t\tMaxConnections: 100,\n\t\t\t\tAllowFrom:      FlexibleStringSlice{},\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{WebSearch: true},\n\t\t},\n\t\tModelList: []ModelConfig{\n\t\t\t// ============================================\n\t\t\t// Add your API key to the model you want to use\n\t\t\t// ============================================\n\n\t\t\t// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys\n\t\t\t{\n\t\t\t\tModelName: \"glm-4.7\",\n\t\t\t\tModel:     \"zhipu/glm-4.7\",\n\t\t\t\tAPIBase:   \"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// OpenAI - https://platform.openai.com/api-keys\n\t\t\t{\n\t\t\t\tModelName: \"gpt-5.4\",\n\t\t\t\tModel:     \"openai/gpt-5.4\",\n\t\t\t\tAPIBase:   \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Anthropic Claude - https://console.anthropic.com/settings/keys\n\t\t\t{\n\t\t\t\tModelName: \"claude-sonnet-4.6\",\n\t\t\t\tModel:     \"anthropic/claude-sonnet-4.6\",\n\t\t\t\tAPIBase:   \"https://api.anthropic.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// DeepSeek - https://platform.deepseek.com/\n\t\t\t{\n\t\t\t\tModelName: \"deepseek-chat\",\n\t\t\t\tModel:     \"deepseek/deepseek-chat\",\n\t\t\t\tAPIBase:   \"https://api.deepseek.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Google Gemini - https://ai.google.dev/\n\t\t\t{\n\t\t\t\tModelName: \"gemini-2.0-flash\",\n\t\t\t\tModel:     \"gemini/gemini-2.0-flash-exp\",\n\t\t\t\tAPIBase:   \"https://generativelanguage.googleapis.com/v1beta\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey\n\t\t\t{\n\t\t\t\tModelName: \"qwen-plus\",\n\t\t\t\tModel:     \"qwen/qwen-plus\",\n\t\t\t\tAPIBase:   \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys\n\t\t\t{\n\t\t\t\tModelName: \"moonshot-v1-8k\",\n\t\t\t\tModel:     \"moonshot/moonshot-v1-8k\",\n\t\t\t\tAPIBase:   \"https://api.moonshot.cn/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Groq - https://console.groq.com/keys\n\t\t\t{\n\t\t\t\tModelName: \"llama-3.3-70b\",\n\t\t\t\tModel:     \"groq/llama-3.3-70b-versatile\",\n\t\t\t\tAPIBase:   \"https://api.groq.com/openai/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// OpenRouter (100+ models) - https://openrouter.ai/keys\n\t\t\t{\n\t\t\t\tModelName: \"openrouter-auto\",\n\t\t\t\tModel:     \"openrouter/auto\",\n\t\t\t\tAPIBase:   \"https://openrouter.ai/api/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tModelName: \"openrouter-gpt-5.4\",\n\t\t\t\tModel:     \"openrouter/openai/gpt-5.4\",\n\t\t\t\tAPIBase:   \"https://openrouter.ai/api/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// NVIDIA - https://build.nvidia.com/\n\t\t\t{\n\t\t\t\tModelName: \"nemotron-4-340b\",\n\t\t\t\tModel:     \"nvidia/nemotron-4-340b-instruct\",\n\t\t\t\tAPIBase:   \"https://integrate.api.nvidia.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Cerebras - https://inference.cerebras.ai/\n\t\t\t{\n\t\t\t\tModelName: \"cerebras-llama-3.3-70b\",\n\t\t\t\tModel:     \"cerebras/llama-3.3-70b\",\n\t\t\t\tAPIBase:   \"https://api.cerebras.ai/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Vivgrid - https://vivgrid.com\n\t\t\t{\n\t\t\t\tModelName: \"vivgrid-auto\",\n\t\t\t\tModel:     \"vivgrid/auto\",\n\t\t\t\tAPIBase:   \"https://api.vivgrid.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Volcengine (火山引擎) - https://console.volcengine.com/ark\n\t\t\t{\n\t\t\t\tModelName: \"ark-code-latest\",\n\t\t\t\tModel:     \"volcengine/ark-code-latest\",\n\t\t\t\tAPIBase:   \"https://ark.cn-beijing.volces.com/api/v3\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tModelName: \"doubao-pro\",\n\t\t\t\tModel:     \"volcengine/doubao-pro-32k\",\n\t\t\t\tAPIBase:   \"https://ark.cn-beijing.volces.com/api/v3\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// ShengsuanYun (神算云)\n\t\t\t{\n\t\t\t\tModelName: \"deepseek-v3\",\n\t\t\t\tModel:     \"shengsuanyun/deepseek-v3\",\n\t\t\t\tAPIBase:   \"https://api.shengsuanyun.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Antigravity (Google Cloud Code Assist) - OAuth only\n\t\t\t{\n\t\t\t\tModelName:  \"gemini-flash\",\n\t\t\t\tModel:      \"antigravity/gemini-3-flash\",\n\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t},\n\n\t\t\t// GitHub Copilot - https://github.com/settings/tokens\n\t\t\t{\n\t\t\t\tModelName:  \"copilot-gpt-5.4\",\n\t\t\t\tModel:      \"github-copilot/gpt-5.4\",\n\t\t\t\tAPIBase:    \"http://localhost:4321\",\n\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t},\n\n\t\t\t// Ollama (local) - https://ollama.com\n\t\t\t{\n\t\t\t\tModelName: \"llama3\",\n\t\t\t\tModel:     \"ollama/llama3\",\n\t\t\t\tAPIBase:   \"http://localhost:11434/v1\",\n\t\t\t\tAPIKey:    \"ollama\",\n\t\t\t},\n\n\t\t\t// Mistral AI - https://console.mistral.ai/api-keys\n\t\t\t{\n\t\t\t\tModelName: \"mistral-small\",\n\t\t\t\tModel:     \"mistral/mistral-small-latest\",\n\t\t\t\tAPIBase:   \"https://api.mistral.ai/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Avian - https://avian.io\n\t\t\t{\n\t\t\t\tModelName: \"deepseek-v3.2\",\n\t\t\t\tModel:     \"avian/deepseek/deepseek-v3.2\",\n\t\t\t\tAPIBase:   \"https://api.avian.io/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tModelName: \"kimi-k2.5\",\n\t\t\t\tModel:     \"avian/moonshotai/kimi-k2.5\",\n\t\t\t\tAPIBase:   \"https://api.avian.io/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Minimax - https://api.minimaxi.com/\n\t\t\t{\n\t\t\t\tModelName: \"MiniMax-M2.5\",\n\t\t\t\tModel:     \"minimax/MiniMax-M2.5\",\n\t\t\t\tAPIBase:   \"https://api.minimaxi.com/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// LongCat - https://longcat.chat/platform\n\t\t\t{\n\t\t\t\tModelName: \"LongCat-Flash-Thinking\",\n\t\t\t\tModel:     \"longcat/LongCat-Flash-Thinking\",\n\t\t\t\tAPIBase:   \"https://api.longcat.chat/openai\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens\n\t\t\t{\n\t\t\t\tModelName: \"modelscope-qwen\",\n\t\t\t\tModel:     \"modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\t\tAPIBase:   \"https://api-inference.modelscope.cn/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// VLLM (local) - http://localhost:8000\n\t\t\t{\n\t\t\t\tModelName: \"local-model\",\n\t\t\t\tModel:     \"vllm/custom-model\",\n\t\t\t\tAPIBase:   \"http://localhost:8000/v1\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\n\t\t\t// Azure OpenAI - https://portal.azure.com\n\t\t\t// model_name is a user-friendly alias; the model field's path after \"azure/\" is your deployment name\n\t\t\t{\n\t\t\t\tModelName: \"azure-gpt5\",\n\t\t\t\tModel:     \"azure/my-gpt5-deployment\",\n\t\t\t\tAPIBase:   \"https://your-resource.openai.azure.com\",\n\t\t\t\tAPIKey:    \"\",\n\t\t\t},\n\t\t},\n\t\tGateway: GatewayConfig{\n\t\t\tHost:      \"127.0.0.1\",\n\t\t\tPort:      18790,\n\t\t\tHotReload: false,\n\t\t},\n\t\tTools: ToolsConfig{\n\t\t\tMediaCleanup: MediaCleanupConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\tMaxAge:   30,\n\t\t\t\tInterval: 5,\n\t\t\t},\n\t\t\tWeb: WebToolsConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\tPreferNative:    true,\n\t\t\t\tProxy:           \"\",\n\t\t\t\tFetchLimitBytes: 10 * 1024 * 1024, // 10MB by default\n\t\t\t\tFormat:          \"plaintext\",\n\t\t\t\tBrave: BraveConfig{\n\t\t\t\t\tEnabled:    false,\n\t\t\t\t\tAPIKey:     \"\",\n\t\t\t\t\tAPIKeys:    nil,\n\t\t\t\t\tMaxResults: 5,\n\t\t\t\t},\n\t\t\t\tTavily: TavilyConfig{\n\t\t\t\t\tEnabled:    false,\n\t\t\t\t\tAPIKey:     \"\",\n\t\t\t\t\tAPIKeys:    nil,\n\t\t\t\t\tMaxResults: 5,\n\t\t\t\t},\n\t\t\t\tDuckDuckGo: DuckDuckGoConfig{\n\t\t\t\t\tEnabled:    true,\n\t\t\t\t\tMaxResults: 5,\n\t\t\t\t},\n\t\t\t\tPerplexity: PerplexityConfig{\n\t\t\t\t\tEnabled:    false,\n\t\t\t\t\tAPIKey:     \"\",\n\t\t\t\t\tAPIKeys:    nil,\n\t\t\t\t\tMaxResults: 5,\n\t\t\t\t},\n\t\t\t\tSearXNG: SearXNGConfig{\n\t\t\t\t\tEnabled:    false,\n\t\t\t\t\tBaseURL:    \"\",\n\t\t\t\t\tMaxResults: 5,\n\t\t\t\t},\n\t\t\t\tGLMSearch: GLMSearchConfig{\n\t\t\t\t\tEnabled:      false,\n\t\t\t\t\tAPIKey:       \"\",\n\t\t\t\t\tBaseURL:      \"https://open.bigmodel.cn/api/paas/v4/web_search\",\n\t\t\t\t\tSearchEngine: \"search_std\",\n\t\t\t\t\tMaxResults:   5,\n\t\t\t\t},\n\t\t\t},\n\t\t\tCron: CronToolsConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\tExecTimeoutMinutes: 5,\n\t\t\t\tAllowCommand:       true,\n\t\t\t},\n\t\t\tExec: ExecConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\tEnableDenyPatterns: true,\n\t\t\t\tAllowRemote:        true,\n\t\t\t\tTimeoutSeconds:     60,\n\t\t\t},\n\t\t\tSkills: SkillsToolsConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t},\n\t\t\t\tRegistries: SkillsRegistriesConfig{\n\t\t\t\t\tClawHub: ClawHubRegistryConfig{\n\t\t\t\t\t\tEnabled: true,\n\t\t\t\t\t\tBaseURL: \"https://clawhub.ai\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tMaxConcurrentSearches: 2,\n\t\t\t\tSearchCache: SearchCacheConfig{\n\t\t\t\t\tMaxSize:    50,\n\t\t\t\t\tTTLSeconds: 300,\n\t\t\t\t},\n\t\t\t},\n\t\t\tSendFile: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tMCP: MCPConfig{\n\t\t\t\tToolConfig: ToolConfig{\n\t\t\t\t\tEnabled: false,\n\t\t\t\t},\n\t\t\t\tDiscovery: ToolDiscoveryConfig{\n\t\t\t\t\tEnabled:          false,\n\t\t\t\t\tTTL:              5,\n\t\t\t\t\tMaxSearchResults: 5,\n\t\t\t\t\tUseBM25:          true,\n\t\t\t\t\tUseRegex:         false,\n\t\t\t\t},\n\t\t\t\tServers: map[string]MCPServerConfig{},\n\t\t\t},\n\t\t\tAppendFile: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tEditFile: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tFindSkills: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tI2C: ToolConfig{\n\t\t\t\tEnabled: false, // Hardware tool - Linux only\n\t\t\t},\n\t\t\tInstallSkill: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tListDir: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tMessage: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tReadFile: ReadFileToolConfig{\n\t\t\t\tEnabled:         true,\n\t\t\t\tMaxReadFileSize: 64 * 1024, // 64KB\n\t\t\t},\n\t\t\tSpawn: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tSpawnStatus: ToolConfig{\n\t\t\t\tEnabled: false,\n\t\t\t},\n\t\t\tSPI: ToolConfig{\n\t\t\t\tEnabled: false, // Hardware tool - Linux only\n\t\t\t},\n\t\t\tSubagent: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tWebFetch: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t\tWriteFile: ToolConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t},\n\t\tHeartbeat: HeartbeatConfig{\n\t\t\tEnabled:  true,\n\t\t\tInterval: 30,\n\t\t},\n\t\tDevices: DevicesConfig{\n\t\t\tEnabled:    false,\n\t\t\tMonitorUSB: true,\n\t\t},\n\t\tVoice: VoiceConfig{\n\t\t\tEchoTranscription: false,\n\t\t},\n\t\tBuildInfo: BuildInfo{\n\t\t\tVersion:   Version,\n\t\t\tGitCommit: GitCommit,\n\t\t\tBuildTime: BuildTime,\n\t\t\tGoVersion: GoVersion,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/config/envkeys.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage config\n\n// Runtime environment variable keys for the picoclaw process.\n// These control the location of files and binaries at runtime and are read\n// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the\n// PICOCLAW_ prefix. Reference these constants instead of inline string\n// literals to keep all supported knobs visible in one place and to prevent\n// typos.\nconst (\n\t// EnvHome overrides the base directory for all picoclaw data\n\t// (config, workspace, skills, auth store, …).\n\t// Default: ~/.picoclaw\n\tEnvHome = \"PICOCLAW_HOME\"\n\n\t// EnvConfig overrides the full path to the JSON config file.\n\t// Default: $PICOCLAW_HOME/config.json\n\tEnvConfig = \"PICOCLAW_CONFIG\"\n\n\t// EnvBuiltinSkills overrides the directory from which built-in\n\t// skills are loaded.\n\t// Default: <cwd>/skills\n\tEnvBuiltinSkills = \"PICOCLAW_BUILTIN_SKILLS\"\n\n\t// EnvBinary overrides the path to the picoclaw executable.\n\t// Used by the web launcher when spawning the gateway subprocess.\n\t// Default: resolved from the same directory as the current executable.\n\tEnvBinary = \"PICOCLAW_BINARY\"\n\n\t// EnvGatewayHost overrides the host address for the gateway server.\n\t// Default: \"127.0.0.1\"\n\tEnvGatewayHost = \"PICOCLAW_GATEWAY_HOST\"\n)\n"
  },
  {
    "path": "pkg/config/migration.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage config\n\nimport (\n\t\"slices\"\n\t\"strings\"\n)\n\n// buildModelWithProtocol constructs a model string with protocol prefix.\n// If the model already contains a \"/\" (indicating it has a protocol prefix), it is returned as-is.\n// Otherwise, the protocol prefix is added.\nfunc buildModelWithProtocol(protocol, model string) string {\n\tif strings.Contains(model, \"/\") {\n\t\t// Model already has a protocol prefix, return as-is\n\t\treturn model\n\t}\n\treturn protocol + \"/\" + model\n}\n\n// providerMigrationConfig defines how to migrate a provider from old config to new format.\ntype providerMigrationConfig struct {\n\t// providerNames are the possible names used in agents.defaults.provider\n\tproviderNames []string\n\t// protocol is the protocol prefix for the model field\n\tprotocol string\n\t// buildConfig creates the ModelConfig from ProviderConfig\n\tbuildConfig func(p ProvidersConfig) (ModelConfig, bool)\n}\n\n// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.\n// This enables backward compatibility with existing configurations.\n// It preserves the user's configured model from agents.defaults.model when possible.\nfunc ConvertProvidersToModelList(cfg *Config) []ModelConfig {\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\n\t// Get user's configured provider and model\n\tuserProvider := strings.ToLower(cfg.Agents.Defaults.Provider)\n\tuserModel := cfg.Agents.Defaults.GetModelName()\n\n\tp := cfg.Providers\n\n\tvar result []ModelConfig\n\n\t// Track if we've applied the legacy model name fix (only for first provider)\n\tlegacyModelNameApplied := false\n\n\t// Define migration rules for each provider\n\tmigrations := []providerMigrationConfig{\n\t\t{\n\t\t\tproviderNames: []string{\"openai\", \"gpt\"},\n\t\t\tprotocol:      \"openai\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.OpenAI.APIKey == \"\" && p.OpenAI.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"openai\",\n\t\t\t\t\tModel:          \"openai/gpt-5.4\",\n\t\t\t\t\tAPIKey:         p.OpenAI.APIKey,\n\t\t\t\t\tAPIBase:        p.OpenAI.APIBase,\n\t\t\t\t\tProxy:          p.OpenAI.Proxy,\n\t\t\t\t\tRequestTimeout: p.OpenAI.RequestTimeout,\n\t\t\t\t\tAuthMethod:     p.OpenAI.AuthMethod,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"anthropic\", \"claude\"},\n\t\t\tprotocol:      \"anthropic\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Anthropic.APIKey == \"\" && p.Anthropic.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"anthropic\",\n\t\t\t\t\tModel:          \"anthropic/claude-sonnet-4.6\",\n\t\t\t\t\tAPIKey:         p.Anthropic.APIKey,\n\t\t\t\t\tAPIBase:        p.Anthropic.APIBase,\n\t\t\t\t\tProxy:          p.Anthropic.Proxy,\n\t\t\t\t\tRequestTimeout: p.Anthropic.RequestTimeout,\n\t\t\t\t\tAuthMethod:     p.Anthropic.AuthMethod,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"litellm\"},\n\t\t\tprotocol:      \"litellm\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.LiteLLM.APIKey == \"\" && p.LiteLLM.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"litellm\",\n\t\t\t\t\tModel:          \"litellm/auto\",\n\t\t\t\t\tAPIKey:         p.LiteLLM.APIKey,\n\t\t\t\t\tAPIBase:        p.LiteLLM.APIBase,\n\t\t\t\t\tProxy:          p.LiteLLM.Proxy,\n\t\t\t\t\tRequestTimeout: p.LiteLLM.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"openrouter\"},\n\t\t\tprotocol:      \"openrouter\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.OpenRouter.APIKey == \"\" && p.OpenRouter.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"openrouter\",\n\t\t\t\t\tModel:          \"openrouter/auto\",\n\t\t\t\t\tAPIKey:         p.OpenRouter.APIKey,\n\t\t\t\t\tAPIBase:        p.OpenRouter.APIBase,\n\t\t\t\t\tProxy:          p.OpenRouter.Proxy,\n\t\t\t\t\tRequestTimeout: p.OpenRouter.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"groq\"},\n\t\t\tprotocol:      \"groq\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Groq.APIKey == \"\" && p.Groq.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"groq\",\n\t\t\t\t\tModel:          \"groq/llama-3.1-70b-versatile\",\n\t\t\t\t\tAPIKey:         p.Groq.APIKey,\n\t\t\t\t\tAPIBase:        p.Groq.APIBase,\n\t\t\t\t\tProxy:          p.Groq.Proxy,\n\t\t\t\t\tRequestTimeout: p.Groq.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"zhipu\", \"glm\"},\n\t\t\tprotocol:      \"zhipu\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Zhipu.APIKey == \"\" && p.Zhipu.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"zhipu\",\n\t\t\t\t\tModel:          \"zhipu/glm-4\",\n\t\t\t\t\tAPIKey:         p.Zhipu.APIKey,\n\t\t\t\t\tAPIBase:        p.Zhipu.APIBase,\n\t\t\t\t\tProxy:          p.Zhipu.Proxy,\n\t\t\t\t\tRequestTimeout: p.Zhipu.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"vllm\"},\n\t\t\tprotocol:      \"vllm\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.VLLM.APIKey == \"\" && p.VLLM.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"vllm\",\n\t\t\t\t\tModel:          \"vllm/auto\",\n\t\t\t\t\tAPIKey:         p.VLLM.APIKey,\n\t\t\t\t\tAPIBase:        p.VLLM.APIBase,\n\t\t\t\t\tProxy:          p.VLLM.Proxy,\n\t\t\t\t\tRequestTimeout: p.VLLM.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"gemini\", \"google\"},\n\t\t\tprotocol:      \"gemini\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Gemini.APIKey == \"\" && p.Gemini.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"gemini\",\n\t\t\t\t\tModel:          \"gemini/gemini-pro\",\n\t\t\t\t\tAPIKey:         p.Gemini.APIKey,\n\t\t\t\t\tAPIBase:        p.Gemini.APIBase,\n\t\t\t\t\tProxy:          p.Gemini.Proxy,\n\t\t\t\t\tRequestTimeout: p.Gemini.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"nvidia\"},\n\t\t\tprotocol:      \"nvidia\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Nvidia.APIKey == \"\" && p.Nvidia.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"nvidia\",\n\t\t\t\t\tModel:          \"nvidia/meta/llama-3.1-8b-instruct\",\n\t\t\t\t\tAPIKey:         p.Nvidia.APIKey,\n\t\t\t\t\tAPIBase:        p.Nvidia.APIBase,\n\t\t\t\t\tProxy:          p.Nvidia.Proxy,\n\t\t\t\t\tRequestTimeout: p.Nvidia.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"ollama\"},\n\t\t\tprotocol:      \"ollama\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Ollama.APIKey == \"\" && p.Ollama.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"ollama\",\n\t\t\t\t\tModel:          \"ollama/llama3\",\n\t\t\t\t\tAPIKey:         p.Ollama.APIKey,\n\t\t\t\t\tAPIBase:        p.Ollama.APIBase,\n\t\t\t\t\tProxy:          p.Ollama.Proxy,\n\t\t\t\t\tRequestTimeout: p.Ollama.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"moonshot\", \"kimi\"},\n\t\t\tprotocol:      \"moonshot\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Moonshot.APIKey == \"\" && p.Moonshot.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"moonshot\",\n\t\t\t\t\tModel:          \"moonshot/kimi\",\n\t\t\t\t\tAPIKey:         p.Moonshot.APIKey,\n\t\t\t\t\tAPIBase:        p.Moonshot.APIBase,\n\t\t\t\t\tProxy:          p.Moonshot.Proxy,\n\t\t\t\t\tRequestTimeout: p.Moonshot.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"shengsuanyun\"},\n\t\t\tprotocol:      \"shengsuanyun\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.ShengSuanYun.APIKey == \"\" && p.ShengSuanYun.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"shengsuanyun\",\n\t\t\t\t\tModel:          \"shengsuanyun/auto\",\n\t\t\t\t\tAPIKey:         p.ShengSuanYun.APIKey,\n\t\t\t\t\tAPIBase:        p.ShengSuanYun.APIBase,\n\t\t\t\t\tProxy:          p.ShengSuanYun.Proxy,\n\t\t\t\t\tRequestTimeout: p.ShengSuanYun.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"deepseek\"},\n\t\t\tprotocol:      \"deepseek\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.DeepSeek.APIKey == \"\" && p.DeepSeek.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"deepseek\",\n\t\t\t\t\tModel:          \"deepseek/deepseek-chat\",\n\t\t\t\t\tAPIKey:         p.DeepSeek.APIKey,\n\t\t\t\t\tAPIBase:        p.DeepSeek.APIBase,\n\t\t\t\t\tProxy:          p.DeepSeek.Proxy,\n\t\t\t\t\tRequestTimeout: p.DeepSeek.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"cerebras\"},\n\t\t\tprotocol:      \"cerebras\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Cerebras.APIKey == \"\" && p.Cerebras.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"cerebras\",\n\t\t\t\t\tModel:          \"cerebras/llama-3.3-70b\",\n\t\t\t\t\tAPIKey:         p.Cerebras.APIKey,\n\t\t\t\t\tAPIBase:        p.Cerebras.APIBase,\n\t\t\t\t\tProxy:          p.Cerebras.Proxy,\n\t\t\t\t\tRequestTimeout: p.Cerebras.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"vivgrid\"},\n\t\t\tprotocol:      \"vivgrid\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Vivgrid.APIKey == \"\" && p.Vivgrid.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"vivgrid\",\n\t\t\t\t\tModel:          \"vivgrid/auto\",\n\t\t\t\t\tAPIKey:         p.Vivgrid.APIKey,\n\t\t\t\t\tAPIBase:        p.Vivgrid.APIBase,\n\t\t\t\t\tProxy:          p.Vivgrid.Proxy,\n\t\t\t\t\tRequestTimeout: p.Vivgrid.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"volcengine\", \"doubao\"},\n\t\t\tprotocol:      \"volcengine\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.VolcEngine.APIKey == \"\" && p.VolcEngine.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"volcengine\",\n\t\t\t\t\tModel:          \"volcengine/doubao-pro\",\n\t\t\t\t\tAPIKey:         p.VolcEngine.APIKey,\n\t\t\t\t\tAPIBase:        p.VolcEngine.APIBase,\n\t\t\t\t\tProxy:          p.VolcEngine.Proxy,\n\t\t\t\t\tRequestTimeout: p.VolcEngine.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"github_copilot\", \"copilot\"},\n\t\t\tprotocol:      \"github-copilot\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.GitHubCopilot.APIKey == \"\" && p.GitHubCopilot.APIBase == \"\" && p.GitHubCopilot.ConnectMode == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:   \"github-copilot\",\n\t\t\t\t\tModel:       \"github-copilot/gpt-5.4\",\n\t\t\t\t\tAPIBase:     p.GitHubCopilot.APIBase,\n\t\t\t\t\tConnectMode: p.GitHubCopilot.ConnectMode,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"antigravity\"},\n\t\t\tprotocol:      \"antigravity\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Antigravity.APIKey == \"\" && p.Antigravity.AuthMethod == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:  \"antigravity\",\n\t\t\t\t\tModel:      \"antigravity/gemini-2.0-flash\",\n\t\t\t\t\tAPIKey:     p.Antigravity.APIKey,\n\t\t\t\t\tAuthMethod: p.Antigravity.AuthMethod,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"qwen\", \"tongyi\"},\n\t\t\tprotocol:      \"qwen\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Qwen.APIKey == \"\" && p.Qwen.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"qwen\",\n\t\t\t\t\tModel:          \"qwen/qwen-max\",\n\t\t\t\t\tAPIKey:         p.Qwen.APIKey,\n\t\t\t\t\tAPIBase:        p.Qwen.APIBase,\n\t\t\t\t\tProxy:          p.Qwen.Proxy,\n\t\t\t\t\tRequestTimeout: p.Qwen.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"mistral\"},\n\t\t\tprotocol:      \"mistral\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Mistral.APIKey == \"\" && p.Mistral.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"mistral\",\n\t\t\t\t\tModel:          \"mistral/mistral-small-latest\",\n\t\t\t\t\tAPIKey:         p.Mistral.APIKey,\n\t\t\t\t\tAPIBase:        p.Mistral.APIBase,\n\t\t\t\t\tProxy:          p.Mistral.Proxy,\n\t\t\t\t\tRequestTimeout: p.Mistral.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"avian\"},\n\t\t\tprotocol:      \"avian\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.Avian.APIKey == \"\" && p.Avian.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"avian\",\n\t\t\t\t\tModel:          \"avian/deepseek/deepseek-v3.2\",\n\t\t\t\t\tAPIKey:         p.Avian.APIKey,\n\t\t\t\t\tAPIBase:        p.Avian.APIBase,\n\t\t\t\t\tProxy:          p.Avian.Proxy,\n\t\t\t\t\tRequestTimeout: p.Avian.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"longcat\"},\n\t\t\tprotocol:      \"longcat\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.LongCat.APIKey == \"\" && p.LongCat.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"longcat\",\n\t\t\t\t\tModel:          \"longcat/LongCat-Flash-Thinking\",\n\t\t\t\t\tAPIKey:         p.LongCat.APIKey,\n\t\t\t\t\tAPIBase:        p.LongCat.APIBase,\n\t\t\t\t\tProxy:          p.LongCat.Proxy,\n\t\t\t\t\tRequestTimeout: p.LongCat.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tproviderNames: []string{\"modelscope\"},\n\t\t\tprotocol:      \"modelscope\",\n\t\t\tbuildConfig: func(p ProvidersConfig) (ModelConfig, bool) {\n\t\t\t\tif p.ModelScope.APIKey == \"\" && p.ModelScope.APIBase == \"\" {\n\t\t\t\t\treturn ModelConfig{}, false\n\t\t\t\t}\n\t\t\t\treturn ModelConfig{\n\t\t\t\t\tModelName:      \"modelscope\",\n\t\t\t\t\tModel:          \"modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\t\t\t\tAPIKey:         p.ModelScope.APIKey,\n\t\t\t\t\tAPIBase:        p.ModelScope.APIBase,\n\t\t\t\t\tProxy:          p.ModelScope.Proxy,\n\t\t\t\t\tRequestTimeout: p.ModelScope.RequestTimeout,\n\t\t\t\t}, true\n\t\t\t},\n\t\t},\n\t}\n\n\t// Process each provider migration\n\tfor _, m := range migrations {\n\t\tmc, ok := m.buildConfig(p)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check if this is the user's configured provider\n\t\tif slices.Contains(m.providerNames, userProvider) && userModel != \"\" {\n\t\t\t// Use the user's configured model instead of default\n\t\t\tmc.Model = buildModelWithProtocol(m.protocol, userModel)\n\t\t} else if userProvider == \"\" && userModel != \"\" && !legacyModelNameApplied {\n\t\t\t// Legacy config: no explicit provider field but model is specified\n\t\t\t// Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it\n\t\t\t// This maintains backward compatibility with old configs that relied on implicit provider selection\n\t\t\tmc.ModelName = userModel\n\t\t\tmc.Model = buildModelWithProtocol(m.protocol, userModel)\n\t\t\tlegacyModelNameApplied = true\n\t\t}\n\n\t\tresult = append(result, mc)\n\t}\n\n\treturn result\n}\n\n// protocolProviderMapping maps a model protocol prefix (the part before \"/\" in\n// the Model field) to a function that extracts the corresponding ProviderConfig\n// from the legacy ProvidersConfig.  Used by InheritProviderCredentials.\nvar protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{\n\t\"openai\":         func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig },\n\t\"anthropic\":      func(p ProvidersConfig) ProviderConfig { return p.Anthropic },\n\t\"litellm\":        func(p ProvidersConfig) ProviderConfig { return p.LiteLLM },\n\t\"openrouter\":     func(p ProvidersConfig) ProviderConfig { return p.OpenRouter },\n\t\"groq\":           func(p ProvidersConfig) ProviderConfig { return p.Groq },\n\t\"zhipu\":          func(p ProvidersConfig) ProviderConfig { return p.Zhipu },\n\t\"vllm\":           func(p ProvidersConfig) ProviderConfig { return p.VLLM },\n\t\"gemini\":         func(p ProvidersConfig) ProviderConfig { return p.Gemini },\n\t\"nvidia\":         func(p ProvidersConfig) ProviderConfig { return p.Nvidia },\n\t\"ollama\":         func(p ProvidersConfig) ProviderConfig { return p.Ollama },\n\t\"moonshot\":       func(p ProvidersConfig) ProviderConfig { return p.Moonshot },\n\t\"shengsuanyun\":   func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun },\n\t\"deepseek\":       func(p ProvidersConfig) ProviderConfig { return p.DeepSeek },\n\t\"cerebras\":       func(p ProvidersConfig) ProviderConfig { return p.Cerebras },\n\t\"vivgrid\":        func(p ProvidersConfig) ProviderConfig { return p.Vivgrid },\n\t\"volcengine\":     func(p ProvidersConfig) ProviderConfig { return p.VolcEngine },\n\t\"github-copilot\": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot },\n\t\"antigravity\":    func(p ProvidersConfig) ProviderConfig { return p.Antigravity },\n\t\"qwen\":           func(p ProvidersConfig) ProviderConfig { return p.Qwen },\n\t\"mistral\":        func(p ProvidersConfig) ProviderConfig { return p.Mistral },\n\t\"avian\":          func(p ProvidersConfig) ProviderConfig { return p.Avian },\n\t\"minimax\":        func(p ProvidersConfig) ProviderConfig { return p.Minimax },\n\t\"longcat\":        func(p ProvidersConfig) ProviderConfig { return p.LongCat },\n\t\"modelscope\":     func(p ProvidersConfig) ProviderConfig { return p.ModelScope },\n\t\"novita\":         func(p ProvidersConfig) ProviderConfig { return p.Novita },\n}\n\n// InheritProviderCredentials fills in missing api_key, api_base, proxy, and\n// request_timeout on model_list entries from the matching legacy providers\n// configuration.  The match is determined by the protocol prefix in the Model\n// field (e.g. \"deepseek/deepseek-chat\" matches providers.deepseek).\n//\n// Only empty fields are filled — any value explicitly set on a model_list entry\n// takes precedence.  This function modifies the slice in place.\n//\n// This bridges the gap described in issue #1635: users who configure\n// credentials once in the providers section expect model_list entries using\n// the same protocol to \"just work\" without duplicating credentials.\nfunc InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) {\n\tif providers.IsEmpty() {\n\t\treturn\n\t}\n\n\tfor i := range models {\n\t\tm := &models[i]\n\n\t\t// Extract protocol prefix from Model field\n\t\tprotocol := \"\"\n\t\tif idx := strings.Index(m.Model, \"/\"); idx > 0 {\n\t\t\tprotocol = strings.ToLower(m.Model[:idx])\n\t\t}\n\t\tif protocol == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tgetProvider, ok := protocolProviderMapping[protocol]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tpc := getProvider(providers)\n\n\t\t// Only fill empty fields — explicit model_list values win\n\t\tif m.APIKey == \"\" && pc.APIKey != \"\" {\n\t\t\tm.APIKey = pc.APIKey\n\t\t}\n\t\tif m.APIBase == \"\" && pc.APIBase != \"\" {\n\t\t\tm.APIBase = pc.APIBase\n\t\t}\n\t\tif m.Proxy == \"\" && pc.Proxy != \"\" {\n\t\t\tm.Proxy = pc.Proxy\n\t\t}\n\t\tif m.RequestTimeout == 0 && pc.RequestTimeout != 0 {\n\t\t\tm.RequestTimeout = pc.RequestTimeout\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/config/migration_test.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestConvertProvidersToModelList_OpenAI(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{\n\t\t\t\tProviderConfig: ProviderConfig{\n\t\t\t\t\tAPIKey:  \"sk-test-key\",\n\t\t\t\t\tAPIBase: \"https://custom.api.com/v1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].ModelName != \"openai\" {\n\t\tt.Errorf(\"ModelName = %q, want %q\", result[0].ModelName, \"openai\")\n\t}\n\tif result[0].Model != \"openai/gpt-5.4\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"openai/gpt-5.4\")\n\t}\n\tif result[0].APIKey != \"sk-test-key\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", result[0].APIKey, \"sk-test-key\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_Anthropic(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tAnthropic: ProviderConfig{\n\t\t\t\tAPIKey:  \"ant-key\",\n\t\t\t\tAPIBase: \"https://custom.anthropic.com\",\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].ModelName != \"anthropic\" {\n\t\tt.Errorf(\"ModelName = %q, want %q\", result[0].ModelName, \"anthropic\")\n\t}\n\tif result[0].Model != \"anthropic/claude-sonnet-4.6\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"anthropic/claude-sonnet-4.6\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_LiteLLM(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tLiteLLM: ProviderConfig{\n\t\t\t\tAPIKey:  \"litellm-key\",\n\t\t\t\tAPIBase: \"http://localhost:4000/v1\",\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].ModelName != \"litellm\" {\n\t\tt.Errorf(\"ModelName = %q, want %q\", result[0].ModelName, \"litellm\")\n\t}\n\tif result[0].Model != \"litellm/auto\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"litellm/auto\")\n\t}\n\tif result[0].APIBase != \"http://localhost:4000/v1\" {\n\t\tt.Errorf(\"APIBase = %q, want %q\", result[0].APIBase, \"http://localhost:4000/v1\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_Multiple(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: \"openai-key\"}},\n\t\t\tGroq:   ProviderConfig{APIKey: \"groq-key\"},\n\t\t\tZhipu:  ProviderConfig{APIKey: \"zhipu-key\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 3 {\n\t\tt.Fatalf(\"len(result) = %d, want 3\", len(result))\n\t}\n\n\t// Check that all providers are present\n\tfound := make(map[string]bool)\n\tfor _, mc := range result {\n\t\tfound[mc.ModelName] = true\n\t}\n\n\tfor _, name := range []string{\"openai\", \"groq\", \"zhipu\"} {\n\t\tif !found[name] {\n\t\t\tt.Errorf(\"Missing provider %q in result\", name)\n\t\t}\n\t}\n}\n\nfunc TestConvertProvidersToModelList_Empty(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 0 {\n\t\tt.Errorf(\"len(result) = %d, want 0\", len(result))\n\t}\n}\n\nfunc TestConvertProvidersToModelList_Nil(t *testing.T) {\n\tresult := ConvertProvidersToModelList(nil)\n\n\tif result != nil {\n\t\tt.Errorf(\"result = %v, want nil\", result)\n\t}\n}\n\nfunc TestConvertProvidersToModelList_AllProviders(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI:        OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: \"key1\"}},\n\t\t\tLiteLLM:       ProviderConfig{APIKey: \"key-litellm\", APIBase: \"http://localhost:4000/v1\"},\n\t\t\tAnthropic:     ProviderConfig{APIKey: \"key2\"},\n\t\t\tOpenRouter:    ProviderConfig{APIKey: \"key3\"},\n\t\t\tGroq:          ProviderConfig{APIKey: \"key4\"},\n\t\t\tZhipu:         ProviderConfig{APIKey: \"key5\"},\n\t\t\tVLLM:          ProviderConfig{APIKey: \"key6\"},\n\t\t\tGemini:        ProviderConfig{APIKey: \"key7\"},\n\t\t\tNvidia:        ProviderConfig{APIKey: \"key8\"},\n\t\t\tOllama:        ProviderConfig{APIKey: \"key9\"},\n\t\t\tMoonshot:      ProviderConfig{APIKey: \"key10\"},\n\t\t\tShengSuanYun:  ProviderConfig{APIKey: \"key11\"},\n\t\t\tDeepSeek:      ProviderConfig{APIKey: \"key12\"},\n\t\t\tCerebras:      ProviderConfig{APIKey: \"key13\"},\n\t\t\tVivgrid:       ProviderConfig{APIKey: \"key14\"},\n\t\t\tVolcEngine:    ProviderConfig{APIKey: \"key15\"},\n\t\t\tGitHubCopilot: ProviderConfig{ConnectMode: \"grpc\"},\n\t\t\tAntigravity:   ProviderConfig{AuthMethod: \"oauth\"},\n\t\t\tQwen:          ProviderConfig{APIKey: \"key17\"},\n\t\t\tMistral:       ProviderConfig{APIKey: \"key18\"},\n\t\t\tAvian:         ProviderConfig{APIKey: \"key19\"},\n\t\t\tLongCat:       ProviderConfig{APIKey: \"key-longcat\"},\n\t\t\tModelScope:    ProviderConfig{APIKey: \"key-modelscope\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\t// All 23 providers should be converted\n\tif len(result) != 23 {\n\t\tt.Errorf(\"len(result) = %d, want 23\", len(result))\n\t}\n}\n\nfunc TestConvertProvidersToModelList_Proxy(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{\n\t\t\t\tProviderConfig: ProviderConfig{\n\t\t\t\t\tAPIKey: \"key\",\n\t\t\t\t\tProxy:  \"http://proxy:8080\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].Proxy != \"http://proxy:8080\" {\n\t\tt.Errorf(\"Proxy = %q, want %q\", result[0].Proxy, \"http://proxy:8080\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOllama: ProviderConfig{\n\t\t\t\tAPIKey:         \"ollama-key\",\n\t\t\t\tRequestTimeout: 300,\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].RequestTimeout != 300 {\n\t\tt.Errorf(\"RequestTimeout = %d, want %d\", result[0].RequestTimeout, 300)\n\t}\n}\n\nfunc TestConvertProvidersToModelList_AuthMethod(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{\n\t\t\t\tProviderConfig: ProviderConfig{\n\t\t\t\t\tAuthMethod: \"oauth\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 0 {\n\t\tt.Errorf(\"len(result) = %d, want 0 (AuthMethod alone should not create entry)\", len(result))\n\t}\n}\n\n// Tests for preserving user's configured model during migration\n\nfunc TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"deepseek\",\n\t\t\t\tModel:    \"deepseek-reasoner\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tDeepSeek: ProviderConfig{APIKey: \"sk-deepseek\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\t// Should use user's model, not default\n\tif result[0].Model != \"deepseek/deepseek-reasoner\" {\n\t\tt.Errorf(\"Model = %q, want %q (user's configured model)\", result[0].Model, \"deepseek/deepseek-reasoner\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tModel:    \"gpt-4-turbo\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: \"sk-openai\"}},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].Model != \"openai/gpt-4-turbo\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"openai/gpt-4-turbo\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"claude\", // alternative name\n\t\t\t\tModel:    \"claude-opus-4-20250514\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tAnthropic: ProviderConfig{APIKey: \"sk-ant\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].Model != \"anthropic/claude-opus-4-20250514\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"anthropic/claude-opus-4-20250514\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"qwen\",\n\t\t\t\tModel:    \"qwen-plus\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tQwen: ProviderConfig{APIKey: \"sk-qwen\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\tif result[0].Model != \"qwen/qwen-plus\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"qwen/qwen-plus\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"deepseek\",\n\t\t\t\tModel:    \"\", // no model specified\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tDeepSeek: ProviderConfig{APIKey: \"sk-deepseek\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\t// Should use default model\n\tif result[0].Model != \"deepseek/deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q (default)\", result[0].Model, \"deepseek/deepseek-chat\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"deepseek\",\n\t\t\t\tModel:    \"deepseek-reasoner\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI:   OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: \"sk-openai\"}},\n\t\t\tDeepSeek: ProviderConfig{APIKey: \"sk-deepseek\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n\n\t// Find each provider and verify model\n\tfor _, mc := range result {\n\t\tswitch mc.ModelName {\n\t\tcase \"openai\":\n\t\t\tif mc.Model != \"openai/gpt-5.4\" {\n\t\t\t\tt.Errorf(\"OpenAI Model = %q, want %q (default)\", mc.Model, \"openai/gpt-5.4\")\n\t\t\t}\n\t\tcase \"deepseek\":\n\t\t\tif mc.Model != \"deepseek/deepseek-reasoner\" {\n\t\t\t\tt.Errorf(\"DeepSeek Model = %q, want %q (user's)\", mc.Model, \"deepseek/deepseek-reasoner\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {\n\ttests := []struct {\n\t\tproviderAlias string\n\t\texpectedModel string\n\t\tprovider      ProviderConfig\n\t}{\n\t\t{\"gpt\", \"openai/gpt-4-custom\", ProviderConfig{APIKey: \"key\"}},\n\t\t{\"claude\", \"anthropic/claude-custom\", ProviderConfig{APIKey: \"key\"}},\n\t\t{\"doubao\", \"volcengine/doubao-custom\", ProviderConfig{APIKey: \"key\"}},\n\t\t{\"tongyi\", \"qwen/qwen-custom\", ProviderConfig{APIKey: \"key\"}},\n\t\t{\"kimi\", \"moonshot/kimi-custom\", ProviderConfig{APIKey: \"key\"}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerAlias, func(t *testing.T) {\n\t\t\tcfg := &Config{\n\t\t\t\tAgents: AgentsConfig{\n\t\t\t\t\tDefaults: AgentDefaults{\n\t\t\t\t\t\tProvider: tt.providerAlias,\n\t\t\t\t\t\tModel: strings.TrimPrefix(\n\t\t\t\t\t\t\ttt.expectedModel,\n\t\t\t\t\t\t\ttt.expectedModel[:strings.Index(tt.expectedModel, \"/\")+1],\n\t\t\t\t\t\t),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tProviders: ProvidersConfig{},\n\t\t\t}\n\n\t\t\t// Set the appropriate provider config\n\t\t\tswitch tt.providerAlias {\n\t\t\tcase \"gpt\":\n\t\t\t\tcfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}\n\t\t\tcase \"claude\":\n\t\t\t\tcfg.Providers.Anthropic = tt.provider\n\t\t\tcase \"doubao\":\n\t\t\t\tcfg.Providers.VolcEngine = tt.provider\n\t\t\tcase \"tongyi\":\n\t\t\t\tcfg.Providers.Qwen = tt.provider\n\t\t\tcase \"kimi\":\n\t\t\t\tcfg.Providers.Moonshot = tt.provider\n\t\t\t}\n\n\t\t\t// Need to fix the model name in config\n\t\t\tcfg.Agents.Defaults.Model = strings.TrimPrefix(\n\t\t\t\ttt.expectedModel,\n\t\t\t\ttt.expectedModel[:strings.Index(tt.expectedModel, \"/\")+1],\n\t\t\t)\n\n\t\t\tresult := ConvertProvidersToModelList(cfg)\n\t\t\tif len(result) != 1 {\n\t\t\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t\t\t}\n\n\t\t\t// Extract just the model ID part (after the first /)\n\t\t\texpectedModelID := tt.expectedModel\n\t\t\tif result[0].Model != expectedModelID {\n\t\t\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, expectedModelID)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test for backward compatibility: single provider without explicit provider field\n// This matches the legacy config pattern where users only set model, not provider\n\nfunc TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {\n\t// This matches the user's actual config:\n\t// - No provider field set\n\t// - model = \"glm-4.7\"\n\t// - Only zhipu has API key configured\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"\", // Not set\n\t\t\t\tModel:    \"glm-4.7\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tZhipu: ProviderConfig{APIKey: \"test-zhipu-key\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\t// ModelName should be the user's model value for backward compatibility\n\tif result[0].ModelName != \"glm-4.7\" {\n\t\tt.Errorf(\"ModelName = %q, want %q (user's model for backward compatibility)\", result[0].ModelName, \"glm-4.7\")\n\t}\n\n\t// Model should use the user's model with protocol prefix\n\tif result[0].Model != \"zhipu/glm-4.7\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result[0].Model, \"zhipu/glm-4.7\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {\n\t// When multiple providers are configured but no provider field is set,\n\t// the FIRST provider (in migration order) will use userModel as ModelName\n\t// for backward compatibility with legacy implicit provider selection\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"\", // Not set\n\t\t\t\tModel:    \"some-model\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: \"openai-key\"}},\n\t\t\tZhipu:  ProviderConfig{APIKey: \"zhipu-key\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n\n\t// The first provider (OpenAI in migration order) should use userModel as ModelName\n\t// This ensures GetModelConfig(\"some-model\") will find it\n\tif result[0].ModelName != \"some-model\" {\n\t\tt.Errorf(\"First provider ModelName = %q, want %q\", result[0].ModelName, \"some-model\")\n\t}\n\n\t// Other providers should use provider name as ModelName\n\tif result[1].ModelName != \"zhipu\" {\n\t\tt.Errorf(\"Second provider ModelName = %q, want %q\", result[1].ModelName, \"zhipu\")\n\t}\n}\n\nfunc TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {\n\t// Edge case: no provider, no model\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"\",\n\t\t\t\tModel:    \"\",\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tZhipu: ProviderConfig{APIKey: \"zhipu-key\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"len(result) = %d, want 1\", len(result))\n\t}\n\n\t// Should use default provider name since no model is specified\n\tif result[0].ModelName != \"zhipu\" {\n\t\tt.Errorf(\"ModelName = %q, want %q\", result[0].ModelName, \"zhipu\")\n\t}\n}\n\n// Tests for buildModelWithProtocol helper function\n\nfunc TestBuildModelWithProtocol_NoPrefix(t *testing.T) {\n\tresult := buildModelWithProtocol(\"openai\", \"gpt-5.4\")\n\tif result != \"openai/gpt-5.4\" {\n\t\tt.Errorf(\"buildModelWithProtocol(openai, gpt-5.4) = %q, want %q\", result, \"openai/gpt-5.4\")\n\t}\n}\n\nfunc TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {\n\tresult := buildModelWithProtocol(\"openrouter\", \"openrouter/auto\")\n\tif result != \"openrouter/auto\" {\n\t\tt.Errorf(\"buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q\", result, \"openrouter/auto\")\n\t}\n}\n\nfunc TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {\n\tresult := buildModelWithProtocol(\"anthropic\", \"openrouter/claude-sonnet-4.6\")\n\tif result != \"openrouter/claude-sonnet-4.6\" {\n\t\tt.Errorf(\n\t\t\t\"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q\",\n\t\t\tresult,\n\t\t\t\"openrouter/claude-sonnet-4.6\",\n\t\t)\n\t}\n}\n\n// Test for legacy config with protocol prefix in model name\nfunc TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {\n\tcfg := &Config{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider: \"\",                // No explicit provider\n\t\t\t\tModel:    \"openrouter/auto\", // Model already has protocol prefix\n\t\t\t},\n\t\t},\n\t\tProviders: ProvidersConfig{\n\t\t\tOpenRouter: ProviderConfig{APIKey: \"sk-or-test\"},\n\t\t},\n\t}\n\n\tresult := ConvertProvidersToModelList(cfg)\n\n\tif len(result) < 1 {\n\t\tt.Fatalf(\"len(result) = %d, want at least 1\", len(result))\n\t}\n\n\t// First provider should use userModel as ModelName for backward compatibility\n\tif result[0].ModelName != \"openrouter/auto\" {\n\t\tt.Errorf(\"ModelName = %q, want %q\", result[0].ModelName, \"openrouter/auto\")\n\t}\n\n\t// Model should NOT have duplicated prefix\n\tif result[0].Model != \"openrouter/auto\" {\n\t\tt.Errorf(\"Model = %q, want %q (should not duplicate prefix)\", result[0].Model, \"openrouter/auto\")\n\t}\n}\n\n// ---------- InheritProviderCredentials tests ----------\n\nfunc TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{ModelName: \"my-deepseek\", Model: \"deepseek/deepseek-chat\"},\n\t}\n\tproviders := ProvidersConfig{\n\t\tDeepSeek: ProviderConfig{\n\t\t\tAPIKey:  \"sk-deepseek-from-providers\",\n\t\t\tAPIBase: \"https://api.deepseek.com/v1\",\n\t\t},\n\t}\n\n\tInheritProviderCredentials(models, providers)\n\n\tif models[0].APIKey != \"sk-deepseek-from-providers\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", models[0].APIKey, \"sk-deepseek-from-providers\")\n\t}\n\tif models[0].APIBase != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"APIBase = %q, want %q\", models[0].APIBase, \"https://api.deepseek.com/v1\")\n\t}\n}\n\nfunc TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"my-openai\",\n\t\t\tModel:     \"openai/gpt-5.4\",\n\t\t\tAPIKey:    \"sk-explicit-model-key\",\n\t\t\tAPIBase:   \"https://my-custom-endpoint.com/v1\",\n\t\t},\n\t}\n\tproviders := ProvidersConfig{\n\t\tOpenAI: OpenAIProviderConfig{\n\t\t\tProviderConfig: ProviderConfig{\n\t\t\t\tAPIKey:  \"sk-provider-key\",\n\t\t\t\tAPIBase: \"https://api.openai.com/v1\",\n\t\t\t},\n\t\t},\n\t}\n\n\tInheritProviderCredentials(models, providers)\n\n\tif models[0].APIKey != \"sk-explicit-model-key\" {\n\t\tt.Errorf(\"APIKey = %q, want %q (explicit should win)\", models[0].APIKey, \"sk-explicit-model-key\")\n\t}\n\tif models[0].APIBase != \"https://my-custom-endpoint.com/v1\" {\n\t\tt.Errorf(\"APIBase = %q, want %q (explicit should win)\", models[0].APIBase, \"https://my-custom-endpoint.com/v1\")\n\t}\n}\n\nfunc TestInheritProviderCredentials_MultipleModels(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{ModelName: \"groq-llama\", Model: \"groq/llama-3.1-70b\"},\n\t\t{ModelName: \"zhipu-glm\", Model: \"zhipu/glm-4\"},\n\t\t{ModelName: \"custom-openai\", Model: \"openai/gpt-5.4\", APIKey: \"sk-already-set\"},\n\t}\n\tproviders := ProvidersConfig{\n\t\tGroq:  ProviderConfig{APIKey: \"gsk-groq-key\", Proxy: \"http://proxy:8080\"},\n\t\tZhipu: ProviderConfig{APIKey: \"zhipu-key-123\", APIBase: \"https://zhipu.example.com\"},\n\t\tOpenAI: OpenAIProviderConfig{\n\t\t\tProviderConfig: ProviderConfig{APIKey: \"sk-should-not-override\"},\n\t\t},\n\t}\n\n\tInheritProviderCredentials(models, providers)\n\n\t// groq model should inherit\n\tif models[0].APIKey != \"gsk-groq-key\" {\n\t\tt.Errorf(\"groq APIKey = %q, want %q\", models[0].APIKey, \"gsk-groq-key\")\n\t}\n\tif models[0].Proxy != \"http://proxy:8080\" {\n\t\tt.Errorf(\"groq Proxy = %q, want %q\", models[0].Proxy, \"http://proxy:8080\")\n\t}\n\n\t// zhipu model should inherit\n\tif models[1].APIKey != \"zhipu-key-123\" {\n\t\tt.Errorf(\"zhipu APIKey = %q, want %q\", models[1].APIKey, \"zhipu-key-123\")\n\t}\n\tif models[1].APIBase != \"https://zhipu.example.com\" {\n\t\tt.Errorf(\"zhipu APIBase = %q, want %q\", models[1].APIBase, \"https://zhipu.example.com\")\n\t}\n\n\t// openai model already has key — should NOT be overridden\n\tif models[2].APIKey != \"sk-already-set\" {\n\t\tt.Errorf(\"openai APIKey = %q, want %q (should not be overridden)\", models[2].APIKey, \"sk-already-set\")\n\t}\n}\n\nfunc TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{ModelName: \"my-model\", Model: \"novelai/some-model\"},\n\t}\n\tproviders := ProvidersConfig{\n\t\tDeepSeek: ProviderConfig{APIKey: \"sk-deepseek\"},\n\t}\n\n\tInheritProviderCredentials(models, providers)\n\n\t// No matching provider for \"novelai\" protocol — should stay empty\n\tif models[0].APIKey != \"\" {\n\t\tt.Errorf(\"APIKey = %q, want empty (no matching provider)\", models[0].APIKey)\n\t}\n}\n\nfunc TestInheritProviderCredentials_EmptyProviders(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{ModelName: \"my-model\", Model: \"openai/gpt-5.4\"},\n\t}\n\tproviders := ProvidersConfig{} // all empty\n\n\tInheritProviderCredentials(models, providers)\n\n\t// Empty providers — nothing to inherit\n\tif models[0].APIKey != \"\" {\n\t\tt.Errorf(\"APIKey = %q, want empty\", models[0].APIKey)\n\t}\n}\n\nfunc TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{ModelName: \"my-ollama\", Model: \"ollama/llama3.2:3b\"},\n\t}\n\tproviders := ProvidersConfig{\n\t\tOllama: ProviderConfig{\n\t\t\tAPIBase:        \"http://localhost:11434\",\n\t\t\tRequestTimeout: 120,\n\t\t},\n\t}\n\n\tInheritProviderCredentials(models, providers)\n\n\tif models[0].APIBase != \"http://localhost:11434\" {\n\t\tt.Errorf(\"APIBase = %q, want %q\", models[0].APIBase, \"http://localhost:11434\")\n\t}\n\tif models[0].RequestTimeout != 120 {\n\t\tt.Errorf(\"RequestTimeout = %d, want 120\", models[0].RequestTimeout)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/model_config_test.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage config\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc TestGetModelConfig_Found(t *testing.T) {\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"test-model\", Model: \"openai/gpt-4o\", APIKey: \"key1\"},\n\t\t\t{ModelName: \"other-model\", Model: \"anthropic/claude\", APIKey: \"key2\"},\n\t\t},\n\t}\n\n\tresult, err := cfg.GetModelConfig(\"test-model\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetModelConfig() error = %v\", err)\n\t}\n\tif result.Model != \"openai/gpt-4o\" {\n\t\tt.Errorf(\"Model = %q, want %q\", result.Model, \"openai/gpt-4o\")\n\t}\n}\n\nfunc TestGetModelConfig_NotFound(t *testing.T) {\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"test-model\", Model: \"openai/gpt-4o\", APIKey: \"key1\"},\n\t\t},\n\t}\n\n\t_, err := cfg.GetModelConfig(\"nonexistent\")\n\tif err == nil {\n\t\tt.Fatal(\"GetModelConfig() expected error for nonexistent model\")\n\t}\n}\n\nfunc TestGetModelConfig_EmptyList(t *testing.T) {\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{},\n\t}\n\n\t_, err := cfg.GetModelConfig(\"any-model\")\n\tif err == nil {\n\t\tt.Fatal(\"GetModelConfig() expected error for empty model list\")\n\t}\n}\n\nfunc TestGetModelConfig_RoundRobin(t *testing.T) {\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-1\", APIKey: \"key1\"},\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-2\", APIKey: \"key2\"},\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-3\", APIKey: \"key3\"},\n\t\t},\n\t}\n\n\t// Test round-robin distribution\n\tresults := make(map[string]int)\n\tfor range 30 {\n\t\tresult, err := cfg.GetModelConfig(\"lb-model\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetModelConfig() error = %v\", err)\n\t\t}\n\t\tresults[result.Model]++\n\t}\n\n\t// Each model should appear roughly 10 times (30 calls / 3 models)\n\tfor model, count := range results {\n\t\tif count < 5 || count > 15 {\n\t\t\tt.Errorf(\"Model %s appeared %d times, expected ~10\", model, count)\n\t\t}\n\t}\n}\n\nfunc TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {\n\trrCounter.Store(0)\n\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-1\", APIKey: \"key1\"},\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-2\", APIKey: \"key2\"},\n\t\t\t{ModelName: \"lb-model\", Model: \"openai/gpt-4o-3\", APIKey: \"key3\"},\n\t\t},\n\t}\n\n\twantOrder := []string{\n\t\t\"openai/gpt-4o-1\",\n\t\t\"openai/gpt-4o-2\",\n\t\t\"openai/gpt-4o-3\",\n\t\t\"openai/gpt-4o-1\",\n\t\t\"openai/gpt-4o-2\",\n\t}\n\n\tfor i, want := range wantOrder {\n\t\tresult, err := cfg.GetModelConfig(\"lb-model\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"GetModelConfig() call %d error = %v\", i, err)\n\t\t}\n\t\tif result.Model != want {\n\t\t\tt.Fatalf(\"GetModelConfig() call %d model = %q, want %q\", i, result.Model, want)\n\t\t}\n\t}\n}\n\nfunc TestGetModelConfig_Concurrent(t *testing.T) {\n\tcfg := &Config{\n\t\tModelList: []ModelConfig{\n\t\t\t{ModelName: \"concurrent-model\", Model: \"openai/gpt-4o-1\", APIKey: \"key1\"},\n\t\t\t{ModelName: \"concurrent-model\", Model: \"openai/gpt-4o-2\", APIKey: \"key2\"},\n\t\t},\n\t}\n\n\tconst goroutines = 100\n\tconst iterations = 10\n\n\tvar wg sync.WaitGroup\n\terrors := make(chan error, goroutines*iterations)\n\n\tfor range goroutines {\n\t\twg.Go(func() {\n\t\t\tfor range iterations {\n\t\t\t\t_, err := cfg.GetModelConfig(\"concurrent-model\")\n\t\t\t\tif err != nil {\n\t\t\t\t\terrors <- err\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\twg.Wait()\n\tclose(errors)\n\n\tfor err := range errors {\n\t\tt.Errorf(\"Concurrent GetModelConfig() error: %v\", err)\n\t}\n}\n\nfunc TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdefaults AgentDefaults\n\t\twantName string\n\t}{\n\t\t{\n\t\t\tname:     \"new model_name field only\",\n\t\t\tdefaults: AgentDefaults{ModelName: \"new-model\"},\n\t\t\twantName: \"new-model\",\n\t\t},\n\t\t{\n\t\t\tname:     \"old model field only\",\n\t\t\tdefaults: AgentDefaults{Model: \"legacy-model\"},\n\t\t\twantName: \"legacy-model\",\n\t\t},\n\t\t{\n\t\t\tname:     \"both fields - model_name takes precedence\",\n\t\t\tdefaults: AgentDefaults{ModelName: \"new-model\", Model: \"old-model\"},\n\t\t\twantName: \"new-model\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.defaults.GetModelName(); got != tt.wantName {\n\t\t\t\tt.Errorf(\"GetModelName() = %q, want %q\", got, tt.wantName)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tjson     string\n\t\twantName string\n\t}{\n\t\t{\n\t\t\tname:     \"new model_name field\",\n\t\t\tjson:     `{\"model_name\": \"gpt4\"}`,\n\t\t\twantName: \"gpt4\",\n\t\t},\n\t\t{\n\t\t\tname:     \"old model field\",\n\t\t\tjson:     `{\"model\": \"gpt4\"}`,\n\t\t\twantName: \"gpt4\",\n\t\t},\n\t\t{\n\t\t\tname:     \"both fields - model_name wins\",\n\t\t\tjson:     `{\"model_name\": \"new\", \"model\": \"old\"}`,\n\t\t\twantName: \"new\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar defaults AgentDefaults\n\t\t\tif err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {\n\t\t\t\tt.Fatalf(\"Unmarshal error: %v\", err)\n\t\t\t}\n\t\t\tif got := defaults.GetModelName(); got != tt.wantName {\n\t\t\t\tt.Errorf(\"GetModelName() = %q, want %q\", got, tt.wantName)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFullConfig_JSON_BackwardCompat(t *testing.T) {\n\t// Test complete config with both old and new formats\n\toldFormat := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\",\n\t\t\t\t\"model\": \"gpt4\",\n\t\t\t\t\"max_tokens\": 4096\n\t\t\t}\n\t\t},\n\t\t\"model_list\": [\n\t\t\t{\n\t\t\t\t\"model_name\": \"gpt4\",\n\t\t\t\t\"model\": \"openai/gpt-4o\",\n\t\t\t\t\"api_key\": \"test-key\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tnewFormat := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\",\n\t\t\t\t\"model_name\": \"gpt4\",\n\t\t\t\t\"max_tokens\": 4096\n\t\t\t}\n\t\t},\n\t\t\"model_list\": [\n\t\t\t{\n\t\t\t\t\"model_name\": \"gpt4\",\n\t\t\t\t\"model\": \"openai/gpt-4o\",\n\t\t\t\t\"api_key\": \"test-key\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tfor name, jsonStr := range map[string]string{\n\t\t\"old format (model)\":      oldFormat,\n\t\t\"new format (model_name)\": newFormat,\n\t} {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tcfg := &Config{}\n\t\t\tif err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {\n\t\t\t\tt.Fatalf(\"Unmarshal error: %v\", err)\n\t\t\t}\n\n\t\t\t// Check that GetModelName returns correct value\n\t\t\tif got := cfg.Agents.Defaults.GetModelName(); got != \"gpt4\" {\n\t\t\t\tt.Errorf(\"GetModelName() = %q, want %q\", got, \"gpt4\")\n\t\t\t}\n\n\t\t\t// Check that GetModelConfig works\n\t\t\tmodelCfg, err := cfg.GetModelConfig(\"gpt4\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"GetModelConfig error: %v\", err)\n\t\t\t}\n\t\t\tif modelCfg.Model != \"openai/gpt-4o\" {\n\t\t\t\tt.Errorf(\"Model = %q, want %q\", modelCfg.Model, \"openai/gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestModelConfig_Validate(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  ModelConfig\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"valid config\",\n\t\t\tconfig: ModelConfig{\n\t\t\t\tModelName: \"test\",\n\t\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"missing model_name\",\n\t\t\tconfig: ModelConfig{\n\t\t\t\tModel: \"openai/gpt-4o\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"missing model\",\n\t\t\tconfig: ModelConfig{\n\t\t\t\tModelName: \"test\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"empty config\",\n\t\t\tconfig:  ModelConfig{},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.config.Validate()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"Validate() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_ValidateModelList(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  *Config\n\t\twantErr bool\n\t\terrMsg  string // partial error message to check\n\t}{\n\t\t{\n\t\t\tname: \"valid list\",\n\t\t\tconfig: &Config{\n\t\t\t\tModelList: []ModelConfig{\n\t\t\t\t\t{ModelName: \"test1\", Model: \"openai/gpt-4o\"},\n\t\t\t\t\t{ModelName: \"test2\", Model: \"anthropic/claude\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid entry\",\n\t\t\tconfig: &Config{\n\t\t\t\tModelList: []ModelConfig{\n\t\t\t\t\t{ModelName: \"test1\", Model: \"openai/gpt-4o\"},\n\t\t\t\t\t{ModelName: \"\", Model: \"anthropic/claude\"}, // missing model_name\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"model_name is required\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty list\",\n\t\t\tconfig: &Config{\n\t\t\t\tModelList: []ModelConfig{},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\t// Load balancing: multiple entries with same model_name are allowed\n\t\t\tname: \"duplicate model_name for load balancing\",\n\t\t\tconfig: &Config{\n\t\t\t\tModelList: []ModelConfig{\n\t\t\t\t\t{ModelName: \"gpt-4\", Model: \"openai/gpt-4o\", APIKey: \"key1\"},\n\t\t\t\t\t{ModelName: \"gpt-4\", Model: \"openai/gpt-4-turbo\", APIKey: \"key2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false, // Changed: duplicates are allowed for load balancing\n\t\t},\n\t\t{\n\t\t\t// Load balancing: non-adjacent entries with same model_name are also allowed\n\t\t\tname: \"duplicate model_name non-adjacent for load balancing\",\n\t\t\tconfig: &Config{\n\t\t\t\tModelList: []ModelConfig{\n\t\t\t\t\t{ModelName: \"model-a\", Model: \"openai/gpt-4o\"},\n\t\t\t\t\t{ModelName: \"model-b\", Model: \"anthropic/claude\"},\n\t\t\t\t\t{ModelName: \"model-a\", Model: \"openai/gpt-4-turbo\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false, // Changed: duplicates are allowed for load balancing\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.config.ValidateModelList()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValidateModelList() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\tif err != nil && tt.errMsg != \"\" {\n\t\t\t\tif !strings.Contains(err.Error(), tt.errMsg) {\n\t\t\t\t\tt.Errorf(\"ValidateModelList() error = %v, want error containing %q\", err, tt.errMsg)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestModelConfig_RequestTimeoutParsing(t *testing.T) {\n\tjsonData := `{\n\t\t\"model_name\": \"slow-local\",\n\t\t\"model\": \"openai/local-model\",\n\t\t\"api_base\": \"http://localhost:11434/v1\",\n\t\t\"request_timeout\": 300\n\t}`\n\n\tvar cfg ModelConfig\n\tif err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\n\tif cfg.RequestTimeout != 300 {\n\t\tt.Fatalf(\"RequestTimeout = %d, want 300\", cfg.RequestTimeout)\n\t}\n}\n\nfunc TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {\n\tjsonData := `{\n\t\t\"model_name\": \"default-timeout\",\n\t\t\"model\": \"openai/gpt-4o\",\n\t\t\"api_key\": \"test-key\"\n\t}`\n\n\tvar cfg ModelConfig\n\tif err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\n\tif cfg.RequestTimeout != 0 {\n\t\tt.Fatalf(\"RequestTimeout = %d, want 0\", cfg.RequestTimeout)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/multikey_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n)\n\nfunc TestExpandMultiKeyModels_SingleKey(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"gpt-4\",\n\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\tAPIKey:    \"single-key\",\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 model, got %d\", len(result))\n\t}\n\n\tif result[0].ModelName != \"gpt-4\" {\n\t\tt.Errorf(\"expected model_name 'gpt-4', got %q\", result[0].ModelName)\n\t}\n\n\tif result[0].APIKey != \"single-key\" {\n\t\tt.Errorf(\"expected api_key 'single-key', got %q\", result[0].APIKey)\n\t}\n\n\tif len(result[0].Fallbacks) != 0 {\n\t\tt.Errorf(\"expected no fallbacks, got %v\", result[0].Fallbacks)\n\t}\n}\n\nfunc TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"glm-4.7\",\n\t\t\tModel:     \"zhipu/glm-4.7\",\n\t\t\tAPIBase:   \"https://api.example.com\",\n\t\t\tAPIKeys:   []string{\"key1\", \"key2\", \"key3\"},\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\t// Should expand to 3 models\n\tif len(result) != 3 {\n\t\tt.Fatalf(\"expected 3 models, got %d\", len(result))\n\t}\n\n\t// First entry should be the primary with key1 and fallbacks\n\tprimary := result[2] // Primary is added last\n\tif primary.ModelName != \"glm-4.7\" {\n\t\tt.Errorf(\"expected primary model_name 'glm-4.7', got %q\", primary.ModelName)\n\t}\n\tif primary.APIKey != \"key1\" {\n\t\tt.Errorf(\"expected primary api_key 'key1', got %q\", primary.APIKey)\n\t}\n\tif len(primary.Fallbacks) != 2 {\n\t\tt.Errorf(\"expected 2 fallbacks, got %d\", len(primary.Fallbacks))\n\t}\n\tif primary.Fallbacks[0] != \"glm-4.7__key_1\" {\n\t\tt.Errorf(\"expected first fallback 'glm-4.7__key_1', got %q\", primary.Fallbacks[0])\n\t}\n\tif primary.Fallbacks[1] != \"glm-4.7__key_2\" {\n\t\tt.Errorf(\"expected second fallback 'glm-4.7__key_2', got %q\", primary.Fallbacks[1])\n\t}\n\n\t// Second entry should be key2\n\tsecond := result[0]\n\tif second.ModelName != \"glm-4.7__key_1\" {\n\t\tt.Errorf(\"expected second model_name 'glm-4.7__key_1', got %q\", second.ModelName)\n\t}\n\tif second.APIKey != \"key2\" {\n\t\tt.Errorf(\"expected second api_key 'key2', got %q\", second.APIKey)\n\t}\n\n\t// Third entry should be key3\n\tthird := result[1]\n\tif third.ModelName != \"glm-4.7__key_2\" {\n\t\tt.Errorf(\"expected third model_name 'glm-4.7__key_2', got %q\", third.ModelName)\n\t}\n\tif third.APIKey != \"key3\" {\n\t\tt.Errorf(\"expected third api_key 'key3', got %q\", third.APIKey)\n\t}\n}\n\nfunc TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"gpt-4\",\n\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\tAPIKey:    \"key0\",\n\t\t\tAPIKeys:   []string{\"key1\", \"key2\"},\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\t// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)\n\tif len(result) != 3 {\n\t\tt.Fatalf(\"expected 3 models, got %d\", len(result))\n\t}\n\n\t// Primary should use key0\n\tprimary := result[2]\n\tif primary.APIKey != \"key0\" {\n\t\tt.Errorf(\"expected primary api_key 'key0', got %q\", primary.APIKey)\n\t}\n\tif len(primary.Fallbacks) != 2 {\n\t\tt.Errorf(\"expected 2 fallbacks, got %d\", len(primary.Fallbacks))\n\t}\n}\n\nfunc TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"gpt-4\",\n\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\tAPIKeys:   []string{\"key1\", \"key2\"},\n\t\t\tFallbacks: []string{\"claude-3\"},\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\tprimary := result[1]\n\t// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total\n\tif len(primary.Fallbacks) != 2 {\n\t\tt.Fatalf(\"expected 2 fallbacks, got %d: %v\", len(primary.Fallbacks), primary.Fallbacks)\n\t}\n\n\t// Key fallbacks should come first, then existing fallbacks\n\tif primary.Fallbacks[0] != \"gpt-4__key_1\" {\n\t\tt.Errorf(\"expected first fallback 'gpt-4__key_1', got %q\", primary.Fallbacks[0])\n\t}\n\tif primary.Fallbacks[1] != \"claude-3\" {\n\t\tt.Errorf(\"expected second fallback 'claude-3', got %q\", primary.Fallbacks[1])\n\t}\n}\n\nfunc TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"gpt-4\",\n\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\tAPIKey:    \"\",\n\t\t\tAPIKeys:   []string{},\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\t// Should keep as-is with no changes\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 model, got %d\", len(result))\n\t}\n\n\tif result[0].ModelName != \"gpt-4\" {\n\t\tt.Errorf(\"expected model_name 'gpt-4', got %q\", result[0].ModelName)\n\t}\n}\n\nfunc TestExpandMultiKeyModels_Deduplication(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName: \"gpt-4\",\n\t\t\tModel:     \"openai/gpt-4o\",\n\t\t\tAPIKey:    \"key1\",\n\t\t\tAPIKeys:   []string{\"key1\", \"key2\", \"key1\"}, // Duplicate key1\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\t// Should only create 2 models (deduplicated keys)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"expected 2 models (deduplicated), got %d\", len(result))\n\t}\n\n\tprimary := result[1]\n\tif primary.APIKey != \"key1\" {\n\t\tt.Errorf(\"expected primary api_key 'key1', got %q\", primary.APIKey)\n\t}\n\tif len(primary.Fallbacks) != 1 {\n\t\tt.Errorf(\"expected 1 fallback, got %d\", len(primary.Fallbacks))\n\t}\n}\n\nfunc TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {\n\tmodels := []ModelConfig{\n\t\t{\n\t\t\tModelName:      \"gpt-4\",\n\t\t\tModel:          \"openai/gpt-4o\",\n\t\t\tAPIBase:        \"https://api.example.com\",\n\t\t\tAPIKeys:        []string{\"key1\", \"key2\"},\n\t\t\tProxy:          \"http://proxy:8080\",\n\t\t\tRPM:            60,\n\t\t\tMaxTokensField: \"max_completion_tokens\",\n\t\t\tRequestTimeout: 30,\n\t\t\tThinkingLevel:  \"high\",\n\t\t},\n\t}\n\n\tresult := ExpandMultiKeyModels(models)\n\n\t// Check primary entry preserves all fields\n\tprimary := result[1]\n\tif primary.APIBase != \"https://api.example.com\" {\n\t\tt.Errorf(\"expected api_base preserved, got %q\", primary.APIBase)\n\t}\n\tif primary.Proxy != \"http://proxy:8080\" {\n\t\tt.Errorf(\"expected proxy preserved, got %q\", primary.Proxy)\n\t}\n\tif primary.RPM != 60 {\n\t\tt.Errorf(\"expected rpm preserved, got %d\", primary.RPM)\n\t}\n\tif primary.MaxTokensField != \"max_completion_tokens\" {\n\t\tt.Errorf(\"expected max_tokens_field preserved, got %q\", primary.MaxTokensField)\n\t}\n\tif primary.RequestTimeout != 30 {\n\t\tt.Errorf(\"expected request_timeout preserved, got %d\", primary.RequestTimeout)\n\t}\n\tif primary.ThinkingLevel != \"high\" {\n\t\tt.Errorf(\"expected thinking_level preserved, got %q\", primary.ThinkingLevel)\n\t}\n\n\t// Check additional entry also preserves fields\n\tadditional := result[0]\n\tif additional.APIBase != \"https://api.example.com\" {\n\t\tt.Errorf(\"expected additional api_base preserved, got %q\", additional.APIBase)\n\t}\n\tif additional.RPM != 60 {\n\t\tt.Errorf(\"expected additional rpm preserved, got %d\", additional.RPM)\n\t}\n}\n\nfunc TestMergeAPIKeys(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tapiKey   string\n\t\tapiKeys  []string\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\tname:     \"both empty\",\n\t\t\tapiKey:   \"\",\n\t\t\tapiKeys:  nil,\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"only apiKey\",\n\t\t\tapiKey:   \"key1\",\n\t\t\tapiKeys:  nil,\n\t\t\texpected: []string{\"key1\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"only apiKeys\",\n\t\t\tapiKey:   \"\",\n\t\t\tapiKeys:  []string{\"key1\", \"key2\"},\n\t\t\texpected: []string{\"key1\", \"key2\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"both with overlap\",\n\t\t\tapiKey:   \"key1\",\n\t\t\tapiKeys:  []string{\"key1\", \"key2\", \"key3\"},\n\t\t\texpected: []string{\"key1\", \"key2\", \"key3\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"with whitespace\",\n\t\t\tapiKey:   \"  key1  \",\n\t\t\tapiKeys:  []string{\"  key2  \", \"  key1  \"},\n\t\t\texpected: []string{\"key1\", \"key2\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := MergeAPIKeys(tt.apiKey, tt.apiKeys)\n\t\t\tif len(result) != len(tt.expected) {\n\t\t\t\tt.Fatalf(\"expected %d keys, got %d\", len(tt.expected), len(result))\n\t\t\t}\n\t\t\tfor i, k := range result {\n\t\t\t\tif k != tt.expected[i] {\n\t\t\t\t\tt.Errorf(\"expected key[%d] = %q, got %q\", i, tt.expected[i], k)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/config/version.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n)\n\n// Build-time variables injected via ldflags during build process.\n// These are set by the Makefile or .goreleaser.yaml using the -X flag:\n//\n//\t-X github.com/sipeed/picoclaw/pkg/config.Version=<version>\n//\t-X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>\n//\t-X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>\n//\t-X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>\nvar (\n\tVersion   = \"dev\" // Default value when not built with ldflags\n\tGitCommit string  // Git commit SHA (short)\n\tBuildTime string  // Build timestamp in RFC3339 format\n\tGoVersion string  // Go version used for building\n)\n\n// FormatVersion returns the version string with optional git commit\nfunc FormatVersion() string {\n\tv := Version\n\tif GitCommit != \"\" {\n\t\tv += fmt.Sprintf(\" (git: %s)\", GitCommit)\n\t}\n\treturn v\n}\n\n// FormatBuildInfo returns build time and go version info\nfunc FormatBuildInfo() (string, string) {\n\tbuild := BuildTime\n\tgoVer := GoVersion\n\tif goVer == \"\" {\n\t\tgoVer = runtime.Version()\n\t}\n\treturn build, goVer\n}\n\n// GetVersion returns the version string\nfunc GetVersion() string {\n\treturn Version\n}\n"
  },
  {
    "path": "pkg/config/version_test.go",
    "content": "package config\n\nimport (\n\t\"runtime\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFormatVersion_NoGitCommit(t *testing.T) {\n\toldVersion, oldGit := Version, GitCommit\n\tt.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })\n\n\tVersion = \"1.2.3\"\n\tGitCommit = \"\"\n\n\tassert.Equal(t, \"1.2.3\", FormatVersion())\n}\n\nfunc TestFormatVersion_WithGitCommit(t *testing.T) {\n\toldVersion, oldGit := Version, GitCommit\n\tt.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })\n\n\tVersion = \"1.2.3\"\n\tGitCommit = \"abc123\"\n\n\tassert.Equal(t, \"1.2.3 (git: abc123)\", FormatVersion())\n}\n\nfunc TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {\n\toldBuildTime, oldGoVersion := BuildTime, GoVersion\n\tt.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })\n\n\tBuildTime = \"2026-02-20T00:00:00Z\"\n\tGoVersion = \"go1.23.0\"\n\n\tbuild, goVer := FormatBuildInfo()\n\n\tassert.Equal(t, BuildTime, build)\n\tassert.Equal(t, GoVersion, goVer)\n}\n\nfunc TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {\n\toldBuildTime, oldGoVersion := BuildTime, GoVersion\n\tt.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })\n\n\tBuildTime = \"\"\n\tGoVersion = \"go1.23.0\"\n\n\tbuild, goVer := FormatBuildInfo()\n\n\tassert.Empty(t, build)\n\tassert.Equal(t, GoVersion, goVer)\n}\n\nfunc TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {\n\toldBuildTime, oldGoVersion := BuildTime, GoVersion\n\tt.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })\n\n\tBuildTime = \"x\"\n\tGoVersion = \"\"\n\n\tbuild, goVer := FormatBuildInfo()\n\n\tassert.Equal(t, \"x\", build)\n\tassert.Equal(t, runtime.Version(), goVer)\n}\n\nfunc TestGetVersion(t *testing.T) {\n\toldVersion := Version\n\tt.Cleanup(func() { Version = oldVersion })\n\n\tVersion = \"dev\"\n\tassert.Equal(t, \"dev\", GetVersion())\n}\n\nfunc TestGetVersion_Custom(t *testing.T) {\n\toldVersion := Version\n\tt.Cleanup(func() { Version = oldVersion })\n\n\tVersion = \"v1.0.0\"\n\tassert.Equal(t, \"v1.0.0\", GetVersion())\n}\n\nfunc TestVersion_DefaultIsDev(t *testing.T) {\n\t// Reset to default values\n\toldVersion := Version\n\tVersion = \"dev\"\n\tt.Cleanup(func() { Version = oldVersion })\n\n\tassert.Equal(t, \"dev\", Version)\n}\n"
  },
  {
    "path": "pkg/constants/channels.go",
    "content": "// Package constants provides shared constants across the codebase.\npackage constants\n\n// internalChannels defines channels that are used for internal communication\n// and should not be exposed to external users or recorded as last active channel.\nvar internalChannels = map[string]struct{}{\n\t\"cli\":      {},\n\t\"system\":   {},\n\t\"subagent\": {},\n}\n\n// IsInternalChannel returns true if the channel is an internal channel.\nfunc IsInternalChannel(channel string) bool {\n\t_, found := internalChannels[channel]\n\treturn found\n}\n"
  },
  {
    "path": "pkg/credential/credential.go",
    "content": "// Package credential resolves API credential values for model_list entries.\n//\n// An API key is a form of authorization credential. This package centralizes\n// how raw credential strings—plaintext or file references—are resolved into\n// their actual values, keeping that logic out of the config loader.\n//\n// Supported formats for the api_key field:\n//\n//   - Plaintext:   \"sk-abc123\"          → returned as-is\n//   - File ref:    \"file://filename.key\" → content read from configDir/filename.key\n//   - Encrypted:   \"enc://<base64>\"     → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE\n//   - Empty:       \"\"                   → returned as-is (auth_method=oauth etc.)\n//\n// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux).\n// An SSH private key is required for both encryption and decryption.\n// Key derivation:\n//\n//\tHKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info)\n//\n// SSH key path resolution priority:\n//\n//  1. sshKeyPath argument to Encrypt (explicit)\n//  2. PICOCLAW_SSH_KEY_PATH env var\n//  3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform)\npackage credential\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/hkdf\"\n\t\"crypto/hmac\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// PassphraseEnvVar is the environment variable that holds the encryption passphrase.\n// Other packages (e.g. config) reference this constant to avoid duplicating the string.\nconst PassphraseEnvVar = \"PICOCLAW_KEY_PASSPHRASE\"\n\n// PassphraseProvider is the function used to retrieve the passphrase for enc://\n// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the\n// process environment. Replace it at startup to use a different source, such as\n// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the\n// same passphrase source without needing os.Environ.\n//\n// Example (launcher main.go):\n//\n//\tcredential.PassphraseProvider = apiHandler.passphraseStore.Get\nvar PassphraseProvider func() string = func() string {\n\treturn os.Getenv(PassphraseEnvVar)\n}\n\n// ErrPassphraseRequired is returned when an enc:// credential is encountered but\n// no passphrase is available from PassphraseProvider. Callers can detect this\n// with errors.Is to distinguish a missing-passphrase condition from other errors.\nvar ErrPassphraseRequired = errors.New(\"credential: enc:// passphrase required\")\n\n// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted,\n// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is.\nvar ErrDecryptionFailed = errors.New(\"credential: enc:// decryption failed (wrong passphrase or SSH key?)\")\n\n// SSHKeyPathEnvVar is the environment variable that specifies the path to the\n// SSH private key used for enc:// credential encryption and decryption.\nconst SSHKeyPathEnvVar = \"PICOCLAW_SSH_KEY_PATH\"\n\n// picoclawHome is a package-local copy of config.EnvHome. It is kept here to\n// avoid a circular import between pkg/credential and pkg/config.\nconst picoclawHome = \"PICOCLAW_HOME\"\n\nconst (\n\tfileScheme = \"file://\"\n\tencScheme  = \"enc://\"\n\thkdfInfo   = \"picoclaw-credential-v1\"\n\tsaltLen    = 16\n\tnonceLen   = 12\n\tkeyLen     = 32\n)\n\n// Resolver resolves raw credential strings for model_list api_key fields.\n// File references are resolved relative to the directory of the config file.\ntype Resolver struct {\n\tconfigDir         string\n\tresolvedConfigDir string // symlink-resolved form of configDir\n}\n\n// NewResolver returns a Resolver that resolves file:// references relative to\n// configDir (typically filepath.Dir of the config file path).\nfunc NewResolver(configDir string) *Resolver {\n\tresolved := configDir\n\tif configDir != \"\" {\n\t\tif linkedPath, err := filepath.EvalSymlinks(configDir); err == nil {\n\t\t\tresolved = linkedPath\n\t\t}\n\t}\n\treturn &Resolver{configDir: configDir, resolvedConfigDir: resolved}\n}\n\n// Resolve returns the actual credential value for raw:\n//\n//   - \"\"                → \"\" (no error; auth_method=oauth needs no key)\n//   - \"file://name.key\" → trimmed content of configDir/name.key\n//   - anything else     → raw unchanged (plaintext credential)\nfunc (r *Resolver) Resolve(raw string) (string, error) {\n\tif raw == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tif strings.HasPrefix(raw, fileScheme) {\n\t\tfileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme))\n\t\tif fileName == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"credential: file:// reference has no filename\")\n\t\t}\n\n\t\tbaseDir := r.resolvedConfigDir\n\t\tif baseDir == \"\" {\n\t\t\tbaseDir = r.configDir\n\t\t}\n\t\tkeyPath := filepath.Join(baseDir, fileName)\n\t\t// Resolve symlinks before enforcing containment to prevent escaping via symlinks.\n\t\trealKeyPath, err := filepath.EvalSymlinks(keyPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"credential: failed to resolve credential file path %q: %w\", keyPath, err)\n\t\t}\n\t\tif !isWithinDir(realKeyPath, baseDir) {\n\t\t\treturn \"\", fmt.Errorf(\"credential: file:// path escapes config directory\")\n\t\t}\n\t\tdata, err := os.ReadFile(realKeyPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"credential: failed to read credential file %q: %w\", realKeyPath, err)\n\t\t}\n\n\t\tvalue := strings.TrimSpace(string(data))\n\t\tif value == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"credential: credential file %q is empty\", realKeyPath)\n\t\t}\n\n\t\treturn value, nil\n\t}\n\n\tif strings.HasPrefix(raw, encScheme) {\n\t\treturn resolveEncrypted(raw)\n\t}\n\n\t// Plaintext credential — return unchanged.\n\treturn raw, nil\n}\n\n// resolveEncrypted decrypts an enc:// credential using PassphraseProvider.\nfunc resolveEncrypted(raw string) (string, error) {\n\tpassphrase := PassphraseProvider()\n\tif passphrase == \"\" {\n\t\treturn \"\", ErrPassphraseRequired\n\t}\n\n\tsshKeyPath := pickSSHKeyPath(\"\") // override=\"\": consult env then auto-detect\n\n\tb64 := strings.TrimPrefix(raw, encScheme)\n\tblob, err := base64.StdEncoding.DecodeString(b64)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: enc:// invalid base64: %w\", err)\n\t}\n\tif len(blob) < saltLen+nonceLen+1 {\n\t\treturn \"\", fmt.Errorf(\"credential: enc:// payload too short\")\n\t}\n\n\tsalt := blob[:saltLen]\n\tnonce := blob[saltLen : saltLen+nonceLen]\n\tciphertext := blob[saltLen+nonceLen:]\n\n\tkey, err := deriveKey(passphrase, sshKeyPath, salt)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: enc:// cipher init: %w\", err)\n\t}\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: enc:// gcm init: %w\", err)\n\t}\n\n\tplaintext, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: %w\", ErrDecryptionFailed, err)\n\t}\n\treturn string(plaintext), nil\n}\n\n// Encrypt encrypts plaintext and returns an enc:// credential string.\n//\n// passphrase is required (PICOCLAW_KEY_PASSPHRASE value).\n// sshKeyPath is the SSH private key file to use; pass \"\" to auto-detect via\n// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key.\n// An SSH private key must be resolvable or Encrypt returns an error.\nfunc Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) {\n\tif passphrase == \"\" {\n\t\treturn \"\", fmt.Errorf(\"credential: passphrase must not be empty\")\n\t}\n\tsshKeyPath = pickSSHKeyPath(sshKeyPath)\n\n\tsalt := make([]byte, saltLen)\n\tif _, err := io.ReadFull(rand.Reader, salt); err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: failed to generate salt: %w\", err)\n\t}\n\n\tkey, err := deriveKey(passphrase, sshKeyPath, salt)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: cipher init: %w\", err)\n\t}\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: gcm init: %w\", err)\n\t}\n\n\tnonce := make([]byte, nonceLen)\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: failed to generate nonce: %w\", err)\n\t}\n\n\tciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil)\n\tblob := make([]byte, 0, saltLen+nonceLen+len(ciphertext))\n\tblob = append(blob, salt...)\n\tblob = append(blob, nonce...)\n\tblob = append(blob, ciphertext...)\n\treturn encScheme + base64.StdEncoding.EncodeToString(blob), nil\n}\n\n// isWithinDir reports whether path is contained within (or equal to) dir.\n// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection.\nfunc isWithinDir(path, dir string) bool {\n\trel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path))\n\treturn err == nil && filepath.IsLocal(rel)\n}\n\n// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files:\n//   - exact match with PICOCLAW_SSH_KEY_PATH env var\n//   - within the PICOCLAW_HOME env var directory\n//   - within ~/.ssh/\nfunc allowedSSHKeyPath(path string) bool {\n\tif path == \"\" {\n\t\treturn true // passphrase-only mode; no file will be read\n\t}\n\tclean := filepath.Clean(path)\n\n\t// Exact match with PICOCLAW_SSH_KEY_PATH.\n\tif envPath, ok := os.LookupEnv(SSHKeyPathEnvVar); ok && envPath != \"\" {\n\t\tif clean == filepath.Clean(envPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Within PICOCLAW_HOME.\n\tif picoHome := os.Getenv(picoclawHome); picoHome != \"\" {\n\t\tif isWithinDir(clean, picoHome) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Within ~/.ssh/.\n\tif userHome, err := os.UserHomeDir(); err == nil {\n\t\tif isWithinDir(clean, filepath.Join(userHome, \".ssh\")) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key.\n//\n// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)\n// Final key: HKDF-SHA256(ikm, salt, info=\"picoclaw-credential-v1\", 32 bytes)\n// sshKeyPath must be non-empty; returns an error otherwise.\nfunc deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {\n\tif sshKeyPath == \"\" {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"credential: SSH private key is required but not found\" +\n\t\t\t\t\" (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)\")\n\t}\n\tif !allowedSSHKeyPath(sshKeyPath) {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)\",\n\t\t\tsshKeyPath,\n\t\t)\n\t}\n\tsshBytes, err := os.ReadFile(sshKeyPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"credential: cannot read SSH key %q: %w\", sshKeyPath, err)\n\t}\n\tsshHash := sha256.Sum256(sshBytes)\n\tmac := hmac.New(sha256.New, sshHash[:])\n\tmac.Write([]byte(passphrase))\n\tikm := mac.Sum(nil)\n\n\tkey, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"credential: HKDF expand failed: %w\", err)\n\t}\n\treturn key, nil\n}\n\n// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption.\n//\n// Priority:\n//  1. override (non-empty explicit argument)\n//  2. PICOCLAW_SSH_KEY_PATH env var\n//  3. ~/.ssh/picoclaw_ed25519.key (auto-detection)\n//\n// Returns \"\" when no key is found; deriveKey will return an error in that case.\nfunc pickSSHKeyPath(override string) string {\n\tif override != \"\" {\n\t\treturn override\n\t}\n\tif p, ok := os.LookupEnv(SSHKeyPathEnvVar); ok {\n\t\treturn p // respect explicit setting, even if \"\"\n\t}\n\treturn findDefaultSSHKey()\n}\n\n// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists.\nfunc findDefaultSSHKey() string {\n\tp, err := DefaultSSHKeyPath()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif _, err := os.Stat(p); err == nil {\n\t\treturn p\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "pkg/credential/credential_test.go",
    "content": "package credential_test\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/credential\"\n)\n\nfunc TestResolve_PlainKey(t *testing.T) {\n\tr := credential.NewResolver(t.TempDir())\n\tgot, err := r.Resolve(\"sk-plaintext-key\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"sk-plaintext-key\" {\n\t\tt.Fatalf(\"got %q, want %q\", got, \"sk-plaintext-key\")\n\t}\n}\n\nfunc TestResolve_FileKey_Success(t *testing.T) {\n\tdir := t.TempDir()\n\tkeyFile := \"openai_plain.key\"\n\tif err := os.WriteFile(filepath.Join(dir, keyFile), []byte(\"sk-from-file\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tr := credential.NewResolver(dir)\n\tgot, err := r.Resolve(\"file://\" + keyFile)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"sk-from-file\" {\n\t\tt.Fatalf(\"got %q, want %q\", got, \"sk-from-file\")\n\t}\n}\n\nfunc TestResolve_FileKey_NotFound(t *testing.T) {\n\tr := credential.NewResolver(t.TempDir())\n\t_, err := r.Resolve(\"file://missing.key\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for missing file, got nil\")\n\t}\n}\n\nfunc TestResolve_FileKey_Empty(t *testing.T) {\n\tdir := t.TempDir()\n\tkeyFile := \"empty.key\"\n\tif err := os.WriteFile(filepath.Join(dir, keyFile), []byte(\"   \\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tr := credential.NewResolver(dir)\n\t_, err := r.Resolve(\"file://\" + keyFile)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty credential file, got nil\")\n\t}\n}\n\n// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key.\nfunc TestResolve_EncKey_RoundTrip(t *testing.T) {\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-ssh-key-material\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tconst passphrase = \"test-passphrase-32bytes-long-ok!\"\n\tconst plaintext = \"sk-encrypted-secret\"\n\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", sshKeyPath)\n\n\tenc, err := credential.Encrypt(passphrase, \"\", plaintext)\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", passphrase)\n\n\tr := credential.NewResolver(t.TempDir())\n\tgot, err := r.Resolve(enc)\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve: %v\", err)\n\t}\n\tif got != plaintext {\n\t\tt.Fatalf(\"got %q, want %q\", got, plaintext)\n\t}\n}\n\n// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation.\nfunc TestResolve_EncKey_WithSSHKey(t *testing.T) {\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-ssh-private-key-material\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tconst passphrase = \"test-passphrase\"\n\tconst plaintext = \"sk-ssh-protected-secret\"\n\n\t// Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation.\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", passphrase)\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", sshKeyPath)\n\n\tenc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext)\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\tr := credential.NewResolver(t.TempDir())\n\tgot, err := r.Resolve(enc)\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve: %v\", err)\n\t}\n\tif got != plaintext {\n\t\tt.Fatalf(\"got %q, want %q\", got, plaintext)\n\t}\n}\n\nfunc TestResolve_EncKey_NoPassphrase(t *testing.T) {\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-ssh-key\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", sshKeyPath)\n\n\tenc, err := credential.Encrypt(\"some-passphrase\", \"\", \"sk-secret\")\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"\")\n\n\tr := credential.NewResolver(t.TempDir())\n\t_, err = r.Resolve(enc)\n\tif err == nil {\n\t\tt.Fatal(\"expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil\")\n\t}\n}\n\nfunc TestResolve_EncKey_BadCiphertext(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"some-passphrase\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\n\tr := credential.NewResolver(t.TempDir())\n\t_, err := r.Resolve(\"enc://!!not-valid-base64!!\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid enc:// payload, got nil\")\n\t}\n}\n\nfunc TestResolve_EncKey_PayloadTooShort(t *testing.T) {\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"some-passphrase\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\n\t// Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum.\n\timport64 := \"dG9vc2hvcnQ=\" // \"tooshort\" = 8 bytes\n\tr := credential.NewResolver(t.TempDir())\n\t_, err := r.Resolve(\"enc://\" + import64)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for too-short enc:// payload, got nil\")\n\t}\n}\n\nfunc TestResolve_EncKey_WrongPassphrase(t *testing.T) {\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-ssh-key\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", sshKeyPath)\n\n\tenc, err := credential.Encrypt(\"correct-passphrase\", \"\", \"sk-secret\")\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"wrong-passphrase\")\n\n\tr := credential.NewResolver(t.TempDir())\n\t_, err = r.Resolve(enc)\n\tif err == nil {\n\t\tt.Fatal(\"expected decryption error for wrong passphrase, got nil\")\n\t}\n}\n\nfunc TestEncrypt_EmptyPassphrase(t *testing.T) {\n\t_, err := credential.Encrypt(\"\", \"\", \"sk-secret\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty passphrase, got nil\")\n\t}\n}\n\nfunc TestDeriveKey_SSHKeyNotFound(t *testing.T) {\n\t// Encrypt with a real SSH key path, then try to decrypt with a missing path.\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-key\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\t// Register the real key path so allowedSSHKeyPath validation passes for Encrypt.\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", sshKeyPath)\n\n\tenc, err := credential.Encrypt(\"passphrase\", sshKeyPath, \"sk-secret\")\n\tif err != nil {\n\t\tt.Fatalf(\"Encrypt: %v\", err)\n\t}\n\n\t// Point to a non-existent SSH key so deriveKey's ReadFile fails.\n\t// The path is still under the same dir, so allowedSSHKeyPath passes (exact env match).\n\tt.Setenv(\"PICOCLAW_KEY_PASSPHRASE\", \"passphrase\")\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", filepath.Join(dir, \"nonexistent_key\"))\n\n\tr := credential.NewResolver(t.TempDir())\n\t_, err = r.Resolve(enc)\n\tif err == nil {\n\t\tt.Fatal(\"expected error when SSH key file is missing, got nil\")\n\t}\n}\n\n// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir\n// via relative traversal (\"../../etc/passwd\") or absolute paths (\"/abs/path\").\nfunc TestResolve_FileRef_PathTraversal(t *testing.T) {\n\tdir := t.TempDir()\n\tcfgPath := filepath.Join(dir, \"config.json\")\n\t// Create a file outside configDir that the traversal would point to.\n\toutsideFile := filepath.Join(t.TempDir(), \"secret.key\")\n\tif err := os.WriteFile(outsideFile, []byte(\"stolen\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\tr := credential.NewResolver(filepath.Dir(cfgPath))\n\n\tcases := []string{\n\t\t\"file://../../secret.key\",\n\t\t\"file://../secret.key\",\n\t\t\"file://\" + outsideFile, // absolute path\n\t}\n\tfor _, raw := range cases {\n\t\t_, err := r.Resolve(raw)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"Resolve(%q): expected path traversal error, got nil\", raw)\n\t\t}\n\t}\n}\n\n// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works.\nfunc TestResolve_FileRef_withinConfigDir(t *testing.T) {\n\tdir := t.TempDir()\n\tif err := os.WriteFile(filepath.Join(dir, \"my.key\"), []byte(\"sk-valid\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\tr := credential.NewResolver(dir)\n\tgot, err := r.Resolve(\"file://my.key\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif got != \"sk-valid\" {\n\t\tt.Fatalf(\"got %q, want %q\", got, \"sk-valid\")\n\t}\n}\n\n// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths\n// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/.\nfunc TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) {\n\tdir := t.TempDir()\n\tsshKeyPath := filepath.Join(dir, \"picoclaw_ed25519.key\")\n\tif err := os.WriteFile(sshKeyPath, []byte(\"fake-key\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"setup: %v\", err)\n\t}\n\n\t// Make sure none of the allowed env vars point here.\n\tt.Setenv(\"PICOCLAW_SSH_KEY_PATH\", \"\")\n\tt.Setenv(\"PICOCLAW_HOME\", \"\")\n\n\t_, err := credential.Encrypt(\"passphrase\", sshKeyPath, \"sk-secret\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for SSH key outside allowed directories, got nil\")\n\t}\n}\n"
  },
  {
    "path": "pkg/credential/keygen.go",
    "content": "package credential\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/pem\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key.\n// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform).\nfunc DefaultSSHKeyPath() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"credential: cannot determine home directory: %w\", err)\n\t}\n\treturn filepath.Join(home, \".ssh\", \"picoclaw_ed25519.key\"), nil\n}\n\n// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key\n// to path (permissions 0600) and the public key to path+\".pub\" (permissions 0644).\n// The ~/.ssh/ directory is created with 0700 if it does not exist.\n// If the files already exist they are overwritten.\nfunc GenerateSSHKey(path string) error {\n\tif err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: cannot create directory %q: %w\", filepath.Dir(path), err)\n\t}\n\n\tpubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: ed25519 key generation failed: %w\", err)\n\t}\n\n\t// Marshal private key as OpenSSH PEM.\n\tblock, err := ssh.MarshalPrivateKey(privRaw, \"\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: marshal private key: %w\", err)\n\t}\n\tprivPEM := pem.EncodeToMemory(block)\n\n\tif err = os.WriteFile(path, privPEM, 0o600); err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: write private key %q: %w\", path, err)\n\t}\n\n\t// Marshal public key as authorized_keys line.\n\tsshPub, err := ssh.NewPublicKey(pubRaw)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: marshal public key: %w\", err)\n\t}\n\tpubLine := ssh.MarshalAuthorizedKey(sshPub)\n\n\tpubPath := path + \".pub\"\n\tif err := os.WriteFile(pubPath, pubLine, 0o644); err != nil {\n\t\treturn fmt.Errorf(\"credential: keygen: write public key %q: %w\", pubPath, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/credential/keygen_test.go",
    "content": "package credential\n\nimport (\n\t\"crypto/ed25519\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"testing\"\n\n\t\"golang.org/x/crypto/ssh\"\n)\n\nfunc TestGenerateSSHKey_CreatesFiles(t *testing.T) {\n\tdir := t.TempDir()\n\tkeyPath := filepath.Join(dir, \"test_ed25519.key\")\n\n\tif err := GenerateSSHKey(keyPath); err != nil {\n\t\tt.Fatalf(\"GenerateSSHKey() error = %v\", err)\n\t}\n\n\t// Private key must exist.\n\tprivInfo, err := os.Stat(keyPath)\n\tif err != nil {\n\t\tt.Fatalf(\"private key file missing: %v\", err)\n\t}\n\n\t// Check permissions on non-Windows (Windows does not support Unix permission bits).\n\tif runtime.GOOS != \"windows\" {\n\t\tif got := privInfo.Mode().Perm(); got != 0o600 {\n\t\t\tt.Errorf(\"private key permissions = %04o, want 0600\", got)\n\t\t}\n\t}\n\n\t// Public key must exist.\n\tpubPath := keyPath + \".pub\"\n\tpubInfo, err := os.Stat(pubPath)\n\tif err != nil {\n\t\tt.Fatalf(\"public key file missing: %v\", err)\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif got := pubInfo.Mode().Perm(); got != 0o644 {\n\t\t\tt.Errorf(\"public key permissions = %04o, want 0644\", got)\n\t\t}\n\t}\n\n\t// Private key must be parseable as an OpenSSH ed25519 key.\n\tprivPEM, err := os.ReadFile(keyPath)\n\tif err != nil {\n\t\tt.Fatalf(\"read private key: %v\", err)\n\t}\n\tprivKey, err := ssh.ParseRawPrivateKey(privPEM)\n\tif err != nil {\n\t\tt.Fatalf(\"parse private key: %v\", err)\n\t}\n\tif _, ok := privKey.(*ed25519.PrivateKey); !ok {\n\t\tt.Errorf(\"private key type = %T, want *ed25519.PrivateKey\", privKey)\n\t}\n\n\t// Public key must be parseable as authorized_keys line.\n\tpubBytes, err := os.ReadFile(pubPath)\n\tif err != nil {\n\t\tt.Fatalf(\"read public key: %v\", err)\n\t}\n\tpubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes)\n\tif err != nil {\n\t\tt.Fatalf(\"parse public key: %v\", err)\n\t}\n\tif pubKey == nil {\n\t\tt.Fatal(\"expected non-nil public key\")\n\t}\n\tif len(rest) > 0 {\n\t\tt.Errorf(\"unexpected trailing bytes after public key: %d bytes\", len(rest))\n\t}\n}\n\nfunc TestGenerateSSHKey_OverwritesExisting(t *testing.T) {\n\tdir := t.TempDir()\n\tkeyPath := filepath.Join(dir, \"test_ed25519.key\")\n\n\t// Generate twice; second call must not error and must produce a different key.\n\tif err := GenerateSSHKey(keyPath); err != nil {\n\t\tt.Fatalf(\"first GenerateSSHKey() error = %v\", err)\n\t}\n\tfirst, err := os.ReadFile(keyPath)\n\tif err != nil {\n\t\tt.Fatalf(\"read first key: %v\", err)\n\t}\n\n\tif err = GenerateSSHKey(keyPath); err != nil {\n\t\tt.Fatalf(\"second GenerateSSHKey() error = %v\", err)\n\t}\n\tsecond, err := os.ReadFile(keyPath)\n\tif err != nil {\n\t\tt.Fatalf(\"read second key: %v\", err)\n\t}\n\n\t// Two independently generated Ed25519 keys must differ.\n\tif string(first) == string(second) {\n\t\tt.Error(\"expected overwritten key to differ from original\")\n\t}\n}\n\nfunc TestGenerateSSHKey_CreatesDirectory(t *testing.T) {\n\tdir := t.TempDir()\n\t// Nested directory that does not yet exist.\n\tkeyPath := filepath.Join(dir, \"subdir\", \".ssh\", \"picoclaw_ed25519.key\")\n\n\tif err := GenerateSSHKey(keyPath); err != nil {\n\t\tt.Fatalf(\"GenerateSSHKey() error = %v\", err)\n\t}\n\n\tif _, err := os.Stat(keyPath); err != nil {\n\t\tt.Fatalf(\"private key not created: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/credential/store.go",
    "content": "package credential\n\nimport \"sync/atomic\"\n\n// SecureStore holds a passphrase in memory.\n//\n// Uses atomic.Pointer so reads and writes are lock-free.\n// The passphrase is never written to disk; callers decide how to\n// transport it outside this store (e.g., via cmd.Env or os.Environ).\ntype SecureStore struct {\n\tval atomic.Pointer[string]\n}\n\n// NewSecureStore creates an empty SecureStore.\nfunc NewSecureStore() *SecureStore {\n\treturn &SecureStore{}\n}\n\n// SetString stores the passphrase. An empty string clears the store.\nfunc (s *SecureStore) SetString(passphrase string) {\n\tif passphrase == \"\" {\n\t\ts.val.Store(nil)\n\t\treturn\n\t}\n\ts.val.Store(&passphrase)\n}\n\n// Get returns the stored passphrase, or \"\" if not set.\nfunc (s *SecureStore) Get() string {\n\tif p := s.val.Load(); p != nil {\n\t\treturn *p\n\t}\n\treturn \"\"\n}\n\n// IsSet reports whether a passphrase is currently stored.\nfunc (s *SecureStore) IsSet() bool {\n\treturn s.val.Load() != nil\n}\n\n// Clear removes the stored passphrase.\nfunc (s *SecureStore) Clear() {\n\ts.val.Store(nil)\n}\n"
  },
  {
    "path": "pkg/credential/store_test.go",
    "content": "package credential\n\nimport (\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc TestSecureStore_SetGet(t *testing.T) {\n\ts := NewSecureStore()\n\tif s.IsSet() {\n\t\tt.Error(\"expected empty store\")\n\t}\n\n\ts.SetString(\"hunter2\")\n\tif !s.IsSet() {\n\t\tt.Error(\"expected store to be set\")\n\t}\n\tif got := s.Get(); got != \"hunter2\" {\n\t\tt.Errorf(\"Get() = %q, want %q\", got, \"hunter2\")\n\t}\n}\n\nfunc TestSecureStore_Clear(t *testing.T) {\n\ts := NewSecureStore()\n\ts.SetString(\"secret\")\n\ts.Clear()\n\n\tif s.IsSet() {\n\t\tt.Error(\"expected store to be empty after Clear()\")\n\t}\n\tif got := s.Get(); got != \"\" {\n\t\tt.Errorf(\"Get() after Clear() = %q, want empty\", got)\n\t}\n}\n\nfunc TestSecureStore_SetOverwrites(t *testing.T) {\n\ts := NewSecureStore()\n\ts.SetString(\"first\")\n\ts.SetString(\"second\")\n\n\tif got := s.Get(); got != \"second\" {\n\t\tt.Errorf(\"Get() = %q, want %q\", got, \"second\")\n\t}\n}\n\nfunc TestSecureStore_EmptyPassphrase(t *testing.T) {\n\ts := NewSecureStore()\n\ts.SetString(\"\") // empty → should not mark as set\n\n\tif s.IsSet() {\n\t\tt.Error(\"empty passphrase should not mark store as set\")\n\t}\n}\n\nfunc TestSecureStore_ConcurrentSetGet(t *testing.T) {\n\ts := NewSecureStore()\n\tconst goroutines = 10\n\tconst iterations = 1000\n\n\tvar wg sync.WaitGroup\n\twg.Add(goroutines)\n\tfor i := 0; i < goroutines; i++ {\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := 0; j < iterations; j++ {\n\t\t\t\tif id%2 == 0 {\n\t\t\t\t\ts.SetString(\"even\")\n\t\t\t\t} else {\n\t\t\t\t\ts.SetString(\"odd\")\n\t\t\t\t}\n\t\t\t\t_ = s.Get()\n\t\t\t}\n\t\t}(i)\n\t}\n\twg.Wait()\n\n\tfinal := s.Get()\n\tif final != \"\" && final != \"even\" && final != \"odd\" {\n\t\tt.Errorf(\"Get() returned unexpected value %q after concurrent Set/Get\", final)\n\t}\n}\n"
  },
  {
    "path": "pkg/cron/service.go",
    "content": "package cron\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/adhocore/gronx\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n)\n\ntype CronSchedule struct {\n\tKind    string `json:\"kind\"`\n\tAtMS    *int64 `json:\"atMs,omitempty\"`\n\tEveryMS *int64 `json:\"everyMs,omitempty\"`\n\tExpr    string `json:\"expr,omitempty\"`\n\tTZ      string `json:\"tz,omitempty\"`\n}\n\ntype CronPayload struct {\n\tKind    string `json:\"kind\"`\n\tMessage string `json:\"message\"`\n\tCommand string `json:\"command,omitempty\"`\n\tDeliver bool   `json:\"deliver\"`\n\tChannel string `json:\"channel,omitempty\"`\n\tTo      string `json:\"to,omitempty\"`\n}\n\ntype CronJobState struct {\n\tNextRunAtMS *int64 `json:\"nextRunAtMs,omitempty\"`\n\tLastRunAtMS *int64 `json:\"lastRunAtMs,omitempty\"`\n\tLastStatus  string `json:\"lastStatus,omitempty\"`\n\tLastError   string `json:\"lastError,omitempty\"`\n}\n\ntype CronJob struct {\n\tID             string       `json:\"id\"`\n\tName           string       `json:\"name\"`\n\tEnabled        bool         `json:\"enabled\"`\n\tSchedule       CronSchedule `json:\"schedule\"`\n\tPayload        CronPayload  `json:\"payload\"`\n\tState          CronJobState `json:\"state\"`\n\tCreatedAtMS    int64        `json:\"createdAtMs\"`\n\tUpdatedAtMS    int64        `json:\"updatedAtMs\"`\n\tDeleteAfterRun bool         `json:\"deleteAfterRun\"`\n}\n\ntype CronStore struct {\n\tVersion int       `json:\"version\"`\n\tJobs    []CronJob `json:\"jobs\"`\n}\n\ntype JobHandler func(job *CronJob) (string, error)\n\ntype CronService struct {\n\tstorePath string\n\tstore     *CronStore\n\tonJob     JobHandler\n\tmu        sync.RWMutex\n\trunning   bool\n\tstopChan  chan struct{}\n\twakeChan  chan struct{}\n\tgronx     *gronx.Gronx\n}\n\nfunc NewCronService(storePath string, onJob JobHandler) *CronService {\n\tcs := &CronService{\n\t\tstorePath: storePath,\n\t\tonJob:     onJob,\n\t\tgronx:     gronx.New(),\n\t\twakeChan:  make(chan struct{}),\n\t}\n\t// Initialize and load store on creation\n\tcs.loadStore()\n\treturn cs\n}\n\nfunc (cs *CronService) Start() error {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tif cs.running {\n\t\treturn nil\n\t}\n\n\tif err := cs.loadStore(); err != nil {\n\t\treturn fmt.Errorf(\"failed to load store: %w\", err)\n\t}\n\n\tcs.recomputeNextRuns()\n\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\treturn fmt.Errorf(\"failed to save store: %w\", err)\n\t}\n\n\tcs.stopChan = make(chan struct{})\n\tif cs.wakeChan == nil {\n\t\tcs.wakeChan = make(chan struct{})\n\t}\n\tcs.running = true\n\tgo cs.runLoop(cs.stopChan)\n\n\treturn nil\n}\n\nfunc (cs *CronService) Stop() {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tif !cs.running {\n\t\treturn\n\t}\n\n\tcs.running = false\n\tif cs.stopChan != nil {\n\t\tclose(cs.stopChan)\n\t\tcs.stopChan = nil\n\t}\n}\n\nfunc (cs *CronService) runLoop(stopChan chan struct{}) {\n\ttimer := time.NewTimer(time.Hour)\n\tif !timer.Stop() {\n\t\t<-timer.C\n\t}\n\tdefer timer.Stop()\n\n\tfor {\n\t\t// every loop, recalculate the next wake time\n\t\tcs.mu.RLock()\n\t\tnextWake := cs.getNextWakeMS()\n\t\tcs.mu.RUnlock()\n\n\t\tvar delay time.Duration\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif nextWake == nil {\n\t\t\t// no jobs, sleep for a long time (or until a new job is added)\n\t\t\tdelay = time.Hour\n\t\t} else {\n\t\t\tdiff := *nextWake - now\n\t\t\tif diff <= 0 {\n\t\t\t\tdelay = 0\n\t\t\t} else {\n\t\t\t\tdelay = time.Duration(diff) * time.Millisecond\n\t\t\t}\n\t\t}\n\n\t\ttimer.Reset(delay)\n\n\t\tselect {\n\t\tcase <-stopChan:\n\t\t\treturn\n\t\tcase <-cs.wakeChan: // wake on new job or update\n\t\t\tif !timer.Stop() {\n\t\t\t\tselect {\n\t\t\t\tcase <-timer.C:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\tcase <-timer.C:\n\t\t\tcs.checkJobs()\n\t\t}\n\t}\n}\n\nfunc (cs *CronService) checkJobs() {\n\tcs.mu.Lock()\n\n\tif !cs.running {\n\t\tcs.mu.Unlock()\n\t\treturn\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tvar dueJobIDs []string\n\n\t// Collect jobs that are due (we need to copy them to execute outside lock)\n\tfor i := range cs.store.Jobs {\n\t\tjob := &cs.store.Jobs[i]\n\t\tif job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {\n\t\t\tdueJobIDs = append(dueJobIDs, job.ID)\n\t\t}\n\t}\n\n\t// Reset next run for due jobs before unlocking to avoid duplicate execution.\n\tdueMap := make(map[string]bool, len(dueJobIDs))\n\tfor _, jobID := range dueJobIDs {\n\t\tdueMap[jobID] = true\n\t}\n\tfor i := range cs.store.Jobs {\n\t\tif dueMap[cs.store.Jobs[i].ID] {\n\t\t\tcs.store.Jobs[i].State.NextRunAtMS = nil\n\t\t}\n\t}\n\n\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\tlog.Printf(\"[cron] failed to save store: %v\", err)\n\t}\n\n\tcs.mu.Unlock()\n\n\t// Execute jobs outside lock.\n\tfor _, jobID := range dueJobIDs {\n\t\tcs.executeJobByID(jobID)\n\t}\n}\n\nfunc (cs *CronService) executeJobByID(jobID string) {\n\tstartTime := time.Now().UnixMilli()\n\n\tcs.mu.RLock()\n\tvar callbackJob *CronJob\n\tfor i := range cs.store.Jobs {\n\t\tjob := &cs.store.Jobs[i]\n\t\tif job.ID == jobID {\n\t\t\tjobCopy := *job\n\t\t\tcallbackJob = &jobCopy\n\t\t\tbreak\n\t\t}\n\t}\n\tcs.mu.RUnlock()\n\n\tif callbackJob == nil {\n\t\tlog.Printf(\"[cron] job %s not found, skipping\", jobID)\n\t\treturn\n\t}\n\n\t// Log job execution start\n\tlog.Printf(\"[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)\",\n\t\tcallbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel)\n\n\tvar err error\n\tif cs.onJob != nil {\n\t\t_, err = cs.onJob(callbackJob)\n\t}\n\n\texecDuration := time.Now().UnixMilli() - startTime\n\n\t// Now acquire lock to update state\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tvar job *CronJob\n\tfor i := range cs.store.Jobs {\n\t\tif cs.store.Jobs[i].ID == jobID {\n\t\t\tjob = &cs.store.Jobs[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif job == nil {\n\t\tlog.Printf(\"[cron] job %s disappeared before state update\", jobID)\n\t\treturn\n\t}\n\n\tjob.State.LastRunAtMS = &startTime\n\tjob.UpdatedAtMS = time.Now().UnixMilli()\n\n\tif err != nil {\n\t\tjob.State.LastStatus = \"error\"\n\t\tjob.State.LastError = err.Error()\n\t\tlog.Printf(\"[cron] ✗ job '%s' failed after %dms: %v\", job.Name, execDuration, err)\n\t} else {\n\t\tjob.State.LastStatus = \"ok\"\n\t\tjob.State.LastError = \"\"\n\t}\n\n\t// Compute next run time\n\tvar nextRunStr string\n\tif job.Schedule.Kind == \"at\" {\n\t\tif job.DeleteAfterRun {\n\t\t\tcs.removeJobUnsafe(job.ID)\n\t\t\tnextRunStr = \"(deleted)\"\n\t\t} else {\n\t\t\tjob.Enabled = false\n\t\t\tjob.State.NextRunAtMS = nil\n\t\t\tnextRunStr = \"(disabled)\"\n\t\t}\n\t} else {\n\t\tnextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())\n\t\tjob.State.NextRunAtMS = nextRun\n\t\tif nextRun != nil {\n\t\t\tnextRunStr = time.UnixMilli(*nextRun).Format(\"2006-01-02 15:04:05\")\n\t\t} else {\n\t\t\tnextRunStr = \"(none)\"\n\t\t}\n\t}\n\n\tif err == nil {\n\t\tlog.Printf(\"[cron] ✓ job '%s' completed in %dms, next run: %s\", job.Name, execDuration, nextRunStr)\n\t}\n\n\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\tlog.Printf(\"[cron] failed to save store: %v\", err)\n\t}\n}\n\nfunc (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {\n\tswitch schedule.Kind {\n\tcase \"at\":\n\t\tif schedule.AtMS != nil && *schedule.AtMS > nowMS {\n\t\t\treturn schedule.AtMS\n\t\t}\n\t\treturn nil\n\tcase \"every\":\n\t\tif schedule.EveryMS == nil || *schedule.EveryMS <= 0 {\n\t\t\treturn nil\n\t\t}\n\t\tnext := nowMS + *schedule.EveryMS\n\t\treturn &next\n\tcase \"cron\":\n\t\tif schedule.Expr == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Use gronx to calculate next run time\n\t\tnow := time.UnixMilli(nowMS)\n\t\tnextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[cron] failed to compute next run for expr '%s': %v\", schedule.Expr, err)\n\t\t\treturn nil\n\t\t}\n\n\t\tnextMS := nextTime.UnixMilli()\n\t\treturn &nextMS\n\tdefault:\n\t\tlog.Printf(\"[cron] unknown schedule kind '%s'\", schedule.Kind)\n\t\treturn nil\n\t}\n}\n\n// wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs)\nfunc (cs *CronService) notify() {\n\tselect {\n\tcase cs.wakeChan <- struct{}{}:\n\tdefault:\n\t\t// if the channel is full, it means the loop will wake up soon anyway, so we can skip sending\n\t}\n}\n\nfunc (cs *CronService) recomputeNextRuns() {\n\tnow := time.Now().UnixMilli()\n\tfor i := range cs.store.Jobs {\n\t\tjob := &cs.store.Jobs[i]\n\t\tif job.Enabled {\n\t\t\tjob.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, now)\n\t\t}\n\t}\n}\n\nfunc (cs *CronService) getNextWakeMS() *int64 {\n\tvar nextWake *int64\n\tfor _, job := range cs.store.Jobs {\n\t\tif job.Enabled && job.State.NextRunAtMS != nil {\n\t\t\tif nextWake == nil || *job.State.NextRunAtMS < *nextWake {\n\t\t\t\tnextWake = job.State.NextRunAtMS\n\t\t\t}\n\t\t}\n\t}\n\treturn nextWake\n}\n\nfunc (cs *CronService) Load() error {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\treturn cs.loadStore()\n}\n\nfunc (cs *CronService) SetOnJob(handler JobHandler) {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\tcs.onJob = handler\n}\n\nfunc (cs *CronService) loadStore() error {\n\tcs.store = &CronStore{\n\t\tVersion: 1,\n\t\tJobs:    []CronJob{},\n\t}\n\n\tdata, err := os.ReadFile(cs.storePath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\treturn json.Unmarshal(data, cs.store)\n}\n\nfunc (cs *CronService) saveStoreUnsafe() error {\n\tdata, err := json.MarshalIndent(cs.store, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\treturn fileutil.WriteFileAtomic(cs.storePath, data, 0o600)\n}\n\nfunc (cs *CronService) AddJob(\n\tname string,\n\tschedule CronSchedule,\n\tmessage string,\n\tdeliver bool,\n\tchannel, to string,\n) (*CronJob, error) {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tnow := time.Now().UnixMilli()\n\n\t// One-time tasks (at) should be deleted after execution\n\tdeleteAfterRun := (schedule.Kind == \"at\")\n\n\tjob := CronJob{\n\t\tID:       generateID(),\n\t\tName:     name,\n\t\tEnabled:  true,\n\t\tSchedule: schedule,\n\t\tPayload: CronPayload{\n\t\t\tKind:    \"agent_turn\",\n\t\t\tMessage: message,\n\t\t\tDeliver: deliver,\n\t\t\tChannel: channel,\n\t\t\tTo:      to,\n\t\t},\n\t\tState: CronJobState{\n\t\t\tNextRunAtMS: cs.computeNextRun(&schedule, now),\n\t\t},\n\t\tCreatedAtMS:    now,\n\t\tUpdatedAtMS:    now,\n\t\tDeleteAfterRun: deleteAfterRun,\n\t}\n\n\tcs.store.Jobs = append(cs.store.Jobs, job)\n\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcs.notify()\n\n\treturn &job, nil\n}\n\nfunc (cs *CronService) UpdateJob(job *CronJob) error {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tfor i := range cs.store.Jobs {\n\t\tif cs.store.Jobs[i].ID == job.ID {\n\t\t\tcs.store.Jobs[i] = *job\n\t\t\tcs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()\n\n\t\t\tcs.notify()\n\n\t\t\treturn cs.saveStoreUnsafe()\n\t\t}\n\t}\n\treturn fmt.Errorf(\"job not found\")\n}\n\nfunc (cs *CronService) RemoveJob(jobID string) bool {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\treturn cs.removeJobUnsafe(jobID)\n}\n\nfunc (cs *CronService) removeJobUnsafe(jobID string) bool {\n\tbefore := len(cs.store.Jobs)\n\tvar jobs []CronJob\n\tfor _, job := range cs.store.Jobs {\n\t\tif job.ID != jobID {\n\t\t\tjobs = append(jobs, job)\n\t\t}\n\t}\n\tcs.store.Jobs = jobs\n\tremoved := len(cs.store.Jobs) < before\n\n\tif removed {\n\t\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\t\tlog.Printf(\"[cron] failed to save store after remove: %v\", err)\n\t\t}\n\t}\n\n\tcs.notify()\n\n\treturn removed\n}\n\nfunc (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {\n\tcs.mu.Lock()\n\tdefer cs.mu.Unlock()\n\n\tfor i := range cs.store.Jobs {\n\t\tjob := &cs.store.Jobs[i]\n\t\tif job.ID == jobID {\n\t\t\tjob.Enabled = enabled\n\t\t\tjob.UpdatedAtMS = time.Now().UnixMilli()\n\n\t\t\tif enabled {\n\t\t\t\tjob.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())\n\t\t\t} else {\n\t\t\t\tjob.State.NextRunAtMS = nil\n\t\t\t}\n\n\t\t\tif err := cs.saveStoreUnsafe(); err != nil {\n\t\t\t\tlog.Printf(\"[cron] failed to save store after enable: %v\", err)\n\t\t\t}\n\n\t\t\tcs.notify()\n\n\t\t\treturn job\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (cs *CronService) ListJobs(includeDisabled bool) []CronJob {\n\tcs.mu.RLock()\n\tdefer cs.mu.RUnlock()\n\n\tif includeDisabled {\n\t\treturn cs.store.Jobs\n\t}\n\n\tvar enabled []CronJob\n\tfor _, job := range cs.store.Jobs {\n\t\tif job.Enabled {\n\t\t\tenabled = append(enabled, job)\n\t\t}\n\t}\n\n\treturn enabled\n}\n\nfunc (cs *CronService) Status() map[string]any {\n\tcs.mu.RLock()\n\tdefer cs.mu.RUnlock()\n\n\tvar enabledCount int\n\tfor _, job := range cs.store.Jobs {\n\t\tif job.Enabled {\n\t\t\tenabledCount++\n\t\t}\n\t}\n\n\treturn map[string]any{\n\t\t\"enabled\":      cs.running,\n\t\t\"jobs\":         len(cs.store.Jobs),\n\t\t\"nextWakeAtMS\": cs.getNextWakeMS(),\n\t}\n}\n\nfunc generateID() string {\n\t// Use crypto/rand for better uniqueness under concurrent access\n\tb := make([]byte, 8)\n\tif _, err := rand.Read(b); err != nil {\n\t\t// Fallback to time-based if crypto/rand fails\n\t\treturn fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\t}\n\treturn hex.EncodeToString(b)\n}\n"
  },
  {
    "path": "pkg/cron/service_test.go",
    "content": "package cron\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestSaveStore_FilePermissions(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"file permission bits are not enforced on Windows\")\n\t}\n\n\ttmpDir := t.TempDir()\n\tstorePath := filepath.Join(tmpDir, \"cron\", \"jobs.json\")\n\n\tcs := NewCronService(storePath, nil)\n\n\t_, err := cs.AddJob(\"test\", CronSchedule{Kind: \"every\", EveryMS: int64Ptr(60000)}, \"hello\", false, \"cli\", \"direct\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddJob failed: %v\", err)\n\t}\n\n\tinfo, err := os.Stat(storePath)\n\tif err != nil {\n\t\tt.Fatalf(\"Stat failed: %v\", err)\n\t}\n\n\tperm := info.Mode().Perm()\n\tif perm != 0o600 {\n\t\tt.Errorf(\"cron store has permission %04o, want 0600\", perm)\n\t}\n}\n\nfunc int64Ptr(v int64) *int64 {\n\treturn &v\n}\n\nfunc setupService(handler JobHandler) (*CronService, string) {\n\ttmpFile := fmt.Sprintf(\"test_cron_%d.json\", time.Now().UnixNano())\n\tcs := NewCronService(tmpFile, handler)\n\treturn cs, tmpFile\n}\n\nfunc TestCronService_CRUD(t *testing.T) {\n\tcs, path := setupService(nil)\n\tdefer os.Remove(path)\n\n\t// Test AddJob\n\tat := time.Now().Add(time.Hour).UnixMilli()\n\tjob, err := cs.AddJob(\"Task1\", CronSchedule{Kind: \"at\", AtMS: &at}, \"msg\", true, \"ch\", \"to\")\n\tif err != nil || job.ID == \"\" {\n\t\tt.Fatalf(\"AddJob failed: %v\", err)\n\t}\n\n\t// Test ListJobs\n\tif len(cs.ListJobs(true)) != 1 {\n\t\tt.Error(\"ListJobs should return 1 job\")\n\t}\n\n\t// Test UpdateJob\n\tjob.Name = \"UpdatedName\"\n\terr = cs.UpdateJob(job)\n\tif err != nil || cs.store.Jobs[0].Name != \"UpdatedName\" {\n\t\tt.Error(\"UpdateJob failed\")\n\t}\n\n\t// Test EnableJob\n\tcs.EnableJob(job.ID, false)\n\tif cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil {\n\t\tt.Error(\"EnableJob(false) failed to clear state\")\n\t}\n\n\t// Test RemoveJob\n\tremoved := cs.RemoveJob(job.ID)\n\tif !removed || len(cs.store.Jobs) != 0 {\n\t\tt.Error(\"RemoveJob failed\")\n\t}\n}\n\n// 2. Test Cron Expression Calculation Logic\nfunc TestCronService_ComputeNextRun(t *testing.T) {\n\tcs, path := setupService(nil)\n\tdefer os.Remove(path)\n\n\tnow := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli()\n\n\ttests := []struct {\n\t\tname     string\n\t\tschedule CronSchedule\n\t\twantNil  bool\n\t}{\n\t\t{\"Valid Cron\", CronSchedule{Kind: \"cron\", Expr: \"0 * * * *\"}, false},\n\t\t{\"Invalid Cron\", CronSchedule{Kind: \"cron\", Expr: \"invalid\"}, true},\n\t\t{\"Every MS\", CronSchedule{Kind: \"every\", EveryMS: int64Ptr(5000)}, false},\n\t\t{\"At Future\", CronSchedule{Kind: \"at\", AtMS: int64Ptr(now + 1000)}, false},\n\t\t{\"At Past\", CronSchedule{Kind: \"at\", AtMS: int64Ptr(now - 1000)}, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := cs.computeNextRun(&tt.schedule, now)\n\t\t\tif (got == nil) != tt.wantNil {\n\t\t\t\tt.Errorf(\"%s: got %v, wantNil %v\", tt.name, got, tt.wantNil)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// 3. Test Execution Flow\nfunc TestCronService_ExecutionFlow(t *testing.T) {\n\tvar mu sync.Mutex\n\texecutedJobs := make(map[string]bool)\n\n\thandler := func(job *CronJob) (string, error) {\n\t\tmu.Lock()\n\t\texecutedJobs[job.ID] = true\n\t\tmu.Unlock()\n\t\treturn \"ok\", nil\n\t}\n\n\tcs, path := setupService(handler)\n\tdefer os.Remove(path)\n\n\t// Start the service\n\tif err := cs.Start(); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\tdefer cs.Stop()\n\n\t// Add a job then runs 100ms from now\n\ttarget := time.Now().Add(100 * time.Millisecond).UnixMilli()\n\tjob, _ := cs.AddJob(\"FastJob\", CronSchedule{Kind: \"at\", AtMS: &target}, \"\", false, \"\", \"\")\n\n\t// Check for job execution with a timeout\n\tsuccess := false\n\tfor range 20 {\n\t\tmu.Lock()\n\t\tif executedJobs[job.ID] {\n\t\t\tsuccess = true\n\t\t\tmu.Unlock()\n\t\t\tbreak\n\t\t}\n\t\tmu.Unlock()\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\n\tif !success {\n\t\tt.Error(\"Job was not executed in time\")\n\t}\n\n\t// check that the job is removed after execution (DeleteAfterRun = true)\n\tstatus := cs.Status()\n\tif status[\"jobs\"].(int) != 0 {\n\t\tt.Errorf(\"Job should be deleted after run, got count: %v\", status[\"jobs\"])\n\t}\n}\n\nfunc TestCronService_PersistenceIntegrity(t *testing.T) {\n\ttmpFile := \"persist_test.json\"\n\tdefer os.Remove(tmpFile)\n\n\t// write a job and persist\n\tcs1 := NewCronService(tmpFile, nil)\n\tat := int64(2000000000000)\n\tcs1.AddJob(\"PersistMe\", CronSchedule{Kind: \"at\", AtMS: &at}, \"payload\", true, \"ch1\", \"\")\n\n\t// check file exists\n\tif _, err := os.Stat(tmpFile); os.IsNotExist(err) {\n\t\tt.Fatal(\"Store file was not created\")\n\t}\n\n\t// reload and check data integrity\n\tcs2 := NewCronService(tmpFile, nil)\n\tif err := cs2.Load(); err != nil {\n\t\tt.Fatalf(\"Failed to load store: %v\", err)\n\t}\n\n\tjobs := cs2.ListJobs(true)\n\tif len(jobs) != 1 || jobs[0].Name != \"PersistMe\" {\n\t\tt.Errorf(\"Data corruption after reload. Got: %+v\", jobs)\n\t}\n\n\t// test loading invalid JSON\n\tos.WriteFile(tmpFile, []byte(\"{invalid json}\"), 0o644)\n\tcs3 := NewCronService(tmpFile, nil)\n\terr := cs3.loadStore()\n\tif err == nil {\n\t\tt.Error(\"Should return error when loading invalid JSON\")\n\t}\n}\n\nfunc TestCronService_ConcurrentAccess(t *testing.T) {\n\tcs, path := setupService(nil)\n\tdefer os.Remove(path)\n\n\tcs.Start()\n\tdefer cs.Stop()\n\n\tvar wg sync.WaitGroup\n\tworkers := 10\n\titerations := 50\n\n\twg.Add(workers * 2)\n\n\t// add jobs concurrently\n\tfor i := range workers {\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := range iterations {\n\t\t\t\tat := time.Now().Add(time.Hour).UnixMilli()\n\t\t\t\tcs.AddJob(fmt.Sprintf(\"Job-%d-%d\", id, j), CronSchedule{Kind: \"at\", AtMS: &at}, \"\", false, \"\", \"\")\n\t\t\t\ttime.Sleep(100 * time.Microsecond)\n\t\t\t}\n\t\t}(i)\n\t}\n\n\t// read and update jobs concurrently\n\tfor range workers {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := range iterations {\n\t\t\t\tjobs := cs.ListJobs(true)\n\t\t\t\tif len(jobs) > 0 {\n\t\t\t\t\tcs.EnableJob(jobs[0].ID, j%2 == 0)\n\t\t\t\t}\n\t\t\t\ttime.Sleep(100 * time.Microsecond)\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n}\n"
  },
  {
    "path": "pkg/devices/events/events.go",
    "content": "package events\n\nimport \"context\"\n\ntype EventSource interface {\n\tKind() Kind\n\tStart(ctx context.Context) (<-chan *DeviceEvent, error)\n\tStop() error\n}\n\ntype Action string\n\nconst (\n\tActionAdd    Action = \"add\"\n\tActionRemove Action = \"remove\"\n\tActionChange Action = \"change\"\n)\n\ntype Kind string\n\nconst (\n\tKindUSB       Kind = \"usb\"\n\tKindBluetooth Kind = \"bluetooth\"\n\tKindPCI       Kind = \"pci\"\n\tKindGeneric   Kind = \"generic\"\n)\n\ntype DeviceEvent struct {\n\tAction       Action\n\tKind         Kind\n\tDeviceID     string            // e.g. \"1-2\" for USB bus 1 dev 2\n\tVendor       string            // Vendor name or ID\n\tProduct      string            // Product name or ID\n\tSerial       string            // Serial number if available\n\tCapabilities string            // Human-readable capability description\n\tRaw          map[string]string // Raw properties for extensibility\n}\n\nfunc (e *DeviceEvent) FormatMessage() string {\n\tactionEmoji := \"🔌\"\n\tactionText := \"Connected\"\n\tif e.Action == ActionRemove {\n\t\tactionEmoji = \"🔌\"\n\t\tactionText = \"Disconnected\"\n\t}\n\n\tmsg := actionEmoji + \" Device \" + actionText + \"\\n\\n\"\n\tmsg += \"Type: \" + string(e.Kind) + \"\\n\"\n\tmsg += \"Device: \" + e.Vendor + \" \" + e.Product + \"\\n\"\n\tif e.Capabilities != \"\" {\n\t\tmsg += \"Capabilities: \" + e.Capabilities + \"\\n\"\n\t}\n\tif e.Serial != \"\" {\n\t\tmsg += \"Serial: \" + e.Serial + \"\\n\"\n\t}\n\treturn msg\n}\n"
  },
  {
    "path": "pkg/devices/service.go",
    "content": "package devices\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n\t\"github.com/sipeed/picoclaw/pkg/devices/events\"\n\t\"github.com/sipeed/picoclaw/pkg/devices/sources\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/state\"\n)\n\ntype Service struct {\n\tbus     *bus.MessageBus\n\tstate   *state.Manager\n\tsources []events.EventSource\n\tenabled bool\n\tctx     context.Context\n\tcancel  context.CancelFunc\n\tmu      sync.RWMutex\n}\n\ntype Config struct {\n\tEnabled    bool\n\tMonitorUSB bool // When true, monitor USB hotplug (Linux only)\n\t// Future: MonitorBluetooth, MonitorPCI, etc.\n}\n\nfunc NewService(cfg Config, stateMgr *state.Manager) *Service {\n\ts := &Service{\n\t\tstate:   stateMgr,\n\t\tenabled: cfg.Enabled,\n\t\tsources: make([]EventSource, 0),\n\t}\n\n\tif cfg.Enabled && cfg.MonitorUSB {\n\t\ts.sources = append(s.sources, sources.NewUSBMonitor())\n\t}\n\n\treturn s\n}\n\nfunc (s *Service) SetBus(msgBus *bus.MessageBus) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.bus = msgBus\n}\n\nfunc (s *Service) Start(ctx context.Context) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif !s.enabled || len(s.sources) == 0 {\n\t\tlogger.InfoC(\"devices\", \"Device event service disabled or no sources\")\n\t\treturn nil\n\t}\n\n\ts.ctx, s.cancel = context.WithCancel(ctx)\n\n\tfor _, src := range s.sources {\n\t\teventCh, err := src.Start(s.ctx)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"devices\", \"Failed to start source\", map[string]any{\n\t\t\t\t\"kind\":  src.Kind(),\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\tgo s.handleEvents(src.Kind(), eventCh)\n\t\tlogger.InfoCF(\"devices\", \"Device source started\", map[string]any{\n\t\t\t\"kind\": src.Kind(),\n\t\t})\n\t}\n\n\tlogger.InfoC(\"devices\", \"Device event service started\")\n\treturn nil\n}\n\nfunc (s *Service) Stop() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.cancel != nil {\n\t\ts.cancel()\n\t\ts.cancel = nil\n\t}\n\n\tfor _, src := range s.sources {\n\t\tsrc.Stop()\n\t}\n\n\tlogger.InfoC(\"devices\", \"Device event service stopped\")\n}\n\nfunc (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) {\n\tfor ev := range eventCh {\n\t\tif ev == nil {\n\t\t\tcontinue\n\t\t}\n\t\ts.sendNotification(ev)\n\t}\n}\n\nfunc (s *Service) sendNotification(ev *events.DeviceEvent) {\n\ts.mu.RLock()\n\tmsgBus := s.bus\n\ts.mu.RUnlock()\n\n\tif msgBus == nil {\n\t\treturn\n\t}\n\n\tlastChannel := s.state.GetLastChannel()\n\tif lastChannel == \"\" {\n\t\tlogger.DebugCF(\"devices\", \"No last channel, skipping notification\", map[string]any{\n\t\t\t\"event\": ev.FormatMessage(),\n\t\t})\n\t\treturn\n\t}\n\n\tplatform, userID := parseLastChannel(lastChannel)\n\tif platform == \"\" || userID == \"\" || constants.IsInternalChannel(platform) {\n\t\treturn\n\t}\n\n\tmsg := ev.FormatMessage()\n\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer pubCancel()\n\tmsgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\tChannel: platform,\n\t\tChatID:  userID,\n\t\tContent: msg,\n\t})\n\n\tlogger.InfoCF(\"devices\", \"Device notification sent\", map[string]any{\n\t\t\"kind\":   ev.Kind,\n\t\t\"action\": ev.Action,\n\t\t\"to\":     platform,\n\t})\n}\n\nfunc parseLastChannel(lastChannel string) (platform, userID string) {\n\tif lastChannel == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tparts := strings.SplitN(lastChannel, \":\", 2)\n\tif len(parts) != 2 || parts[0] == \"\" || parts[1] == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\treturn parts[0], parts[1]\n}\n"
  },
  {
    "path": "pkg/devices/source.go",
    "content": "package devices\n\nimport \"github.com/sipeed/picoclaw/pkg/devices/events\"\n\ntype EventSource = events.EventSource\n"
  },
  {
    "path": "pkg/devices/sources/usb_linux.go",
    "content": "//go:build linux\n\npackage sources\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/devices/events\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nvar usbClassToCapability = map[string]string{\n\t\"00\": \"Interface Definition (by interface)\",\n\t\"01\": \"Audio\",\n\t\"02\": \"CDC Communication (Network Card/Modem)\",\n\t\"03\": \"HID (Keyboard/Mouse/Gamepad)\",\n\t\"05\": \"Physical Interface\",\n\t\"06\": \"Image (Scanner/Camera)\",\n\t\"07\": \"Printer\",\n\t\"08\": \"Mass Storage (USB Flash Drive/Hard Disk)\",\n\t\"09\": \"USB Hub\",\n\t\"0a\": \"CDC Data\",\n\t\"0b\": \"Smart Card\",\n\t\"0e\": \"Video (Camera)\",\n\t\"dc\": \"Diagnostic Device\",\n\t\"e0\": \"Wireless Controller (Bluetooth)\",\n\t\"ef\": \"Miscellaneous\",\n\t\"fe\": \"Application Specific\",\n\t\"ff\": \"Vendor Specific\",\n}\n\ntype USBMonitor struct {\n\tcmd *exec.Cmd\n\tmu  sync.Mutex\n}\n\nfunc NewUSBMonitor() *USBMonitor {\n\treturn &USBMonitor{}\n}\n\nfunc (m *USBMonitor) Kind() events.Kind {\n\treturn events.KindUSB\n}\n\nfunc (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\t// udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem)\n\t// Followed by KEY=value lines, empty line separates events\n\t// Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev)\n\tcmd := exec.CommandContext(ctx, \"udevadm\", \"monitor\", \"--property\", \"--subsystem-match=usb\")\n\tstdout, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"udevadm stdout pipe: %w\", err)\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn nil, fmt.Errorf(\"udevadm start: %w (is udevadm installed?)\", err)\n\t}\n\n\tm.cmd = cmd\n\teventCh := make(chan *events.DeviceEvent, 16)\n\n\tgo func() {\n\t\tdefer close(eventCh)\n\t\tscanner := bufio.NewScanner(stdout)\n\t\tvar props map[string]string\n\t\tvar action string\n\t\tisUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info\n\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tif line == \"\" {\n\t\t\t\t// End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications)\n\t\t\t\tif isUdev && props != nil && (action == \"add\" || action == \"remove\") {\n\t\t\t\t\tif ev := parseUSBEvent(action, props); ev != nil {\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase eventCh <- ev:\n\t\t\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tprops = nil\n\t\t\t\taction = \"\"\n\t\t\t\tisUdev = false\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tidx := strings.Index(line, \"=\")\n\t\t\t// First line of block: \"UDEV  [ts] action devpath\" or \"KERNEL[ts] action devpath\" - no KEY=value\n\t\t\tif idx <= 0 {\n\t\t\t\tisUdev = strings.HasPrefix(strings.TrimSpace(line), \"UDEV\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Parse KEY=value\n\t\t\tkey := line[:idx]\n\t\t\tval := line[idx+1:]\n\t\t\tif props == nil {\n\t\t\t\tprops = make(map[string]string)\n\t\t\t}\n\t\t\tprops[key] = val\n\n\t\t\tif key == \"ACTION\" {\n\t\t\t\taction = val\n\t\t\t}\n\t\t}\n\n\t\tif err := scanner.Err(); err != nil {\n\t\t\tlogger.ErrorCF(\"devices\", \"udevadm scan error\", map[string]any{\"error\": err.Error()})\n\t\t}\n\t\tcmd.Wait()\n\t}()\n\n\treturn eventCh, nil\n}\n\nfunc (m *USBMonitor) Stop() error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tif m.cmd != nil && m.cmd.Process != nil {\n\t\tm.cmd.Process.Kill()\n\t\tm.cmd = nil\n\t}\n\treturn nil\n}\n\nfunc parseUSBEvent(action string, props map[string]string) *events.DeviceEvent {\n\t// Only care about add/remove for physical devices (not interfaces)\n\tsubsystem := props[\"SUBSYSTEM\"]\n\tif subsystem != \"usb\" {\n\t\treturn nil\n\t}\n\t// Skip interface events - we want device-level only to avoid duplicates\n\tdevType := props[\"DEVTYPE\"]\n\tif devType == \"usb_interface\" {\n\t\treturn nil\n\t}\n\t// Prefer usb_device, but accept if DEVTYPE not set (varies by udev version)\n\tif devType != \"\" && devType != \"usb_device\" {\n\t\treturn nil\n\t}\n\n\tev := &events.DeviceEvent{\n\t\tRaw: props,\n\t}\n\tswitch action {\n\tcase \"add\":\n\t\tev.Action = events.ActionAdd\n\tcase \"remove\":\n\t\tev.Action = events.ActionRemove\n\tdefault:\n\t\treturn nil\n\t}\n\tev.Kind = events.KindUSB\n\n\tev.Vendor = props[\"ID_VENDOR\"]\n\tif ev.Vendor == \"\" {\n\t\tev.Vendor = props[\"ID_VENDOR_ID\"]\n\t}\n\tif ev.Vendor == \"\" {\n\t\tev.Vendor = \"Unknown Vendor\"\n\t}\n\n\tev.Product = props[\"ID_MODEL\"]\n\tif ev.Product == \"\" {\n\t\tev.Product = props[\"ID_MODEL_ID\"]\n\t}\n\tif ev.Product == \"\" {\n\t\tev.Product = \"Unknown Device\"\n\t}\n\n\tev.Serial = props[\"ID_SERIAL_SHORT\"]\n\tev.DeviceID = props[\"DEVPATH\"]\n\tif bus := props[\"BUSNUM\"]; bus != \"\" {\n\t\tif dev := props[\"DEVNUM\"]; dev != \"\" {\n\t\t\tev.DeviceID = bus + \":\" + dev\n\t\t}\n\t}\n\n\t// Map USB class to capability\n\tif class := props[\"ID_USB_CLASS\"]; class != \"\" {\n\t\tev.Capabilities = usbClassToCapability[strings.ToLower(class)]\n\t}\n\tif ev.Capabilities == \"\" {\n\t\tev.Capabilities = \"USB Device\"\n\t}\n\n\treturn ev\n}\n"
  },
  {
    "path": "pkg/devices/sources/usb_stub.go",
    "content": "//go:build !linux\n\npackage sources\n\nimport (\n\t\"context\"\n\n\t\"github.com/sipeed/picoclaw/pkg/devices/events\"\n)\n\ntype USBMonitor struct{}\n\nfunc NewUSBMonitor() *USBMonitor {\n\treturn &USBMonitor{}\n}\n\nfunc (m *USBMonitor) Kind() events.Kind {\n\treturn events.KindUSB\n}\n\nfunc (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {\n\tch := make(chan *events.DeviceEvent)\n\tclose(ch) // Immediately close, no events\n\treturn ch, nil\n}\n\nfunc (m *USBMonitor) Stop() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/fileutil/file.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\n// Package fileutil provides file manipulation utilities.\npackage fileutil\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n)\n\n// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.\n//\n// This guarantees that the target file is either:\n// - Completely written with the new data\n// - Unchanged (if any step fails before rename)\n//\n// The function:\n// 1. Creates a temp file in the same directory (original untouched)\n// 2. Writes data to temp file\n// 3. Syncs data to disk (critical for SD cards/flash storage)\n// 4. Sets file permissions\n// 5. Syncs directory metadata (ensures rename is durable)\n// 6. Atomically renames temp file to target path\n//\n// Safety guarantees:\n// - Original file is NEVER modified until successful rename\n// - Temp file is always cleaned up on error\n// - Data is flushed to physical storage before rename\n// - Directory entry is synced to prevent orphaned inodes\n//\n// Parameters:\n//   - path: Target file path\n//   - data: Data to write\n//   - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)\n//\n// Returns:\n//   - Error if any step fails, nil on success\n//\n// Example:\n//\n//\t// Secure config file (owner read/write only)\n//\terr := utils.WriteFileAtomic(\"config.json\", data, 0o600)\n//\n//\t// Public readable file\n//\terr := utils.WriteFileAtomic(\"public.txt\", data, 0o644)\nfunc WriteFileAtomic(path string, data []byte, perm os.FileMode) error {\n\tdir := filepath.Dir(path)\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create directory: %w\", err)\n\t}\n\n\t// Create temp file in the same directory (ensures atomic rename works)\n\t// Using a hidden prefix (.tmp-) to avoid issues with some tools\n\ttmpFile, err := os.OpenFile(\n\t\tfilepath.Join(dir, fmt.Sprintf(\".tmp-%d-%d\", os.Getpid(), time.Now().UnixNano())),\n\t\tos.O_WRONLY|os.O_CREATE|os.O_EXCL,\n\t\tperm,\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create temp file: %w\", err)\n\t}\n\n\ttmpPath := tmpFile.Name()\n\tcleanup := true\n\n\tdefer func() {\n\t\tif cleanup {\n\t\t\ttmpFile.Close()\n\t\t\t_ = os.Remove(tmpPath)\n\t\t}\n\t}()\n\n\t// Write data to temp file\n\t// Note: Original file is untouched at this point\n\tif _, err := tmpFile.Write(data); err != nil {\n\t\treturn fmt.Errorf(\"failed to write temp file: %w\", err)\n\t}\n\n\t// CRITICAL: Force sync to storage medium before any other operations.\n\t// This ensures data is physically written to disk, not just cached.\n\t// Essential for SD cards, eMMC, and other flash storage on edge devices.\n\tif err := tmpFile.Sync(); err != nil {\n\t\treturn fmt.Errorf(\"failed to sync temp file: %w\", err)\n\t}\n\n\t// Set file permissions before closing\n\tif err := tmpFile.Chmod(perm); err != nil {\n\t\treturn fmt.Errorf(\"failed to set permissions: %w\", err)\n\t}\n\n\t// Close file before rename (required on Windows)\n\tif err := tmpFile.Close(); err != nil {\n\t\treturn fmt.Errorf(\"failed to close temp file: %w\", err)\n\t}\n\n\t// Atomic rename: temp file becomes the target\n\t// On POSIX: rename() is atomic\n\t// On Windows: Rename() is atomic for files\n\tif err := os.Rename(tmpPath, path); err != nil {\n\t\treturn fmt.Errorf(\"failed to rename temp file: %w\", err)\n\t}\n\n\t// Sync directory to ensure rename is durable\n\t// This prevents the renamed file from disappearing after a crash\n\tif dirFile, err := os.Open(dir); err == nil {\n\t\t_ = dirFile.Sync()\n\t\tdirFile.Close()\n\t}\n\n\t// Success: skip cleanup (file was renamed, no temp to remove)\n\tcleanup = false\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/gateway/gateway.go",
    "content": "package gateway\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/agent\"\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/channels\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/dingtalk\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/discord\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/feishu\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/irc\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/line\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/maixcam\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/matrix\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/onebot\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/pico\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/qq\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/slack\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/telegram\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/wecom\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/whatsapp\"\n\t_ \"github.com/sipeed/picoclaw/pkg/channels/whatsapp_native\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/cron\"\n\t\"github.com/sipeed/picoclaw/pkg/devices\"\n\t\"github.com/sipeed/picoclaw/pkg/health\"\n\t\"github.com/sipeed/picoclaw/pkg/heartbeat\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/state\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n\t\"github.com/sipeed/picoclaw/pkg/voice\"\n)\n\nconst (\n\tserviceShutdownTimeout  = 30 * time.Second\n\tproviderReloadTimeout   = 30 * time.Second\n\tgracefulShutdownTimeout = 15 * time.Second\n)\n\ntype services struct {\n\tCronService      *cron.CronService\n\tHeartbeatService *heartbeat.HeartbeatService\n\tMediaStore       media.MediaStore\n\tChannelManager   *channels.Manager\n\tDeviceService    *devices.Service\n\tHealthServer     *health.Server\n\tmanualReloadChan chan struct{}\n\treloading        atomic.Bool\n}\n\ntype startupBlockedProvider struct {\n\treason string\n}\n\nfunc (p *startupBlockedProvider) Chat(\n\t_ context.Context,\n\t_ []providers.Message,\n\t_ []providers.ToolDefinition,\n\t_ string,\n\t_ map[string]any,\n) (*providers.LLMResponse, error) {\n\treturn nil, fmt.Errorf(\"%s\", p.reason)\n}\n\nfunc (p *startupBlockedProvider) GetDefaultModel() string {\n\treturn \"\"\n}\n\n// Run starts the gateway runtime using the configuration loaded from configPath.\nfunc Run(debug bool, configPath string, allowEmptyStartup bool) error {\n\tif debug {\n\t\tlogger.SetLevel(logger.DEBUG)\n\t\tfmt.Println(\"🔍 Debug mode enabled\")\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error loading config: %w\", err)\n\t}\n\n\tprovider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating provider: %w\", err)\n\t}\n\n\tif modelID != \"\" {\n\t\tcfg.Agents.Defaults.ModelName = modelID\n\t}\n\n\tmsgBus := bus.NewMessageBus()\n\tagentLoop := agent.NewAgentLoop(cfg, msgBus, provider)\n\n\tfmt.Println(\"\\n📦 Agent Status:\")\n\tstartupInfo := agentLoop.GetStartupInfo()\n\ttoolsInfo := startupInfo[\"tools\"].(map[string]any)\n\tskillsInfo := startupInfo[\"skills\"].(map[string]any)\n\tfmt.Printf(\"  • Tools: %d loaded\\n\", toolsInfo[\"count\"])\n\tfmt.Printf(\"  • Skills: %d/%d available\\n\", skillsInfo[\"available\"], skillsInfo[\"total\"])\n\n\tlogger.InfoCF(\"agent\", \"Agent initialized\",\n\t\tmap[string]any{\n\t\t\t\"tools_count\":      toolsInfo[\"count\"],\n\t\t\t\"skills_total\":     skillsInfo[\"total\"],\n\t\t\t\"skills_available\": skillsInfo[\"available\"],\n\t\t})\n\n\trunningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Setup manual reload channel for /reload endpoint\n\tmanualReloadChan := make(chan struct{}, 1)\n\trunningServices.manualReloadChan = manualReloadChan\n\treloadTrigger := func() error {\n\t\tif !runningServices.reloading.CompareAndSwap(false, true) {\n\t\t\treturn fmt.Errorf(\"reload already in progress\")\n\t\t}\n\t\tselect {\n\t\tcase manualReloadChan <- struct{}{}:\n\t\t\treturn nil\n\t\tdefault:\n\t\t\t// Should not happen, but reset flag if channel is full\n\t\t\trunningServices.reloading.Store(false)\n\t\t\treturn fmt.Errorf(\"reload already queued\")\n\t\t}\n\t}\n\trunningServices.HealthServer.SetReloadFunc(reloadTrigger)\n\tagentLoop.SetReloadFunc(reloadTrigger)\n\n\tfmt.Printf(\"✓ Gateway started on %s:%d\\n\", cfg.Gateway.Host, cfg.Gateway.Port)\n\tfmt.Println(\"Press Ctrl+C to stop\")\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tgo agentLoop.Run(ctx)\n\n\tvar configReloadChan <-chan *config.Config\n\tstopWatch := func() {}\n\tif cfg.Gateway.HotReload {\n\t\tconfigReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug)\n\t\tlogger.Info(\"Config hot reload enabled\")\n\t}\n\tdefer stopWatch()\n\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)\n\n\tfor {\n\t\tselect {\n\t\tcase <-sigChan:\n\t\t\tlogger.Info(\"Shutting down...\")\n\t\t\tshutdownGateway(runningServices, agentLoop, provider, true)\n\t\t\treturn nil\n\t\tcase newCfg := <-configReloadChan:\n\t\t\tif !runningServices.reloading.CompareAndSwap(false, true) {\n\t\t\t\tlogger.Warn(\"Config reload skipped: another reload is in progress\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\terr := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Config reload failed: %v\", err)\n\t\t\t}\n\t\tcase <-manualReloadChan:\n\t\t\tlogger.Info(\"Manual reload triggered via /reload endpoint\")\n\t\t\tnewCfg, err := config.LoadConfig(configPath)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Error loading config for manual reload: %v\", err)\n\t\t\t\trunningServices.reloading.Store(false)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err = newCfg.ValidateModelList(); err != nil {\n\t\t\t\tlogger.Errorf(\"Config validation failed: %v\", err)\n\t\t\t\trunningServices.reloading.Store(false)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\terr = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Manual reload failed: %v\", err)\n\t\t\t} else {\n\t\t\t\tlogger.Info(\"Manual reload completed successfully\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc executeReload(\n\tctx context.Context,\n\tagentLoop *agent.AgentLoop,\n\tnewCfg *config.Config,\n\tprovider *providers.LLMProvider,\n\trunningServices *services,\n\tmsgBus *bus.MessageBus,\n\tallowEmptyStartup bool,\n) error {\n\tdefer runningServices.reloading.Store(false)\n\treturn handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup)\n}\n\nfunc createStartupProvider(\n\tcfg *config.Config,\n\tallowEmptyStartup bool,\n) (providers.LLMProvider, string, error) {\n\tmodelName := cfg.Agents.Defaults.GetModelName()\n\tif modelName == \"\" && allowEmptyStartup {\n\t\treason := \"no default model configured; gateway started in limited mode\"\n\t\tfmt.Printf(\"⚠ Warning: %s\\n\", reason)\n\t\tlogger.WarnCF(\"gateway\", \"Gateway started without default model\", map[string]any{\n\t\t\t\"limited_mode\": true,\n\t\t})\n\t\treturn &startupBlockedProvider{reason: reason}, \"\", nil\n\t}\n\n\treturn providers.CreateProvider(cfg)\n}\n\nfunc setupAndStartServices(\n\tcfg *config.Config,\n\tagentLoop *agent.AgentLoop,\n\tmsgBus *bus.MessageBus,\n) (*services, error) {\n\trunningServices := &services{}\n\n\texecTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute\n\tvar err error\n\trunningServices.CronService, err = setupCronTool(\n\t\tagentLoop,\n\t\tmsgBus,\n\t\tcfg.WorkspacePath(),\n\t\tcfg.Agents.Defaults.RestrictToWorkspace,\n\t\texecTimeout,\n\t\tcfg,\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error setting up cron service: %w\", err)\n\t}\n\tif err = runningServices.CronService.Start(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error starting cron service: %w\", err)\n\t}\n\tfmt.Println(\"✓ Cron service started\")\n\n\trunningServices.HeartbeatService = heartbeat.NewHeartbeatService(\n\t\tcfg.WorkspacePath(),\n\t\tcfg.Heartbeat.Interval,\n\t\tcfg.Heartbeat.Enabled,\n\t)\n\trunningServices.HeartbeatService.SetBus(msgBus)\n\trunningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))\n\tif err = runningServices.HeartbeatService.Start(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error starting heartbeat service: %w\", err)\n\t}\n\tfmt.Println(\"✓ Heartbeat service started\")\n\n\trunningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{\n\t\tEnabled:  cfg.Tools.MediaCleanup.Enabled,\n\t\tMaxAge:   time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,\n\t\tInterval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,\n\t})\n\tif fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {\n\t\tfms.Start()\n\t}\n\n\trunningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)\n\tif err != nil {\n\t\tif fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {\n\t\t\tfms.Stop()\n\t\t}\n\t\treturn nil, fmt.Errorf(\"error creating channel manager: %w\", err)\n\t}\n\n\tagentLoop.SetChannelManager(runningServices.ChannelManager)\n\tagentLoop.SetMediaStore(runningServices.MediaStore)\n\n\tif transcriber := voice.DetectTranscriber(cfg); transcriber != nil {\n\t\tagentLoop.SetTranscriber(transcriber)\n\t\tlogger.InfoCF(\"voice\", \"Transcription enabled (agent-level)\", map[string]any{\"provider\": transcriber.Name()})\n\t}\n\n\tenabledChannels := runningServices.ChannelManager.GetEnabledChannels()\n\tif len(enabledChannels) > 0 {\n\t\tfmt.Printf(\"✓ Channels enabled: %s\\n\", enabledChannels)\n\t} else {\n\t\tfmt.Println(\"⚠ Warning: No channels enabled\")\n\t}\n\n\taddr := fmt.Sprintf(\"%s:%d\", cfg.Gateway.Host, cfg.Gateway.Port)\n\trunningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)\n\trunningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)\n\n\tif err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {\n\t\treturn nil, fmt.Errorf(\"error starting channels: %w\", err)\n\t}\n\n\tfmt.Printf(\n\t\t\"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\\n\",\n\t\tcfg.Gateway.Host,\n\t\tcfg.Gateway.Port,\n\t)\n\n\tstateManager := state.NewManager(cfg.WorkspacePath())\n\trunningServices.DeviceService = devices.NewService(devices.Config{\n\t\tEnabled:    cfg.Devices.Enabled,\n\t\tMonitorUSB: cfg.Devices.MonitorUSB,\n\t}, stateManager)\n\trunningServices.DeviceService.SetBus(msgBus)\n\tif err = runningServices.DeviceService.Start(context.Background()); err != nil {\n\t\tlogger.ErrorCF(\"device\", \"Error starting device service\", map[string]any{\"error\": err.Error()})\n\t} else if cfg.Devices.Enabled {\n\t\tfmt.Println(\"✓ Device event service started\")\n\t}\n\n\treturn runningServices, nil\n}\n\nfunc stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) {\n\tshutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)\n\tdefer shutdownCancel()\n\n\t// reload should not stop channel manager\n\tif !isReload && runningServices.ChannelManager != nil {\n\t\trunningServices.ChannelManager.StopAll(shutdownCtx)\n\t}\n\tif runningServices.DeviceService != nil {\n\t\trunningServices.DeviceService.Stop()\n\t}\n\tif runningServices.HeartbeatService != nil {\n\t\trunningServices.HeartbeatService.Stop()\n\t}\n\tif runningServices.CronService != nil {\n\t\trunningServices.CronService.Stop()\n\t}\n\tif runningServices.MediaStore != nil {\n\t\tif fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {\n\t\t\tfms.Stop()\n\t\t}\n\t}\n}\n\nfunc shutdownGateway(\n\trunningServices *services,\n\tagentLoop *agent.AgentLoop,\n\tprovider providers.LLMProvider,\n\tfullShutdown bool,\n) {\n\tif cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {\n\t\tcp.Close()\n\t}\n\n\tstopAndCleanupServices(runningServices, gracefulShutdownTimeout, false)\n\n\tagentLoop.Stop()\n\tagentLoop.Close()\n\n\tlogger.Info(\"✓ Gateway stopped\")\n}\n\nfunc handleConfigReload(\n\tctx context.Context,\n\tal *agent.AgentLoop,\n\tnewCfg *config.Config,\n\tproviderRef *providers.LLMProvider,\n\trunningServices *services,\n\tmsgBus *bus.MessageBus,\n\tallowEmptyStartup bool,\n) error {\n\tlogger.Info(\"🔄 Config file changed, reloading...\")\n\n\tnewModel := newCfg.Agents.Defaults.ModelName\n\tif newModel == \"\" {\n\t\tnewModel = newCfg.Agents.Defaults.Model\n\t}\n\n\tlogger.Infof(\" New model is '%s', recreating provider...\", newModel)\n\n\tlogger.Info(\"  Stopping all services...\")\n\tstopAndCleanupServices(runningServices, serviceShutdownTimeout, true)\n\n\tnewProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)\n\tif err != nil {\n\t\tlogger.Errorf(\"  ⚠ Error creating new provider: %v\", err)\n\t\tlogger.Warn(\"  Attempting to restart services with old provider and config...\")\n\t\tif restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {\n\t\t\tlogger.Errorf(\"  ⚠ Failed to restart services: %v\", restartErr)\n\t\t}\n\t\treturn fmt.Errorf(\"error creating new provider: %w\", err)\n\t}\n\n\tif newModelID != \"\" {\n\t\tnewCfg.Agents.Defaults.ModelName = newModelID\n\t}\n\n\treloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)\n\tdefer reloadCancel()\n\n\tif err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {\n\t\tlogger.Errorf(\"  ⚠ Error reloading agent loop: %v\", err)\n\t\tif cp, ok := newProvider.(providers.StatefulProvider); ok {\n\t\t\tcp.Close()\n\t\t}\n\t\tlogger.Warn(\"  Attempting to restart services with old provider and config...\")\n\t\tif restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {\n\t\t\tlogger.Errorf(\"  ⚠ Failed to restart services: %v\", restartErr)\n\t\t}\n\t\treturn fmt.Errorf(\"error reloading agent loop: %w\", err)\n\t}\n\n\t*providerRef = newProvider\n\n\tlogger.Info(\"  Restarting all services with new configuration...\")\n\tif err := restartServices(al, runningServices, msgBus); err != nil {\n\t\tlogger.Errorf(\"  ⚠ Error restarting services: %v\", err)\n\t\treturn fmt.Errorf(\"error restarting services: %w\", err)\n\t}\n\n\tlogger.Info(\"  ✓ Provider, configuration, and services reloaded successfully (thread-safe)\")\n\treturn nil\n}\n\nfunc restartServices(\n\tal *agent.AgentLoop,\n\trunningServices *services,\n\tmsgBus *bus.MessageBus,\n) error {\n\tcfg := al.GetConfig()\n\n\texecTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute\n\tvar err error\n\trunningServices.CronService, err = setupCronTool(\n\t\tal,\n\t\tmsgBus,\n\t\tcfg.WorkspacePath(),\n\t\tcfg.Agents.Defaults.RestrictToWorkspace,\n\t\texecTimeout,\n\t\tcfg,\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error restarting cron service: %w\", err)\n\t}\n\tif err = runningServices.CronService.Start(); err != nil {\n\t\treturn fmt.Errorf(\"error restarting cron service: %w\", err)\n\t}\n\tfmt.Println(\"  ✓ Cron service restarted\")\n\n\trunningServices.HeartbeatService = heartbeat.NewHeartbeatService(\n\t\tcfg.WorkspacePath(),\n\t\tcfg.Heartbeat.Interval,\n\t\tcfg.Heartbeat.Enabled,\n\t)\n\trunningServices.HeartbeatService.SetBus(msgBus)\n\trunningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))\n\tif err = runningServices.HeartbeatService.Start(); err != nil {\n\t\treturn fmt.Errorf(\"error restarting heartbeat service: %w\", err)\n\t}\n\tfmt.Println(\"  ✓ Heartbeat service restarted\")\n\n\trunningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{\n\t\tEnabled:  cfg.Tools.MediaCleanup.Enabled,\n\t\tMaxAge:   time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,\n\t\tInterval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,\n\t})\n\tif fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {\n\t\tfms.Start()\n\t}\n\tal.SetMediaStore(runningServices.MediaStore)\n\n\trunningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error recreating channel manager: %w\", err)\n\t}\n\tal.SetChannelManager(runningServices.ChannelManager)\n\n\tenabledChannels := runningServices.ChannelManager.GetEnabledChannels()\n\tif len(enabledChannels) > 0 {\n\t\tfmt.Printf(\"  ✓ Channels enabled: %s\\n\", enabledChannels)\n\t} else {\n\t\tfmt.Println(\"  ⚠ Warning: No channels enabled\")\n\t}\n\n\taddr := fmt.Sprintf(\"%s:%d\", cfg.Gateway.Host, cfg.Gateway.Port)\n\t// Reuse existing HealthServer to preserve reloadFunc\n\tif runningServices.HealthServer == nil {\n\t\trunningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)\n\t}\n\trunningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)\n\n\tif err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {\n\t\treturn fmt.Errorf(\"error reload channels: %w\", err)\n\t}\n\tfmt.Println(\"  ✓ Channels restarted.\")\n\n\tstateManager := state.NewManager(cfg.WorkspacePath())\n\trunningServices.DeviceService = devices.NewService(devices.Config{\n\t\tEnabled:    cfg.Devices.Enabled,\n\t\tMonitorUSB: cfg.Devices.MonitorUSB,\n\t}, stateManager)\n\trunningServices.DeviceService.SetBus(msgBus)\n\tif err := runningServices.DeviceService.Start(context.Background()); err != nil {\n\t\tlogger.WarnCF(\"device\", \"Failed to restart device service\", map[string]any{\"error\": err.Error()})\n\t} else if cfg.Devices.Enabled {\n\t\tfmt.Println(\"  ✓ Device event service restarted\")\n\t}\n\n\ttranscriber := voice.DetectTranscriber(cfg)\n\tal.SetTranscriber(transcriber)\n\tif transcriber != nil {\n\t\tlogger.InfoCF(\"voice\", \"Transcription re-enabled (agent-level)\", map[string]any{\"provider\": transcriber.Name()})\n\t} else {\n\t\tlogger.InfoCF(\"voice\", \"Transcription disabled\", nil)\n\t}\n\n\treturn nil\n}\n\nfunc setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {\n\tconfigChan := make(chan *config.Config, 1)\n\tstop := make(chan struct{})\n\tvar wg sync.WaitGroup\n\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\n\t\tlastModTime := getFileModTime(configPath)\n\t\tlastSize := getFileSize(configPath)\n\n\t\tticker := time.NewTicker(2 * time.Second)\n\t\tdefer ticker.Stop()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ticker.C:\n\t\t\t\tcurrentModTime := getFileModTime(configPath)\n\t\t\t\tcurrentSize := getFileSize(configPath)\n\n\t\t\t\tif currentModTime.After(lastModTime) || currentSize != lastSize {\n\t\t\t\t\tif debug {\n\t\t\t\t\t\tlogger.Debugf(\"🔍 Config file change detected\")\n\t\t\t\t\t}\n\n\t\t\t\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\t\t\t\tlastModTime = currentModTime\n\t\t\t\t\tlastSize = currentSize\n\n\t\t\t\t\tnewCfg, err := config.LoadConfig(configPath)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"⚠ Error loading new config: %v\", err)\n\t\t\t\t\t\tlogger.Warn(\"  Using previous valid config\")\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif err := newCfg.ValidateModelList(); err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"  ⚠ New config validation failed: %v\", err)\n\t\t\t\t\t\tlogger.Warn(\"  Using previous valid config\")\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tlogger.Info(\"✓ Config file validated and loaded\")\n\n\t\t\t\t\tselect {\n\t\t\t\t\tcase configChan <- newCfg:\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tlogger.Warn(\"⚠ Previous config reload still in progress, skipping\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase <-stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tstopFunc := func() {\n\t\tclose(stop)\n\t\twg.Wait()\n\t}\n\n\treturn configChan, stopFunc\n}\n\nfunc getFileModTime(path string) time.Time {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn time.Time{}\n\t}\n\treturn info.ModTime()\n}\n\nfunc getFileSize(path string) int64 {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn info.Size()\n}\n\nfunc setupCronTool(\n\tagentLoop *agent.AgentLoop,\n\tmsgBus *bus.MessageBus,\n\tworkspace string,\n\trestrict bool,\n\texecTimeout time.Duration,\n\tcfg *config.Config,\n) (*cron.CronService, error) {\n\tcronStorePath := filepath.Join(workspace, \"cron\", \"jobs.json\")\n\n\tcronService := cron.NewCronService(cronStorePath, nil)\n\n\tvar cronTool *tools.CronTool\n\tif cfg.Tools.IsToolEnabled(\"cron\") {\n\t\tvar err error\n\t\tcronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"critical error during CronTool initialization: %w\", err)\n\t\t}\n\n\t\tagentLoop.RegisterTool(cronTool)\n\t}\n\n\tif cronTool != nil {\n\t\tcronService.SetOnJob(func(job *cron.CronJob) (string, error) {\n\t\t\tresult := cronTool.ExecuteJob(context.Background(), job)\n\t\t\treturn result, nil\n\t\t})\n\t}\n\n\treturn cronService, nil\n}\n\nfunc createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {\n\treturn func(prompt, channel, chatID string) *tools.ToolResult {\n\t\tif channel == \"\" || chatID == \"\" {\n\t\t\tchannel, chatID = \"cli\", \"direct\"\n\t\t}\n\n\t\tresponse, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)\n\t\tif err != nil {\n\t\t\treturn tools.ErrorResult(fmt.Sprintf(\"Heartbeat error: %v\", err))\n\t\t}\n\t\tif response == \"HEARTBEAT_OK\" {\n\t\t\treturn tools.SilentResult(\"Heartbeat OK\")\n\t\t}\n\t\treturn tools.SilentResult(response)\n\t}\n}\n"
  },
  {
    "path": "pkg/health/server.go",
    "content": "package health\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\t\"net/http\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype Server struct {\n\tserver     *http.Server\n\tmu         sync.RWMutex\n\tready      bool\n\tchecks     map[string]Check\n\tstartTime  time.Time\n\treloadFunc func() error\n}\n\ntype Check struct {\n\tName      string    `json:\"name\"`\n\tStatus    string    `json:\"status\"`\n\tMessage   string    `json:\"message,omitempty\"`\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\ntype StatusResponse struct {\n\tStatus string           `json:\"status\"`\n\tUptime string           `json:\"uptime\"`\n\tChecks map[string]Check `json:\"checks,omitempty\"`\n\tPid    int              `json:\"pid\"`\n}\n\nfunc NewServer(host string, port int) *Server {\n\tmux := http.NewServeMux()\n\ts := &Server{\n\t\tready:     false,\n\t\tchecks:    make(map[string]Check),\n\t\tstartTime: time.Now(),\n\t}\n\n\tmux.HandleFunc(\"/health\", s.healthHandler)\n\tmux.HandleFunc(\"/ready\", s.readyHandler)\n\tmux.HandleFunc(\"/reload\", s.reloadHandler)\n\n\taddr := fmt.Sprintf(\"%s:%d\", host, port)\n\ts.server = &http.Server{\n\t\tAddr:         addr,\n\t\tHandler:      mux,\n\t\tReadTimeout:  5 * time.Second,\n\t\tWriteTimeout: 5 * time.Second,\n\t}\n\n\treturn s\n}\n\nfunc (s *Server) Start() error {\n\ts.mu.Lock()\n\ts.ready = true\n\ts.mu.Unlock()\n\treturn s.server.ListenAndServe()\n}\n\nfunc (s *Server) StartContext(ctx context.Context) error {\n\ts.mu.Lock()\n\ts.ready = true\n\ts.mu.Unlock()\n\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- s.server.ListenAndServe()\n\t}()\n\n\tselect {\n\tcase err := <-errCh:\n\t\treturn err\n\tcase <-ctx.Done():\n\t\treturn s.server.Shutdown(context.Background())\n\t}\n}\n\nfunc (s *Server) Stop(ctx context.Context) error {\n\ts.mu.Lock()\n\ts.ready = false\n\ts.mu.Unlock()\n\treturn s.server.Shutdown(ctx)\n}\n\nfunc (s *Server) SetReady(ready bool) {\n\ts.mu.Lock()\n\ts.ready = ready\n\ts.mu.Unlock()\n}\n\nfunc (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tstatus, msg := checkFn()\n\ts.checks[name] = Check{\n\t\tName:      name,\n\t\tStatus:    statusString(status),\n\t\tMessage:   msg,\n\t\tTimestamp: time.Now(),\n\t}\n}\n\n// SetReloadFunc sets the callback function for config reload.\nfunc (s *Server) SetReloadFunc(fn func() error) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.reloadFunc = fn\n}\n\nfunc (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"error\": \"method not allowed, use POST\"})\n\t\treturn\n\t}\n\n\ts.mu.Lock()\n\treloadFunc := s.reloadFunc\n\ts.mu.Unlock()\n\n\tif reloadFunc == nil {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"error\": \"reload not configured\"})\n\t\treturn\n\t}\n\n\tif err := reloadFunc(); err != nil {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"error\": err.Error()})\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"reload triggered\"})\n}\n\nfunc (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\n\tuptime := time.Since(s.startTime)\n\tresp := StatusResponse{\n\t\tStatus: \"ok\",\n\t\tUptime: uptime.String(),\n\t\tPid:    os.Getpid(),\n\t}\n\n\tjson.NewEncoder(w).Encode(resp)\n}\n\nfunc (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\ts.mu.RLock()\n\tready := s.ready\n\tchecks := make(map[string]Check)\n\tmaps.Copy(checks, s.checks)\n\ts.mu.RUnlock()\n\n\tif !ready {\n\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\tjson.NewEncoder(w).Encode(StatusResponse{\n\t\t\tStatus: \"not ready\",\n\t\t\tChecks: checks,\n\t\t})\n\t\treturn\n\t}\n\n\tfor _, check := range checks {\n\t\tif check.Status == \"fail\" {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\tjson.NewEncoder(w).Encode(StatusResponse{\n\t\t\t\tStatus: \"not ready\",\n\t\t\t\tChecks: checks,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tw.WriteHeader(http.StatusOK)\n\tuptime := time.Since(s.startTime)\n\tjson.NewEncoder(w).Encode(StatusResponse{\n\t\tStatus: \"ready\",\n\t\tUptime: uptime.String(),\n\t\tChecks: checks,\n\t})\n}\n\n// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.\n// This allows the health endpoints to be served by a shared HTTP server.\nfunc (s *Server) RegisterOnMux(mux *http.ServeMux) {\n\tmux.HandleFunc(\"/health\", s.healthHandler)\n\tmux.HandleFunc(\"/ready\", s.readyHandler)\n\tmux.HandleFunc(\"/reload\", s.reloadHandler)\n}\n\nfunc statusString(ok bool) string {\n\tif ok {\n\t\treturn \"ok\"\n\t}\n\treturn \"fail\"\n}\n"
  },
  {
    "path": "pkg/heartbeat/service.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage heartbeat\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/state\"\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\nconst (\n\tminIntervalMinutes     = 5\n\tdefaultIntervalMinutes = 30\n)\n\n// HeartbeatHandler is the function type for handling heartbeat.\n// It returns a ToolResult that can indicate async operations.\n// channel and chatID are derived from the last active user channel.\ntype HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult\n\n// HeartbeatService manages periodic heartbeat checks\ntype HeartbeatService struct {\n\tworkspace string\n\tbus       *bus.MessageBus\n\tstate     *state.Manager\n\thandler   HeartbeatHandler\n\tinterval  time.Duration\n\tenabled   bool\n\tmu        sync.RWMutex\n\tstopChan  chan struct{}\n}\n\n// NewHeartbeatService creates a new heartbeat service\nfunc NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService {\n\t// Apply minimum interval\n\tif intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {\n\t\tintervalMinutes = minIntervalMinutes\n\t}\n\n\tif intervalMinutes == 0 {\n\t\tintervalMinutes = defaultIntervalMinutes\n\t}\n\n\treturn &HeartbeatService{\n\t\tworkspace: workspace,\n\t\tinterval:  time.Duration(intervalMinutes) * time.Minute,\n\t\tenabled:   enabled,\n\t\tstate:     state.NewManager(workspace),\n\t}\n}\n\n// SetBus sets the message bus for delivering heartbeat results.\nfunc (hs *HeartbeatService) SetBus(msgBus *bus.MessageBus) {\n\ths.mu.Lock()\n\tdefer hs.mu.Unlock()\n\ths.bus = msgBus\n}\n\n// SetHandler sets the heartbeat handler.\nfunc (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) {\n\ths.mu.Lock()\n\tdefer hs.mu.Unlock()\n\ths.handler = handler\n}\n\n// Start begins the heartbeat service\nfunc (hs *HeartbeatService) Start() error {\n\ths.mu.Lock()\n\tdefer hs.mu.Unlock()\n\n\tif hs.stopChan != nil {\n\t\tlogger.InfoC(\"heartbeat\", \"Heartbeat service already running\")\n\t\treturn nil\n\t}\n\n\tif !hs.enabled {\n\t\tlogger.InfoC(\"heartbeat\", \"Heartbeat service disabled\")\n\t\treturn nil\n\t}\n\n\ths.stopChan = make(chan struct{})\n\tgo hs.runLoop(hs.stopChan)\n\n\tlogger.InfoCF(\"heartbeat\", \"Heartbeat service started\", map[string]any{\n\t\t\"interval_minutes\": hs.interval.Minutes(),\n\t})\n\n\treturn nil\n}\n\n// Stop gracefully stops the heartbeat service\nfunc (hs *HeartbeatService) Stop() {\n\ths.mu.Lock()\n\tdefer hs.mu.Unlock()\n\n\tif hs.stopChan == nil {\n\t\treturn\n\t}\n\n\tlogger.InfoC(\"heartbeat\", \"Stopping heartbeat service\")\n\tclose(hs.stopChan)\n\ths.stopChan = nil\n}\n\n// IsRunning returns whether the service is running\nfunc (hs *HeartbeatService) IsRunning() bool {\n\ths.mu.RLock()\n\tdefer hs.mu.RUnlock()\n\treturn hs.stopChan != nil\n}\n\n// runLoop runs the heartbeat ticker\nfunc (hs *HeartbeatService) runLoop(stopChan chan struct{}) {\n\tticker := time.NewTicker(hs.interval)\n\tdefer ticker.Stop()\n\n\t// Run first heartbeat after initial delay\n\ttime.AfterFunc(time.Second, func() {\n\t\ths.executeHeartbeat()\n\t})\n\n\tfor {\n\t\tselect {\n\t\tcase <-stopChan:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\ths.executeHeartbeat()\n\t\t}\n\t}\n}\n\n// executeHeartbeat performs a single heartbeat check\nfunc (hs *HeartbeatService) executeHeartbeat() {\n\ths.mu.RLock()\n\tenabled := hs.enabled\n\thandler := hs.handler\n\tif !hs.enabled || hs.stopChan == nil {\n\t\ths.mu.RUnlock()\n\t\treturn\n\t}\n\ths.mu.RUnlock()\n\n\tif !enabled {\n\t\treturn\n\t}\n\n\tlogger.DebugC(\"heartbeat\", \"Executing heartbeat\")\n\n\tprompt := hs.buildPrompt()\n\tif prompt == \"\" {\n\t\tlogger.InfoC(\"heartbeat\", \"No heartbeat prompt (HEARTBEAT.md empty or missing)\")\n\t\treturn\n\t}\n\n\tif handler == nil {\n\t\ths.logErrorf(\"Heartbeat handler not configured\")\n\t\treturn\n\t}\n\n\t// Get last channel info for context\n\tlastChannel := hs.state.GetLastChannel()\n\tchannel, chatID := hs.parseLastChannel(lastChannel)\n\n\t// Debug log for channel resolution\n\ths.logInfof(\"Resolved channel: %s, chatID: %s (from lastChannel: %s)\", channel, chatID, lastChannel)\n\n\tresult := handler(prompt, channel, chatID)\n\n\tif result == nil {\n\t\ths.logInfof(\"Heartbeat handler returned nil result\")\n\t\treturn\n\t}\n\n\t// Handle different result types\n\tif result.IsError {\n\t\ths.logErrorf(\"Heartbeat error: %s\", result.ForLLM)\n\t\treturn\n\t}\n\n\tif result.Async {\n\t\ths.logInfof(\"Async task started: %s\", result.ForLLM)\n\t\tlogger.InfoCF(\"heartbeat\", \"Async heartbeat task started\",\n\t\t\tmap[string]any{\n\t\t\t\t\"message\": result.ForLLM,\n\t\t\t})\n\t\treturn\n\t}\n\n\t// Check if silent\n\tif result.Silent {\n\t\ths.logInfof(\"Heartbeat OK - silent\")\n\t\treturn\n\t}\n\n\t// Send result to user\n\tif result.ForUser != \"\" {\n\t\ths.sendResponse(result.ForUser)\n\t} else if result.ForLLM != \"\" {\n\t\ths.sendResponse(result.ForLLM)\n\t}\n\n\ths.logInfof(\"Heartbeat completed: %s\", result.ForLLM)\n}\n\n// buildPrompt builds the heartbeat prompt from HEARTBEAT.md\nfunc (hs *HeartbeatService) buildPrompt() string {\n\theartbeatPath := filepath.Join(hs.workspace, \"HEARTBEAT.md\")\n\n\tdata, err := os.ReadFile(heartbeatPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\ths.createDefaultHeartbeatTemplate()\n\t\t\treturn \"\"\n\t\t}\n\t\ths.logErrorf(\"Error reading HEARTBEAT.md: %v\", err)\n\t\treturn \"\"\n\t}\n\n\tcontent := string(data)\n\tif len(content) == 0 {\n\t\treturn \"\"\n\t}\n\n\tnow := time.Now().Format(\"2006-01-02 15:04:05\")\n\treturn fmt.Sprintf(`# Heartbeat Check\n\nCurrent time: %s\n\nYou are a proactive AI assistant. This is a scheduled heartbeat check.\nReview the following tasks and execute any necessary actions using available skills.\nIf there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK\n\n%s\n`, now, content)\n}\n\n// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file\nfunc (hs *HeartbeatService) createDefaultHeartbeatTemplate() {\n\theartbeatPath := filepath.Join(hs.workspace, \"HEARTBEAT.md\")\n\n\tdefaultContent := `# Heartbeat Check List\n\nThis file contains tasks for the heartbeat service to check periodically.\n\n## Examples\n\n- Check for unread messages\n- Review upcoming calendar events\n- Check device status (e.g., MaixCam)\n\n## Instructions\n\n- Execute ALL tasks listed below. Do NOT skip any task.\n- For simple tasks (e.g., report current time), respond directly.\n- For complex tasks that may take time, use the spawn tool to create a subagent.\n- The spawn tool is async - subagent results will be sent to the user automatically.\n- After spawning a subagent, CONTINUE to process remaining tasks.\n- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention.\n\n---\n\nAdd your heartbeat tasks below this line:\n`\n\n\tif err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {\n\t\ths.logErrorf(\"Failed to create default HEARTBEAT.md: %v\", err)\n\t} else {\n\t\ths.logInfof(\"Created default HEARTBEAT.md template\")\n\t}\n}\n\n// sendResponse sends the heartbeat response to the last channel\nfunc (hs *HeartbeatService) sendResponse(response string) {\n\ths.mu.RLock()\n\tmsgBus := hs.bus\n\ths.mu.RUnlock()\n\n\tif msgBus == nil {\n\t\ths.logInfof(\"No message bus configured, heartbeat result not sent\")\n\t\treturn\n\t}\n\n\t// Get last channel from state\n\tlastChannel := hs.state.GetLastChannel()\n\tif lastChannel == \"\" {\n\t\ths.logInfof(\"No last channel recorded, heartbeat result not sent\")\n\t\treturn\n\t}\n\n\tplatform, userID := hs.parseLastChannel(lastChannel)\n\n\t// Skip internal channels that can't receive messages\n\tif platform == \"\" || userID == \"\" {\n\t\treturn\n\t}\n\n\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer pubCancel()\n\tmsgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\tChannel: platform,\n\t\tChatID:  userID,\n\t\tContent: response,\n\t})\n\n\ths.logInfof(\"Heartbeat result sent to %s\", platform)\n}\n\n// parseLastChannel parses the last channel string into platform and userID.\n// Returns empty strings for invalid or internal channels.\nfunc (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {\n\tif lastChannel == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\n\t// Parse channel format: \"platform:user_id\" (e.g., \"telegram:123456\")\n\tparts := strings.SplitN(lastChannel, \":\", 2)\n\tif len(parts) != 2 || parts[0] == \"\" || parts[1] == \"\" {\n\t\ths.logErrorf(\"Invalid last channel format: %s\", lastChannel)\n\t\treturn \"\", \"\"\n\t}\n\n\tplatform, userID = parts[0], parts[1]\n\n\t// Skip internal channels\n\tif constants.IsInternalChannel(platform) {\n\t\ths.logInfof(\"Skipping internal channel: %s\", platform)\n\t\treturn \"\", \"\"\n\t}\n\n\treturn platform, userID\n}\n\n// logInfof logs an informational message to the heartbeat log\nfunc (hs *HeartbeatService) logInfof(format string, args ...any) {\n\ths.logf(\"INFO\", format, args...)\n}\n\n// logErrorf logs an error message to the heartbeat log\nfunc (hs *HeartbeatService) logErrorf(format string, args ...any) {\n\ths.logf(\"ERROR\", format, args...)\n}\n\n// logf writes a message to the heartbeat log file\nfunc (hs *HeartbeatService) logf(level, format string, args ...any) {\n\tlogFile := filepath.Join(hs.workspace, \"heartbeat.log\")\n\tf, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer f.Close()\n\n\ttimestamp := time.Now().Format(\"2006-01-02 15:04:05\")\n\tfmt.Fprintf(f, \"[%s] [%s] %s\\n\", timestamp, level, fmt.Sprintf(format, args...))\n}\n"
  },
  {
    "path": "pkg/heartbeat/service_test.go",
    "content": "package heartbeat\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/tools\"\n)\n\nfunc TestExecuteHeartbeat_Async(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 30, true)\n\ths.stopChan = make(chan struct{}) // Enable for testing\n\n\tasyncCalled := false\n\tasyncResult := &tools.ToolResult{\n\t\tForLLM:  \"Background task started\",\n\t\tForUser: \"Task started in background\",\n\t\tSilent:  false,\n\t\tIsError: false,\n\t\tAsync:   true,\n\t}\n\n\ths.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {\n\t\tasyncCalled = true\n\t\tif prompt == \"\" {\n\t\t\tt.Error(\"Expected non-empty prompt\")\n\t\t}\n\t\treturn asyncResult\n\t})\n\n\t// Create HEARTBEAT.md\n\tos.WriteFile(filepath.Join(tmpDir, \"HEARTBEAT.md\"), []byte(\"Test task\"), 0o644)\n\n\t// Execute heartbeat directly (internal method for testing)\n\ths.executeHeartbeat()\n\n\tif !asyncCalled {\n\t\tt.Error(\"Expected handler to be called\")\n\t}\n}\n\nfunc TestExecuteHeartbeat_ResultLogging(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tresult  *tools.ToolResult\n\t\twantLog string\n\t}{\n\t\t{\n\t\t\tname: \"error result\",\n\t\t\tresult: &tools.ToolResult{\n\t\t\t\tForLLM:  \"Heartbeat failed: connection error\",\n\t\t\t\tForUser: \"\",\n\t\t\t\tSilent:  false,\n\t\t\t\tIsError: true,\n\t\t\t\tAsync:   false,\n\t\t\t},\n\t\t\twantLog: \"error message\",\n\t\t},\n\t\t{\n\t\t\tname: \"silent result\",\n\t\t\tresult: &tools.ToolResult{\n\t\t\t\tForLLM:  \"Heartbeat completed successfully\",\n\t\t\t\tForUser: \"\",\n\t\t\t\tSilent:  true,\n\t\t\t\tIsError: false,\n\t\t\t\tAsync:   false,\n\t\t\t},\n\t\t\twantLog: \"completion message\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t\t\t}\n\t\t\tdefer os.RemoveAll(tmpDir)\n\n\t\t\ths := NewHeartbeatService(tmpDir, 30, true)\n\t\t\ths.stopChan = make(chan struct{}) // Enable for testing\n\n\t\t\ths.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {\n\t\t\t\treturn tt.result\n\t\t\t})\n\n\t\t\tos.WriteFile(filepath.Join(tmpDir, \"HEARTBEAT.md\"), []byte(\"Test task\"), 0o644)\n\t\t\ths.executeHeartbeat()\n\n\t\t\tlogFile := filepath.Join(tmpDir, \"heartbeat.log\")\n\t\t\tdata, err := os.ReadFile(logFile)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to read log file: %v\", err)\n\t\t\t}\n\t\t\tif string(data) == \"\" {\n\t\t\t\tt.Errorf(\"Expected log file to contain %s\", tt.wantLog)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHeartbeatService_StartStop(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 1, true)\n\n\terr = hs.Start()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to start heartbeat service: %v\", err)\n\t}\n\n\ths.Stop()\n\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nfunc TestHeartbeatService_Disabled(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 1, false)\n\n\tif hs.enabled != false {\n\t\tt.Error(\"Expected service to be disabled\")\n\t}\n\n\terr = hs.Start()\n\t_ = err // Disabled service returns nil\n}\n\nfunc TestExecuteHeartbeat_NilResult(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 30, true)\n\ths.stopChan = make(chan struct{}) // Enable for testing\n\n\ths.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {\n\t\treturn nil\n\t})\n\n\t// Create HEARTBEAT.md\n\tos.WriteFile(filepath.Join(tmpDir, \"HEARTBEAT.md\"), []byte(\"Test task\"), 0o644)\n\n\t// Should not panic with nil result\n\ths.executeHeartbeat()\n}\n\n// TestLogPath verifies heartbeat log is written to workspace directory\nfunc TestLogPath(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 30, true)\n\n\t// Write a log entry\n\ths.logf(\"INFO\", \"Test log entry\")\n\n\t// Verify log file exists at workspace root\n\texpectedLogPath := filepath.Join(tmpDir, \"heartbeat.log\")\n\tif _, err := os.Stat(expectedLogPath); os.IsNotExist(err) {\n\t\tt.Errorf(\"Expected log file at %s, but it doesn't exist\", expectedLogPath)\n\t}\n}\n\n// TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root\nfunc TestHeartbeatFilePath(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"heartbeat-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\ths := NewHeartbeatService(tmpDir, 30, true)\n\n\t// Trigger default template creation\n\ths.buildPrompt()\n\n\t// Verify HEARTBEAT.md exists at workspace root\n\texpectedPath := filepath.Join(tmpDir, \"HEARTBEAT.md\")\n\tif _, err := os.Stat(expectedPath); os.IsNotExist(err) {\n\t\tt.Errorf(\"Expected HEARTBEAT.md at %s, but it doesn't exist\", expectedPath)\n\t}\n}\n"
  },
  {
    "path": "pkg/identity/identity.go",
    "content": "// Package identity provides unified user identity utilities for PicoClaw.\n// It introduces a canonical \"platform:id\" format and matching logic\n// that is backward-compatible with all legacy allow-list formats.\npackage identity\n\nimport (\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n)\n\n// BuildCanonicalID constructs a canonical \"platform:id\" identifier.\n// Both platform and platformID are lowercased and trimmed.\nfunc BuildCanonicalID(platform, platformID string) string {\n\tp := strings.ToLower(strings.TrimSpace(platform))\n\tid := strings.TrimSpace(platformID)\n\tif p == \"\" || id == \"\" {\n\t\treturn \"\"\n\t}\n\treturn p + \":\" + id\n}\n\n// ParseCanonicalID splits a canonical ID (\"platform:id\") into its parts.\n// Returns ok=false if the input does not contain a colon separator.\nfunc ParseCanonicalID(canonical string) (platform, id string, ok bool) {\n\tcanonical = strings.TrimSpace(canonical)\n\tidx := strings.Index(canonical, \":\")\n\tif idx <= 0 || idx == len(canonical)-1 {\n\t\treturn \"\", \"\", false\n\t}\n\treturn canonical[:idx], canonical[idx+1:], true\n}\n\n// MatchAllowed checks whether the given sender matches a single allow-list entry.\n// It is backward-compatible with all legacy formats:\n//\n//   - \"123456\"              → matches sender.PlatformID\n//   - \"@alice\"              → matches sender.Username\n//   - \"123456|alice\"        → matches PlatformID or Username\n//   - \"telegram:123456\"     → exact match on sender.CanonicalID\nfunc MatchAllowed(sender bus.SenderInfo, allowed string) bool {\n\tallowed = strings.TrimSpace(allowed)\n\tif allowed == \"\" {\n\t\treturn false\n\t}\n\n\t// Try canonical match first: \"platform:id\" format\n\tif platform, id, ok := ParseCanonicalID(allowed); ok {\n\t\t// Only treat as canonical if the platform portion looks like a known platform name\n\t\t// (not a pure-numeric string, which could be a compound ID)\n\t\tif !isNumeric(platform) {\n\t\t\tcandidate := BuildCanonicalID(platform, id)\n\t\t\tif candidate != \"\" && sender.CanonicalID != \"\" {\n\t\t\t\treturn strings.EqualFold(sender.CanonicalID, candidate)\n\t\t\t}\n\t\t\t// If sender has no canonical ID, try matching platform + platformID\n\t\t\treturn strings.EqualFold(platform, sender.Platform) &&\n\t\t\t\tsender.PlatformID == id\n\t\t}\n\t}\n\n\t// Keep track of explicit username format\n\tisAtUsername := strings.HasPrefix(allowed, \"@\")\n\n\t// Strip leading \"@\" for username matching\n\ttrimmed := strings.TrimPrefix(allowed, \"@\")\n\n\t// Split compound \"id|username\" format\n\tallowedID := trimmed\n\tallowedUser := \"\"\n\tif idx := strings.Index(trimmed, \"|\"); idx > 0 {\n\t\tallowedID = trimmed[:idx]\n\t\tallowedUser = trimmed[idx+1:]\n\t}\n\n\t// Match against PlatformID\n\tif sender.PlatformID != \"\" && sender.PlatformID == allowedID {\n\t\treturn true\n\t}\n\n\t// Match against Username only when explicitly requested via \"@username\"\n\tif isAtUsername && sender.Username != \"\" && sender.Username == trimmed {\n\t\treturn true\n\t}\n\n\t// Match compound sender format against allowed parts\n\tif allowedUser != \"\" && sender.PlatformID != \"\" && sender.PlatformID == allowedID {\n\t\treturn true\n\t}\n\tif allowedUser != \"\" && sender.Username != \"\" && sender.Username == allowedUser {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// isNumeric returns true if s consists entirely of digits.\nfunc isNumeric(s string) bool {\n\tif s == \"\" {\n\t\treturn false\n\t}\n\tfor _, r := range s {\n\t\tif r < '0' || r > '9' {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pkg/identity/identity_test.go",
    "content": "package identity\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n)\n\nfunc TestBuildCanonicalID(t *testing.T) {\n\ttests := []struct {\n\t\tplatform   string\n\t\tplatformID string\n\t\twant       string\n\t}{\n\t\t{\"telegram\", \"123456\", \"telegram:123456\"},\n\t\t{\"Discord\", \"98765432\", \"discord:98765432\"},\n\t\t{\"SLACK\", \"U123ABC\", \"slack:U123ABC\"},\n\t\t{\"\", \"123\", \"\"},\n\t\t{\"telegram\", \"\", \"\"},\n\t\t{\"  telegram  \", \"  123  \", \"telegram:123\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := BuildCanonicalID(tt.platform, tt.platformID)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"BuildCanonicalID(%q, %q) = %q, want %q\",\n\t\t\t\ttt.platform, tt.platformID, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestParseCanonicalID(t *testing.T) {\n\ttests := []struct {\n\t\tinput        string\n\t\twantPlatform string\n\t\twantID       string\n\t\twantOk       bool\n\t}{\n\t\t{\"telegram:123456\", \"telegram\", \"123456\", true},\n\t\t{\"discord:98765432\", \"discord\", \"98765432\", true},\n\t\t{\"slack:U123ABC\", \"slack\", \"U123ABC\", true},\n\t\t{\"nocolon\", \"\", \"\", false},\n\t\t{\"\", \"\", \"\", false},\n\t\t{\":missing\", \"\", \"\", false},\n\t\t{\"missing:\", \"\", \"\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tplatform, id, ok := ParseCanonicalID(tt.input)\n\t\tif ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID {\n\t\t\tt.Errorf(\"ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)\",\n\t\t\t\ttt.input, platform, id, ok,\n\t\t\t\ttt.wantPlatform, tt.wantID, tt.wantOk)\n\t\t}\n\t}\n}\n\nfunc TestMatchAllowed(t *testing.T) {\n\ttelegramSender := bus.SenderInfo{\n\t\tPlatform:    \"telegram\",\n\t\tPlatformID:  \"123456\",\n\t\tCanonicalID: \"telegram:123456\",\n\t\tUsername:    \"alice\",\n\t\tDisplayName: \"Alice Smith\",\n\t}\n\n\tdiscordSender := bus.SenderInfo{\n\t\tPlatform:    \"discord\",\n\t\tPlatformID:  \"98765432\",\n\t\tCanonicalID: \"discord:98765432\",\n\t\tUsername:    \"bob\",\n\t\tDisplayName: \"bob#1234\",\n\t}\n\n\tnoCanonicalSender := bus.SenderInfo{\n\t\tPlatform:   \"telegram\",\n\t\tPlatformID: \"999\",\n\t\tUsername:   \"carol\",\n\t}\n\n\ttests := []struct {\n\t\tname    string\n\t\tsender  bus.SenderInfo\n\t\tallowed string\n\t\twant    bool\n\t}{\n\t\t// Pure numeric ID matching\n\t\t{\n\t\t\tname:    \"numeric ID matches PlatformID\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"123456\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"numeric ID does not match\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"654321\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Username matching\n\t\t{\n\t\t\tname:    \"@username matches Username\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"@alice\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"plain entry does not match username\",\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:   \"discord\",\n\t\t\t\tPlatformID: \"999999\",\n\t\t\t\tUsername:   \"123456\",\n\t\t\t},\n\t\t\tallowed: \"123456\",\n\t\t\twant:    false,\n\t\t},\n\t\t{\n\t\t\tname:    \"@username does not match\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"@bob\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Compound format \"id|username\"\n\t\t{\n\t\t\tname:    \"compound matches by ID\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"123456|alice\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"compound matches by username\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"999|alice\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"compound matches by ID when username differs\",\n\t\t\tsender: bus.SenderInfo{\n\t\t\t\tPlatform:   \"discord\",\n\t\t\t\tPlatformID: \"123456\",\n\t\t\t\tUsername:   \"not123456\",\n\t\t\t},\n\t\t\tallowed: \"123456|alice\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"compound does not match\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"654321|bob\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Canonical format \"platform:id\"\n\t\t{\n\t\t\tname:    \"canonical matches exactly\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"telegram:123456\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"canonical case-insensitive platform\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"Telegram:123456\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"canonical wrong platform\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"discord:123456\",\n\t\t\twant:    false,\n\t\t},\n\t\t{\n\t\t\tname:    \"canonical wrong ID\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"telegram:654321\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Cross-platform canonical\n\t\t{\n\t\t\tname:    \"discord canonical match\",\n\t\t\tsender:  discordSender,\n\t\t\tallowed: \"discord:98765432\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"telegram canonical does not match discord sender\",\n\t\t\tsender:  discordSender,\n\t\t\tallowed: \"telegram:98765432\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Sender without canonical ID\n\t\t{\n\t\t\tname:    \"canonical match falls back to platform+platformID\",\n\t\t\tsender:  noCanonicalSender,\n\t\t\tallowed: \"telegram:999\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"platform mismatch on fallback\",\n\t\t\tsender:  noCanonicalSender,\n\t\t\tallowed: \"discord:999\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Empty allowed string\n\t\t{\n\t\t\tname:    \"empty allowed never matches\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"\",\n\t\t\twant:    false,\n\t\t},\n\t\t// Whitespace handling\n\t\t{\n\t\t\tname:    \"trimmed allowed matches\",\n\t\t\tsender:  telegramSender,\n\t\t\tallowed: \"  123456  \",\n\t\t\twant:    true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := MatchAllowed(tt.sender, tt.allowed)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"MatchAllowed(%+v, %q) = %v, want %v\",\n\t\t\t\t\ttt.sender, tt.allowed, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsNumeric(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  bool\n\t}{\n\t\t{\"123456\", true},\n\t\t{\"0\", true},\n\t\t{\"\", false},\n\t\t{\"abc\", false},\n\t\t{\"12a34\", false},\n\t\t{\"telegram\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := isNumeric(tt.input)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"isNumeric(%q) = %v, want %v\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/rs/zerolog\"\n)\n\ntype LogLevel = zerolog.Level\n\nconst (\n\tDEBUG = zerolog.DebugLevel\n\tINFO  = zerolog.InfoLevel\n\tWARN  = zerolog.WarnLevel\n\tERROR = zerolog.ErrorLevel\n\tFATAL = zerolog.FatalLevel\n)\n\nvar (\n\tlogLevelNames = map[LogLevel]string{\n\t\tDEBUG: \"DEBUG\",\n\t\tINFO:  \"INFO\",\n\t\tWARN:  \"WARN\",\n\t\tERROR: \"ERROR\",\n\t\tFATAL: \"FATAL\",\n\t}\n\n\tcurrentLevel = INFO\n\tlogger       zerolog.Logger\n\tfileLogger   zerolog.Logger\n\tlogFile      *os.File\n\tonce         sync.Once\n\tmu           sync.RWMutex\n)\n\nfunc init() {\n\tonce.Do(func() {\n\t\tzerolog.SetGlobalLevel(zerolog.InfoLevel)\n\n\t\tconsoleWriter := zerolog.ConsoleWriter{\n\t\t\tOut:        os.Stdout,\n\t\t\tTimeFormat: \"15:04:05\", // TODO: make it configurable???\n\n\t\t\t// Custom formatter to handle multiline strings and JSON objects\n\t\t\tFormatFieldValue: formatFieldValue,\n\t\t}\n\n\t\tlogger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger()\n\t\tfileLogger = zerolog.Logger{}\n\t})\n}\n\nfunc formatFieldValue(i any) string {\n\tvar s string\n\n\tswitch val := i.(type) {\n\tcase string:\n\t\ts = val\n\tcase []byte:\n\t\ts = string(val)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%v\", i)\n\t}\n\n\tif unquoted, err := strconv.Unquote(s); err == nil {\n\t\ts = unquoted\n\t}\n\n\tif strings.Contains(s, \"\\n\") {\n\t\treturn fmt.Sprintf(\"\\n%s\", s)\n\t}\n\n\tif strings.Contains(s, \" \") {\n\t\tif (strings.HasPrefix(s, \"{\") && strings.HasSuffix(s, \"}\")) ||\n\t\t\t(strings.HasPrefix(s, \"[\") && strings.HasSuffix(s, \"]\")) {\n\t\t\treturn s\n\t\t}\n\t\treturn fmt.Sprintf(\"%q\", s)\n\t}\n\n\treturn s\n}\n\nfunc SetLevel(level LogLevel) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tcurrentLevel = level\n\tzerolog.SetGlobalLevel(level)\n}\n\nfunc SetConsoleLevel(level LogLevel) {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\tlogger = logger.Level(level)\n}\n\nfunc GetLevel() LogLevel {\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\treturn currentLevel\n}\n\nfunc EnableFileLogging(filePath string) error {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\tif err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create log directory: %w\", err)\n\t}\n\n\tnewFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open log file: %w\", err)\n\t}\n\n\t// Close old file if exists\n\tif logFile != nil {\n\t\tlogFile.Close()\n\t}\n\n\tlogFile = newFile\n\tfileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()\n\treturn nil\n}\n\nfunc DisableFileLogging() {\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\tif logFile != nil {\n\t\tlogFile.Close()\n\t\tlogFile = nil\n\t}\n\tfileLogger = zerolog.Logger{}\n}\n\nfunc getCallerSkip() int {\n\tfor i := 2; i < 15; i++ {\n\t\tpc, file, _, ok := runtime.Caller(i)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tfn := runtime.FuncForPC(pc)\n\t\tif fn == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// bypass common loggers\n\t\tif strings.HasSuffix(file, \"/logger.go\") ||\n\t\t\tstrings.HasSuffix(file, \"/logger_3rd_party.go\") ||\n\t\t\tstrings.HasSuffix(file, \"/log.go\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tfuncName := fn.Name()\n\t\tif strings.HasPrefix(funcName, \"runtime.\") {\n\t\t\tcontinue\n\t\t}\n\n\t\treturn i - 1\n\t}\n\n\treturn 3\n}\n\n//nolint:zerologlint\nfunc getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {\n\tswitch level {\n\tcase zerolog.DebugLevel:\n\t\treturn logger.Debug()\n\tcase zerolog.InfoLevel:\n\t\treturn logger.Info()\n\tcase zerolog.WarnLevel:\n\t\treturn logger.Warn()\n\tcase zerolog.ErrorLevel:\n\t\treturn logger.Error()\n\tcase zerolog.FatalLevel:\n\t\treturn logger.Fatal()\n\tdefault:\n\t\treturn logger.Info()\n\t}\n}\n\nfunc logMessage(level LogLevel, component string, message string, fields map[string]any) {\n\tif level < currentLevel {\n\t\treturn\n\t}\n\n\tskip := getCallerSkip()\n\n\tevent := getEvent(logger, level)\n\n\tif component != \"\" {\n\t\tevent.Str(\"component\", component)\n\t}\n\n\tappendFields(event, fields)\n\tevent.CallerSkipFrame(skip).Msg(message)\n\n\t// Also log to file if enabled\n\tif fileLogger.GetLevel() != zerolog.NoLevel {\n\t\tfileEvent := getEvent(fileLogger, level)\n\n\t\tif component != \"\" {\n\t\t\tfileEvent.Str(\"component\", component)\n\t\t}\n\t\t// fileEvent.Str(\"caller\", fmt.Sprintf(\"%s:%d (%s)\", callerFile, callerLine, callerFunc))\n\n\t\tappendFields(fileEvent, fields)\n\t\tfileEvent.CallerSkipFrame(skip).Msg(message)\n\t}\n\n\tif level == FATAL {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc appendFields(event *zerolog.Event, fields map[string]any) {\n\tfor k, v := range fields {\n\t\t// Type switch to avoid double JSON serialization of strings\n\t\tswitch val := v.(type) {\n\t\tcase string:\n\t\t\tevent.Str(k, val)\n\t\tcase int:\n\t\t\tevent.Int(k, val)\n\t\tcase int64:\n\t\t\tevent.Int64(k, val)\n\t\tcase float64:\n\t\t\tevent.Float64(k, val)\n\t\tcase bool:\n\t\t\tevent.Bool(k, val)\n\t\tdefault:\n\t\t\tevent.Interface(k, v) // Fallback for struct, slice and maps\n\t\t}\n\t}\n}\n\nfunc Debug(message string) {\n\tlogMessage(DEBUG, \"\", message, nil)\n}\n\nfunc DebugC(component string, message string) {\n\tlogMessage(DEBUG, component, message, nil)\n}\n\nfunc Debugf(message string, ss ...any) {\n\tlogMessage(DEBUG, \"\", fmt.Sprintf(message, ss...), nil)\n}\n\nfunc DebugF(message string, fields map[string]any) {\n\tlogMessage(DEBUG, \"\", message, fields)\n}\n\nfunc DebugCF(component string, message string, fields map[string]any) {\n\tlogMessage(DEBUG, component, message, fields)\n}\n\nfunc Info(message string) {\n\tlogMessage(INFO, \"\", message, nil)\n}\n\nfunc InfoC(component string, message string) {\n\tlogMessage(INFO, component, message, nil)\n}\n\nfunc InfoF(message string, fields map[string]any) {\n\tlogMessage(INFO, \"\", message, fields)\n}\n\nfunc Infof(message string, ss ...any) {\n\tlogMessage(INFO, \"\", fmt.Sprintf(message, ss...), nil)\n}\n\nfunc InfoCF(component string, message string, fields map[string]any) {\n\tlogMessage(INFO, component, message, fields)\n}\n\nfunc Warn(message string) {\n\tlogMessage(WARN, \"\", message, nil)\n}\n\nfunc WarnC(component string, message string) {\n\tlogMessage(WARN, component, message, nil)\n}\n\nfunc WarnF(message string, fields map[string]any) {\n\tlogMessage(WARN, \"\", message, fields)\n}\n\nfunc WarnCF(component string, message string, fields map[string]any) {\n\tlogMessage(WARN, component, message, fields)\n}\n\nfunc Error(message string) {\n\tlogMessage(ERROR, \"\", message, nil)\n}\n\nfunc ErrorC(component string, message string) {\n\tlogMessage(ERROR, component, message, nil)\n}\n\nfunc Errorf(message string, ss ...any) {\n\tlogMessage(ERROR, \"\", fmt.Sprintf(message, ss...), nil)\n}\n\nfunc ErrorF(message string, fields map[string]any) {\n\tlogMessage(ERROR, \"\", message, fields)\n}\n\nfunc ErrorCF(component string, message string, fields map[string]any) {\n\tlogMessage(ERROR, component, message, fields)\n}\n\nfunc Fatal(message string) {\n\tlogMessage(FATAL, \"\", message, nil)\n}\n\nfunc FatalC(component string, message string) {\n\tlogMessage(FATAL, component, message, nil)\n}\n\nfunc Fatalf(message string, ss ...any) {\n\tlogMessage(FATAL, \"\", fmt.Sprintf(message, ss...), nil)\n}\n\nfunc FatalF(message string, fields map[string]any) {\n\tlogMessage(FATAL, \"\", message, fields)\n}\n\nfunc FatalCF(component string, message string, fields map[string]any) {\n\tlogMessage(FATAL, component, message, fields)\n}\n"
  },
  {
    "path": "pkg/logger/logger_3rd_party.go",
    "content": "// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project\n\npackage logger\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n)\n\n// botTokenRe matches the bot ID prefix and the secret part of a Telegram bot token.\n// Groups: 1 = \"bot<id>:\", 2 = first 4 chars of secret, 3 = middle, 4 = last 4 chars.\nvar botTokenRe = regexp.MustCompile(`(bot\\d+:)([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{12,}([A-Za-z0-9_-]{4})`)\n\n// maskSecrets replaces any embedded bot tokens in s with a redacted placeholder\n// that keeps the first and last 4 characters of the secret for identification.\nfunc maskSecrets(s string) string {\n\treturn botTokenRe.ReplaceAllString(s, \"${1}${2}****${3}\")\n}\n\n// Logger implements common Logger interface\ntype Logger struct {\n\tcomponent string\n\tlevels    map[int]LogLevel\n}\n\n// Debug logs debug messages\nfunc (b *Logger) Debug(v ...any) {\n\tlogMessage(DEBUG, b.component, maskSecrets(fmt.Sprint(v...)), nil)\n}\n\n// Info logs info messages\nfunc (b *Logger) Info(v ...any) {\n\tlogMessage(INFO, b.component, maskSecrets(fmt.Sprint(v...)), nil)\n}\n\n// Warn logs warning messages\nfunc (b *Logger) Warn(v ...any) {\n\tlogMessage(WARN, b.component, maskSecrets(fmt.Sprint(v...)), nil)\n}\n\n// Error logs error messages\nfunc (b *Logger) Error(v ...any) {\n\tlogMessage(ERROR, b.component, maskSecrets(fmt.Sprint(v...)), nil)\n}\n\n// Debugf logs formatted debug messages\nfunc (b *Logger) Debugf(format string, v ...any) {\n\tlogMessage(DEBUG, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Infof logs formatted info messages\nfunc (b *Logger) Infof(format string, v ...any) {\n\tlogMessage(INFO, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Warnf logs formatted warning messages\nfunc (b *Logger) Warnf(format string, v ...any) {\n\tlogMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Warningf logs formatted warning messages\nfunc (b *Logger) Warningf(format string, v ...any) {\n\tlogMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Errorf logs formatted error messages\nfunc (b *Logger) Errorf(format string, v ...any) {\n\tlogMessage(ERROR, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Fatalf logs formatted fatal messages and exits\nfunc (b *Logger) Fatalf(format string, v ...any) {\n\tlogMessage(FATAL, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)\n}\n\n// Log logs a message at a given level with caller information\n// the func name must be this because 3rd party loggers expect this\n// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL)\n// caller: unused parameter reserved for compatibility\n// format: format string\n// a: format arguments\n//\n//nolint:goprintffuncname\nfunc (b *Logger) Log(msgL, caller int, format string, a ...any) {\n\tlevel := LogLevel(msgL)\n\tif b.levels != nil {\n\t\tif lvl, ok := b.levels[msgL]; ok {\n\t\t\tlevel = lvl\n\t\t}\n\t}\n\tlogMessage(level, b.component, maskSecrets(fmt.Sprintf(format, a...)), nil)\n}\n\n// Sync flushes log buffer (no-op for this implementation)\nfunc (b *Logger) Sync() error {\n\treturn nil\n}\n\n// WithLevels sets log levels mapping for this logger\nfunc (b *Logger) WithLevels(levels map[int]LogLevel) *Logger {\n\tb.levels = levels\n\treturn b\n}\n\n// NewLogger creates a new logger instance with optional component name\nfunc NewLogger(component string) *Logger {\n\treturn &Logger{component: component}\n}\n"
  },
  {
    "path": "pkg/logger/logger_test.go",
    "content": "package logger\n\nimport (\n\t\"testing\"\n)\n\nfunc TestLogLevelFiltering(t *testing.T) {\n\tinitialLevel := GetLevel()\n\tdefer SetLevel(initialLevel)\n\n\tSetLevel(WARN)\n\n\ttests := []struct {\n\t\tname      string\n\t\tlevel     LogLevel\n\t\tshouldLog bool\n\t}{\n\t\t{\"DEBUG message\", DEBUG, false},\n\t\t{\"INFO message\", INFO, false},\n\t\t{\"WARN message\", WARN, true},\n\t\t{\"ERROR message\", ERROR, true},\n\t\t{\"FATAL message\", FATAL, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tswitch tt.level {\n\t\t\tcase DEBUG:\n\t\t\t\tDebug(tt.name)\n\t\t\tcase INFO:\n\t\t\t\tInfo(tt.name)\n\t\t\tcase WARN:\n\t\t\t\tWarn(tt.name)\n\t\t\tcase ERROR:\n\t\t\t\tError(tt.name)\n\t\t\tcase FATAL:\n\t\t\t\tif tt.shouldLog {\n\t\t\t\t\tt.Logf(\"FATAL test skipped to prevent program exit\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\tSetLevel(INFO)\n}\n\nfunc TestLoggerWithComponent(t *testing.T) {\n\tinitialLevel := GetLevel()\n\tdefer SetLevel(initialLevel)\n\n\tSetLevel(DEBUG)\n\n\ttests := []struct {\n\t\tname      string\n\t\tcomponent string\n\t\tmessage   string\n\t\tfields    map[string]any\n\t}{\n\t\t{\"Simple message\", \"test\", \"Hello, world!\", nil},\n\t\t{\"Message with component\", \"discord\", \"Discord message\", nil},\n\t\t{\"Message with fields\", \"telegram\", \"Telegram message\", map[string]any{\n\t\t\t\"user_id\": \"12345\",\n\t\t\t\"count\":   42,\n\t\t}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tswitch {\n\t\t\tcase tt.fields == nil && tt.component != \"\":\n\t\t\t\tInfoC(tt.component, tt.message)\n\t\t\tcase tt.fields != nil:\n\t\t\t\tInfoF(tt.message, tt.fields)\n\t\t\tdefault:\n\t\t\t\tInfo(tt.message)\n\t\t\t}\n\t\t})\n\t}\n\n\tSetLevel(INFO)\n}\n\nfunc TestLogLevels(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tlevel LogLevel\n\t\twant  string\n\t}{\n\t\t{\"DEBUG level\", DEBUG, \"DEBUG\"},\n\t\t{\"INFO level\", INFO, \"INFO\"},\n\t\t{\"WARN level\", WARN, \"WARN\"},\n\t\t{\"ERROR level\", ERROR, \"ERROR\"},\n\t\t{\"FATAL level\", FATAL, \"FATAL\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif logLevelNames[tt.level] != tt.want {\n\t\t\t\tt.Errorf(\"logLevelNames[%d] = %s, want %s\", tt.level, logLevelNames[tt.level], tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSetGetLevel(t *testing.T) {\n\tinitialLevel := GetLevel()\n\tdefer SetLevel(initialLevel)\n\n\ttests := []LogLevel{DEBUG, INFO, WARN, ERROR, FATAL}\n\n\tfor _, level := range tests {\n\t\tSetLevel(level)\n\t\tif GetLevel() != level {\n\t\t\tt.Errorf(\"SetLevel(%v) -> GetLevel() = %v, want %v\", level, GetLevel(), level)\n\t\t}\n\t}\n}\n\nfunc TestLoggerHelperFunctions(t *testing.T) {\n\tinitialLevel := GetLevel()\n\tdefer SetLevel(initialLevel)\n\n\tSetLevel(INFO)\n\n\tDebug(\"This should not log\")\n\tDebugf(\"this should not log\")\n\tInfo(\"This should log\")\n\tWarn(\"This should log\")\n\tError(\"This should log\")\n\n\tInfoC(\"test\", \"Component message\")\n\tInfoF(\"Fields message\", map[string]any{\"key\": \"value\"})\n\tInfof(\"test from %v\", \"Infof\")\n\n\tWarnC(\"test\", \"Warning with component\")\n\tErrorF(\"Error with fields\", map[string]any{\"error\": \"test\"})\n\tErrorf(\"test from %v\", \"Errorf\")\n\n\tSetLevel(DEBUG)\n\tDebugC(\"test\", \"Debug with component\")\n\tDebugf(\"test from %v\", \"Debugf\")\n\tWarnF(\"Warning with fields\", map[string]any{\"key\": \"value\"})\n}\n\nfunc TestFormatFieldValue(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    any\n\t\texpected string\n\t}{\n\t\t// Basic types test (default case of the switch)\n\t\t{\n\t\t\tname:     \"Integer Type\",\n\t\t\tinput:    42,\n\t\t\texpected: \"42\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Boolean Type\",\n\t\t\tinput:    true,\n\t\t\texpected: \"true\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Unsupported Struct Type\",\n\t\t\tinput:    struct{ A int }{A: 1},\n\t\t\texpected: \"{1}\",\n\t\t},\n\n\t\t// Simple strings and byte slices test\n\t\t{\n\t\t\tname:     \"Simple string without spaces\",\n\t\t\tinput:    \"simple_value\",\n\t\t\texpected: \"simple_value\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Simple byte slice\",\n\t\t\tinput:    []byte(\"byte_value\"),\n\t\t\texpected: \"byte_value\",\n\t\t},\n\n\t\t// Unquoting test (strconv.Unquote)\n\t\t{\n\t\t\tname:     \"Quoted string\",\n\t\t\tinput:    `\"quoted_value\"`,\n\t\t\texpected: \"quoted_value\",\n\t\t},\n\n\t\t// Strings with newline (\\n) test\n\t\t{\n\t\t\tname:     \"String with newline\",\n\t\t\tinput:    \"line1\\nline2\",\n\t\t\texpected: \"\\nline1\\nline2\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Quoted string with newline (Unquote -> newline)\",\n\t\t\tinput:    `\"line1\\nline2\"`, // Escaped \\n that Unquote will resolve\n\t\t\texpected: \"\\nline1\\nline2\",\n\t\t},\n\n\t\t// Strings with spaces test (which should be quoted)\n\t\t{\n\t\t\tname:     \"String with spaces\",\n\t\t\tinput:    \"hello world\",\n\t\t\texpected: `\"hello world\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"Quoted string with spaces (Unquote -> has spaces -> Re-quote)\",\n\t\t\tinput:    `\"hello world\"`,\n\t\t\texpected: `\"hello world\"`,\n\t\t},\n\n\t\t// JSON formats test (strings with spaces that start/end with brackets)\n\t\t{\n\t\t\tname:     \"Valid JSON object\",\n\t\t\tinput:    `{\"key\": \"value\"}`,\n\t\t\texpected: `{\"key\": \"value\"}`,\n\t\t},\n\t\t{\n\t\t\tname:     \"Valid JSON array\",\n\t\t\tinput:    `[1, 2, \"three\"]`,\n\t\t\texpected: `[1, 2, \"three\"]`,\n\t\t},\n\t\t{\n\t\t\tname:     \"Fake JSON (starts with { but doesn't end with })\",\n\t\t\tinput:    `{\"key\": \"value\"`, // Missing closing bracket, has spaces\n\t\t\texpected: `\"{\\\"key\\\": \\\"value\\\"\"`,\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty JSON (object)\",\n\t\t\tinput:    `{ }`,\n\t\t\texpected: `{ }`,\n\t\t},\n\n\t\t// 7. Edge Cases\n\t\t{\n\t\t\tname:     \"Empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Whitespace only string\",\n\t\t\tinput:    \"   \",\n\t\t\texpected: `\"   \"`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tactual := formatFieldValue(tt.input)\n\t\t\tif actual != tt.expected {\n\t\t\t\tt.Errorf(\"formatFieldValue() = %q, expected %q\", actual, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/mcp/manager.go",
    "content": "package mcp\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/modelcontextprotocol/go-sdk/mcp\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// headerTransport is an http.RoundTripper that adds custom headers to requests\ntype headerTransport struct {\n\tbase    http.RoundTripper\n\theaders map[string]string\n}\n\nfunc (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n\t// Clone the request to avoid modifying the original\n\treq = req.Clone(req.Context())\n\n\t// Add custom headers\n\tfor key, value := range t.headers {\n\t\treq.Header.Set(key, value)\n\t}\n\n\t// Use the base transport\n\tbase := t.base\n\tif base == nil {\n\t\tbase = http.DefaultTransport\n\t}\n\treturn base.RoundTrip(req)\n}\n\n// loadEnvFile loads environment variables from a file in .env format\n// Each line should be in the format: KEY=value\n// Lines starting with # are comments\n// Empty lines are ignored\nfunc loadEnvFile(path string) (map[string]string, error) {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open env file: %w\", err)\n\t}\n\tdefer file.Close()\n\n\tenvVars := make(map[string]string)\n\tscanner := bufio.NewScanner(file)\n\tlineNum := 0\n\n\tfor scanner.Scan() {\n\t\tlineNum++\n\t\tline := strings.TrimSpace(scanner.Text())\n\n\t\t// Skip empty lines and comments\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse KEY=value\n\t\tparts := strings.SplitN(line, \"=\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn nil, fmt.Errorf(\"invalid format at line %d: %s\", lineNum, line)\n\t\t}\n\n\t\tkey := strings.TrimSpace(parts[0])\n\t\tvalue := strings.TrimSpace(parts[1])\n\n\t\tif key == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"invalid format at line %d: empty key\", lineNum)\n\t\t}\n\n\t\t// Remove surrounding quotes if present\n\t\tif len(value) >= 2 {\n\t\t\tif (value[0] == '\"' && value[len(value)-1] == '\"') ||\n\t\t\t\t(value[0] == '\\'' && value[len(value)-1] == '\\'') {\n\t\t\t\tvalue = value[1 : len(value)-1]\n\t\t\t}\n\t\t}\n\n\t\tenvVars[key] = value\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading env file: %w\", err)\n\t}\n\n\treturn envVars, nil\n}\n\n// ServerConnection represents a connection to an MCP server\ntype ServerConnection struct {\n\tName    string\n\tClient  *mcp.Client\n\tSession *mcp.ClientSession\n\tTools   []*mcp.Tool\n}\n\n// Manager manages multiple MCP server connections\ntype Manager struct {\n\tservers map[string]*ServerConnection\n\tmu      sync.RWMutex\n\tclosed  atomic.Bool    // changed from bool to atomic.Bool to avoid TOCTOU race\n\twg      sync.WaitGroup // tracks in-flight CallTool calls\n}\n\n// NewManager creates a new MCP manager\nfunc NewManager() *Manager {\n\treturn &Manager{\n\t\tservers: make(map[string]*ServerConnection),\n\t}\n}\n\n// LoadFromConfig loads MCP servers from configuration\nfunc (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error {\n\treturn m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath())\n}\n\n// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path.\n// This is the minimal dependency version that doesn't require the full Config object.\nfunc (m *Manager) LoadFromMCPConfig(\n\tctx context.Context,\n\tmcpCfg config.MCPConfig,\n\tworkspacePath string,\n) error {\n\tif !mcpCfg.Enabled {\n\t\tlogger.InfoCF(\"mcp\", \"MCP integration is disabled\", nil)\n\t\treturn nil\n\t}\n\n\tif len(mcpCfg.Servers) == 0 {\n\t\tlogger.InfoCF(\"mcp\", \"No MCP servers configured\", nil)\n\t\treturn nil\n\t}\n\n\tlogger.InfoCF(\"mcp\", \"Initializing MCP servers\",\n\t\tmap[string]any{\n\t\t\t\"count\": len(mcpCfg.Servers),\n\t\t})\n\n\tvar wg sync.WaitGroup\n\terrs := make(chan error, len(mcpCfg.Servers))\n\tenabledCount := 0\n\n\tfor name, serverCfg := range mcpCfg.Servers {\n\t\tif !serverCfg.Enabled {\n\t\t\tlogger.DebugCF(\"mcp\", \"Skipping disabled server\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"server\": name,\n\t\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tenabledCount++\n\t\twg.Add(1)\n\t\tgo func(name string, serverCfg config.MCPServerConfig, workspace string) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// Resolve relative envFile paths relative to workspace\n\t\t\tif serverCfg.EnvFile != \"\" && !filepath.IsAbs(serverCfg.EnvFile) {\n\t\t\t\tif workspace == \"\" {\n\t\t\t\t\terr := fmt.Errorf(\n\t\t\t\t\t\t\"workspace path is empty while resolving relative envFile %q for server %s\",\n\t\t\t\t\t\tserverCfg.EnvFile,\n\t\t\t\t\t\tname,\n\t\t\t\t\t)\n\t\t\t\t\tlogger.ErrorCF(\"mcp\", \"Invalid MCP server configuration\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"server\":   name,\n\t\t\t\t\t\t\t\"env_file\": serverCfg.EnvFile,\n\t\t\t\t\t\t\t\"error\":    err.Error(),\n\t\t\t\t\t\t})\n\t\t\t\t\terrs <- err\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tserverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile)\n\t\t\t}\n\n\t\t\tif err := m.ConnectServer(ctx, name, serverCfg); err != nil {\n\t\t\t\tlogger.ErrorCF(\"mcp\", \"Failed to connect to MCP server\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"server\": name,\n\t\t\t\t\t\t\"error\":  err.Error(),\n\t\t\t\t\t})\n\t\t\t\terrs <- fmt.Errorf(\"failed to connect to server %s: %w\", name, err)\n\t\t\t}\n\t\t}(name, serverCfg, workspacePath)\n\t}\n\n\twg.Wait()\n\tclose(errs)\n\n\t// Collect errors\n\tvar allErrors []error\n\tfor err := range errs {\n\t\tallErrors = append(allErrors, err)\n\t}\n\n\tconnectedCount := len(m.GetServers())\n\n\t// If all enabled servers failed to connect, return aggregated error\n\tif enabledCount > 0 && connectedCount == 0 {\n\t\tlogger.ErrorCF(\"mcp\", \"All MCP servers failed to connect\",\n\t\t\tmap[string]any{\n\t\t\t\t\"failed\": len(allErrors),\n\t\t\t\t\"total\":  enabledCount,\n\t\t\t})\n\t\treturn errors.Join(allErrors...)\n\t}\n\n\tif len(allErrors) > 0 {\n\t\tlogger.WarnCF(\"mcp\", \"Some MCP servers failed to connect\",\n\t\t\tmap[string]any{\n\t\t\t\t\"failed\":    len(allErrors),\n\t\t\t\t\"connected\": connectedCount,\n\t\t\t\t\"total\":     enabledCount,\n\t\t\t})\n\t\t// Don't fail completely if some servers successfully connected\n\t}\n\n\tlogger.InfoCF(\"mcp\", \"MCP server initialization complete\",\n\t\tmap[string]any{\n\t\t\t\"connected\": connectedCount,\n\t\t\t\"total\":     enabledCount,\n\t\t})\n\n\treturn nil\n}\n\n// ConnectServer connects to a single MCP server\nfunc (m *Manager) ConnectServer(\n\tctx context.Context,\n\tname string,\n\tcfg config.MCPServerConfig,\n) error {\n\tlogger.InfoCF(\"mcp\", \"Connecting to MCP server\",\n\t\tmap[string]any{\n\t\t\t\"server\":     name,\n\t\t\t\"command\":    cfg.Command,\n\t\t\t\"args_count\": len(cfg.Args),\n\t\t})\n\n\t// Create client\n\tclient := mcp.NewClient(&mcp.Implementation{\n\t\tName:    \"picoclaw\",\n\t\tVersion: \"1.0.0\",\n\t}, nil)\n\n\t// Create transport based on configuration\n\t// Auto-detect transport type if not explicitly specified\n\tvar transport mcp.Transport\n\ttransportType := cfg.Type\n\n\t// Auto-detect: if URL is provided, use SSE; if command is provided, use stdio\n\tif transportType == \"\" {\n\t\tif cfg.URL != \"\" {\n\t\t\ttransportType = \"sse\"\n\t\t} else if cfg.Command != \"\" {\n\t\t\ttransportType = \"stdio\"\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"either URL or command must be provided\")\n\t\t}\n\t}\n\n\tswitch transportType {\n\tcase \"sse\", \"http\":\n\t\tif cfg.URL == \"\" {\n\t\t\treturn fmt.Errorf(\"URL is required for SSE/HTTP transport\")\n\t\t}\n\t\tlogger.DebugCF(\"mcp\", \"Using SSE/HTTP transport\",\n\t\t\tmap[string]any{\n\t\t\t\t\"server\": name,\n\t\t\t\t\"url\":    cfg.URL,\n\t\t\t})\n\n\t\tsseTransport := &mcp.StreamableClientTransport{\n\t\t\tEndpoint: cfg.URL,\n\t\t}\n\n\t\t// Add custom headers if provided\n\t\tif len(cfg.Headers) > 0 {\n\t\t\t// Create a custom HTTP client with header-injecting transport\n\t\t\tsseTransport.HTTPClient = &http.Client{\n\t\t\t\tTransport: &headerTransport{\n\t\t\t\t\tbase:    http.DefaultTransport,\n\t\t\t\t\theaders: cfg.Headers,\n\t\t\t\t},\n\t\t\t}\n\t\t\tlogger.DebugCF(\"mcp\", \"Added custom HTTP headers\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"server\":       name,\n\t\t\t\t\t\"header_count\": len(cfg.Headers),\n\t\t\t\t})\n\t\t}\n\n\t\ttransport = sseTransport\n\tcase \"stdio\":\n\t\tif cfg.Command == \"\" {\n\t\t\treturn fmt.Errorf(\"command is required for stdio transport\")\n\t\t}\n\t\tlogger.DebugCF(\"mcp\", \"Using stdio transport\",\n\t\t\tmap[string]any{\n\t\t\t\t\"server\":  name,\n\t\t\t\t\"command\": cfg.Command,\n\t\t\t})\n\t\t// Create command with context\n\t\tcmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)\n\n\t\t// Build environment variables with proper override semantics\n\t\t// Use a map to ensure config variables override file variables\n\t\tenvMap := make(map[string]string)\n\n\t\t// Start with parent process environment\n\t\tfor _, e := range cmd.Environ() {\n\t\t\tif idx := strings.Index(e, \"=\"); idx > 0 {\n\t\t\t\tenvMap[e[:idx]] = e[idx+1:]\n\t\t\t}\n\t\t}\n\n\t\t// Load environment variables from file if specified\n\t\tif cfg.EnvFile != \"\" {\n\t\t\tenvVars, err := loadEnvFile(cfg.EnvFile)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to load env file %s: %w\", cfg.EnvFile, err)\n\t\t\t}\n\t\t\tfor k, v := range envVars {\n\t\t\t\tenvMap[k] = v\n\t\t\t}\n\t\t\tlogger.DebugCF(\"mcp\", \"Loaded environment variables from file\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"server\":    name,\n\t\t\t\t\t\"envFile\":   cfg.EnvFile,\n\t\t\t\t\t\"var_count\": len(envVars),\n\t\t\t\t})\n\t\t}\n\n\t\t// Environment variables from config override those from file\n\t\tfor k, v := range cfg.Env {\n\t\t\tenvMap[k] = v\n\t\t}\n\n\t\t// Convert map to slice\n\t\tenv := make([]string, 0, len(envMap))\n\t\tfor k, v := range envMap {\n\t\t\tenv = append(env, fmt.Sprintf(\"%s=%s\", k, v))\n\t\t}\n\t\tcmd.Env = env\n\n\t\ttransport = &mcp.CommandTransport{Command: cmd}\n\tdefault:\n\t\treturn fmt.Errorf(\n\t\t\t\"unsupported transport type: %s (supported: stdio, sse, http)\",\n\t\t\ttransportType,\n\t\t)\n\t}\n\n\t// Connect to server\n\tsession, err := client.Connect(ctx, transport, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to connect: %w\", err)\n\t}\n\n\t// Get server info\n\tinitResult := session.InitializeResult()\n\tlogger.InfoCF(\"mcp\", \"Connected to MCP server\",\n\t\tmap[string]any{\n\t\t\t\"server\":        name,\n\t\t\t\"serverName\":    initResult.ServerInfo.Name,\n\t\t\t\"serverVersion\": initResult.ServerInfo.Version,\n\t\t\t\"protocol\":      initResult.ProtocolVersion,\n\t\t})\n\n\t// List available tools if supported\n\tvar tools []*mcp.Tool\n\tif initResult.Capabilities.Tools != nil {\n\t\tfor tool, err := range session.Tools(ctx, nil) {\n\t\t\tif err != nil {\n\t\t\t\tlogger.WarnCF(\"mcp\", \"Error listing tool\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"server\": name,\n\t\t\t\t\t\t\"error\":  err.Error(),\n\t\t\t\t\t})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttools = append(tools, tool)\n\t\t}\n\n\t\tlogger.InfoCF(\"mcp\", \"Listed tools from MCP server\",\n\t\t\tmap[string]any{\n\t\t\t\t\"server\":    name,\n\t\t\t\t\"toolCount\": len(tools),\n\t\t\t})\n\t}\n\n\t// Store connection\n\tm.mu.Lock()\n\tm.servers[name] = &ServerConnection{\n\t\tName:    name,\n\t\tClient:  client,\n\t\tSession: session,\n\t\tTools:   tools,\n\t}\n\tm.mu.Unlock()\n\n\treturn nil\n}\n\n// GetServers returns all connected servers\nfunc (m *Manager) GetServers() map[string]*ServerConnection {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make(map[string]*ServerConnection, len(m.servers))\n\tfor k, v := range m.servers {\n\t\tresult[k] = v\n\t}\n\treturn result\n}\n\n// GetServer returns a specific server connection\nfunc (m *Manager) GetServer(name string) (*ServerConnection, bool) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tconn, ok := m.servers[name]\n\treturn conn, ok\n}\n\n// CallTool calls a tool on a specific server\nfunc (m *Manager) CallTool(\n\tctx context.Context,\n\tserverName, toolName string,\n\targuments map[string]any,\n) (*mcp.CallToolResult, error) {\n\t// Check if closed before acquiring lock (fast path)\n\tif m.closed.Load() {\n\t\treturn nil, fmt.Errorf(\"manager is closed\")\n\t}\n\n\tm.mu.RLock()\n\t// Double-check after acquiring lock to prevent TOCTOU race\n\tif m.closed.Load() {\n\t\tm.mu.RUnlock()\n\t\treturn nil, fmt.Errorf(\"manager is closed\")\n\t}\n\tconn, ok := m.servers[serverName]\n\tif ok {\n\t\tm.wg.Add(1) // Add to WaitGroup while holding the lock\n\t}\n\tm.mu.RUnlock()\n\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"server %s not found\", serverName)\n\t}\n\tdefer m.wg.Done()\n\n\tparams := &mcp.CallToolParams{\n\t\tName:      toolName,\n\t\tArguments: arguments,\n\t}\n\n\tresult, err := conn.Session.CallTool(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to call tool: %w\", err)\n\t}\n\n\treturn result, nil\n}\n\n// Close closes all server connections\nfunc (m *Manager) Close() error {\n\t// Use Swap to atomically set closed=true and get the previous value\n\t// This prevents TOCTOU race with CallTool's closed check\n\tif m.closed.Swap(true) {\n\t\treturn nil // already closed\n\t}\n\n\t// Wait for all in-flight CallTool calls to finish before closing sessions\n\t// After closed=true is set, no new CallTool can start (they check closed first)\n\tm.wg.Wait()\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tlogger.InfoCF(\"mcp\", \"Closing all MCP server connections\",\n\t\tmap[string]any{\n\t\t\t\"count\": len(m.servers),\n\t\t})\n\n\tvar errs []error\n\tfor name, conn := range m.servers {\n\t\tif err := conn.Session.Close(); err != nil {\n\t\t\tlogger.ErrorCF(\"mcp\", \"Failed to close server connection\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"server\": name,\n\t\t\t\t\t\"error\":  err.Error(),\n\t\t\t\t})\n\t\t\terrs = append(errs, fmt.Errorf(\"server %s: %w\", name, err))\n\t\t}\n\t}\n\n\tm.servers = make(map[string]*ServerConnection)\n\n\tif len(errs) > 0 {\n\t\treturn fmt.Errorf(\"failed to close %d server(s): %w\", len(errs), errors.Join(errs...))\n\t}\n\n\treturn nil\n}\n\n// GetAllTools returns all tools from all connected servers\nfunc (m *Manager) GetAllTools() map[string][]*mcp.Tool {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make(map[string][]*mcp.Tool)\n\tfor name, conn := range m.servers {\n\t\tif len(conn.Tools) > 0 {\n\t\t\tresult[name] = conn.Tools\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "pkg/mcp/manager_test.go",
    "content": "package mcp\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\tsdkmcp \"github.com/modelcontextprotocol/go-sdk/mcp\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestLoadEnvFile(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tcontent   string\n\t\texpected  map[string]string\n\t\texpectErr bool\n\t}{\n\t\t{\n\t\t\tname: \"basic env file\",\n\t\t\tcontent: `API_KEY=secret123\nDATABASE_URL=postgres://localhost/db\nPORT=8080`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"API_KEY\":      \"secret123\",\n\t\t\t\t\"DATABASE_URL\": \"postgres://localhost/db\",\n\t\t\t\t\"PORT\":         \"8080\",\n\t\t\t},\n\t\t\texpectErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"with comments and empty lines\",\n\t\t\tcontent: `# This is a comment\nAPI_KEY=secret123\n\n# Another comment\nDATABASE_URL=postgres://localhost/db\n\nPORT=8080`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"API_KEY\":      \"secret123\",\n\t\t\t\t\"DATABASE_URL\": \"postgres://localhost/db\",\n\t\t\t\t\"PORT\":         \"8080\",\n\t\t\t},\n\t\t\texpectErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"with quoted values\",\n\t\t\tcontent: `API_KEY=\"secret with spaces\"\nNAME='single quoted'\nPLAIN=no-quotes`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"API_KEY\": \"secret with spaces\",\n\t\t\t\t\"NAME\":    \"single quoted\",\n\t\t\t\t\"PLAIN\":   \"no-quotes\",\n\t\t\t},\n\t\t\texpectErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"with spaces around equals\",\n\t\t\tcontent: `API_KEY = secret123\nDATABASE_URL= postgres://localhost/db\nPORT =8080`,\n\t\t\texpected: map[string]string{\n\t\t\t\t\"API_KEY\":      \"secret123\",\n\t\t\t\t\"DATABASE_URL\": \"postgres://localhost/db\",\n\t\t\t\t\"PORT\":         \"8080\",\n\t\t\t},\n\t\t\texpectErr: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"invalid format - no equals\",\n\t\t\tcontent:   `INVALID_LINE`,\n\t\t\texpectErr: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty file\",\n\t\t\tcontent:   ``,\n\t\t\texpected:  map[string]string{},\n\t\t\texpectErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"only comments\",\n\t\t\tcontent: `# Comment 1\n# Comment 2`,\n\t\t\texpected:  map[string]string{},\n\t\t\texpectErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tenvFile := filepath.Join(tmpDir, \".env\")\n\n\t\t\tif err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create test file: %v\", err)\n\t\t\t}\n\n\t\t\tresult, err := loadEnvFile(envFile)\n\n\t\t\tif tt.expectErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(result) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"Expected %d variables, got %d\", len(tt.expected), len(result))\n\t\t\t}\n\n\t\t\tfor key, expectedValue := range tt.expected {\n\t\t\t\tif actualValue, ok := result[key]; !ok {\n\t\t\t\t\tt.Errorf(\"Expected key %s not found\", key)\n\t\t\t\t} else if actualValue != expectedValue {\n\t\t\t\t\tt.Errorf(\"For key %s: expected %q, got %q\", key, expectedValue, actualValue)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLoadEnvFileNotFound(t *testing.T) {\n\t_, err := loadEnvFile(\"/nonexistent/file.env\")\n\tif err == nil {\n\t\tt.Error(\"Expected error for nonexistent file\")\n\t}\n}\n\nfunc TestEnvFilePriority(t *testing.T) {\n\t// Create a temporary .env file\n\ttmpDir := t.TempDir()\n\tenvFile := filepath.Join(tmpDir, \".env\")\n\n\tenvContent := `API_KEY=from_file\nDATABASE_URL=from_file\nSHARED_VAR=from_file`\n\n\tif err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil {\n\t\tt.Fatalf(\"Failed to create .env file: %v\", err)\n\t}\n\n\t// Load envFile\n\tenvVars, err := loadEnvFile(envFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to load env file: %v\", err)\n\t}\n\n\t// Verify envFile variables\n\tif envVars[\"API_KEY\"] != \"from_file\" {\n\t\tt.Errorf(\"Expected API_KEY=from_file, got %s\", envVars[\"API_KEY\"])\n\t}\n\n\t// Simulate config.Env overriding envFile\n\tconfigEnv := map[string]string{\n\t\t\"SHARED_VAR\": \"from_config\",\n\t\t\"NEW_VAR\":    \"from_config\",\n\t}\n\n\t// Merge: envFile first, then config overrides\n\tmerged := make(map[string]string)\n\tfor k, v := range envVars {\n\t\tmerged[k] = v\n\t}\n\tfor k, v := range configEnv {\n\t\tmerged[k] = v\n\t}\n\n\t// Verify priority: config.Env should override envFile\n\tif merged[\"SHARED_VAR\"] != \"from_config\" {\n\t\tt.Errorf(\n\t\t\t\"Expected SHARED_VAR=from_config (config should override file), got %s\",\n\t\t\tmerged[\"SHARED_VAR\"],\n\t\t)\n\t}\n\tif merged[\"API_KEY\"] != \"from_file\" {\n\t\tt.Errorf(\"Expected API_KEY=from_file, got %s\", merged[\"API_KEY\"])\n\t}\n\tif merged[\"NEW_VAR\"] != \"from_config\" {\n\t\tt.Errorf(\"Expected NEW_VAR=from_config, got %s\", merged[\"NEW_VAR\"])\n\t}\n}\n\nfunc TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {\n\tmgr := NewManager()\n\n\tmcpCfg := config.MCPConfig{\n\t\tToolConfig: config.ToolConfig{\n\t\t\tEnabled: true,\n\t\t},\n\t\tServers: map[string]config.MCPServerConfig{\n\t\t\t\"test-server\": {\n\t\t\t\tEnabled: true,\n\t\t\t\tCommand: \"echo\",\n\t\t\t\tArgs:    []string{\"ok\"},\n\t\t\t\tEnvFile: \".env\",\n\t\t\t},\n\t\t},\n\t}\n\n\terr := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for relative env_file with empty workspace path, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"workspace path is empty\") {\n\t\tt.Fatalf(\"expected workspace path validation error, got: %v\", err)\n\t}\n}\n\nfunc TestNewManager_InitialState(t *testing.T) {\n\tmgr := NewManager()\n\tif mgr == nil {\n\t\tt.Fatal(\"expected manager instance, got nil\")\n\t}\n\tif len(mgr.GetServers()) != 0 {\n\t\tt.Fatalf(\"expected no servers on new manager, got %d\", len(mgr.GetServers()))\n\t}\n}\n\nfunc TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {\n\tmgr := NewManager()\n\n\terr := mgr.LoadFromMCPConfig(\n\t\tcontext.Background(),\n\t\tconfig.MCPConfig{ToolConfig: config.ToolConfig{Enabled: false}},\n\t\t\"/tmp\",\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil error when MCP disabled, got: %v\", err)\n\t}\n\n\terr = mgr.LoadFromMCPConfig(\n\t\tcontext.Background(),\n\t\tconfig.MCPConfig{ToolConfig: config.ToolConfig{Enabled: true}},\n\t\t\"/tmp\",\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"expected nil error when no servers configured, got: %v\", err)\n\t}\n}\n\nfunc TestGetServers_ReturnsCopy(t *testing.T) {\n\tmgr := NewManager()\n\tmgr.servers[\"s1\"] = &ServerConnection{Name: \"s1\"}\n\n\tservers := mgr.GetServers()\n\tdelete(servers, \"s1\")\n\n\tif _, ok := mgr.GetServer(\"s1\"); !ok {\n\t\tt.Fatal(\"expected internal manager state to remain unchanged\")\n\t}\n}\n\nfunc TestGetAllTools_FiltersEmptyTools(t *testing.T) {\n\tmgr := NewManager()\n\tmgr.servers[\"empty\"] = &ServerConnection{Name: \"empty\", Tools: nil}\n\tmgr.servers[\"with-tools\"] = &ServerConnection{Name: \"with-tools\", Tools: []*sdkmcp.Tool{{}}}\n\n\tall := mgr.GetAllTools()\n\tif _, ok := all[\"empty\"]; ok {\n\t\tt.Fatal(\"expected server without tools to be excluded\")\n\t}\n\tif _, ok := all[\"with-tools\"]; !ok {\n\t\tt.Fatal(\"expected server with tools to be included\")\n\t}\n}\n\nfunc TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {\n\tt.Run(\"manager closed\", func(t *testing.T) {\n\t\tmgr := NewManager()\n\t\tmgr.closed.Store(true)\n\n\t\t_, err := mgr.CallTool(context.Background(), \"s1\", \"tool\", nil)\n\t\tif err == nil || !strings.Contains(err.Error(), \"manager is closed\") {\n\t\t\tt.Fatalf(\"expected manager closed error, got: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"server missing\", func(t *testing.T) {\n\t\tmgr := NewManager()\n\n\t\t_, err := mgr.CallTool(context.Background(), \"missing\", \"tool\", nil)\n\t\tif err == nil || !strings.Contains(err.Error(), \"not found\") {\n\t\t\tt.Fatalf(\"expected server not found error, got: %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestClose_IdempotentOnEmptyManager(t *testing.T) {\n\tmgr := NewManager()\n\n\tif err := mgr.Close(); err != nil {\n\t\tt.Fatalf(\"first close should succeed, got: %v\", err)\n\t}\n\tif err := mgr.Close(); err != nil {\n\t\tt.Fatalf(\"second close should be idempotent, got: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/media/store.go",
    "content": "package media\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// MediaMeta holds metadata about a stored media file.\ntype MediaMeta struct {\n\tFilename    string\n\tContentType string\n\tSource      string // \"telegram\", \"discord\", \"tool:image-gen\", etc.\n}\n\n// MediaStore manages the lifecycle of media files associated with processing scopes.\ntype MediaStore interface {\n\t// Store registers an existing local file under the given scope.\n\t// Returns a ref identifier (e.g. \"media://<id>\").\n\t// Store does not move or copy the file; it only records the mapping.\n\tStore(localPath string, meta MediaMeta, scope string) (ref string, err error)\n\n\t// Resolve returns the local file path for a given ref.\n\tResolve(ref string) (localPath string, err error)\n\n\t// ResolveWithMeta returns the local file path and metadata for a given ref.\n\tResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)\n\n\t// ReleaseAll deletes all files registered under the given scope\n\t// and removes the mapping entries. File-not-exist errors are ignored.\n\tReleaseAll(scope string) error\n}\n\n// mediaEntry holds the path and metadata for a stored media file.\ntype mediaEntry struct {\n\tpath     string\n\tmeta     MediaMeta\n\tstoredAt time.Time\n}\n\n// MediaCleanerConfig configures the background TTL cleanup.\ntype MediaCleanerConfig struct {\n\tEnabled  bool\n\tMaxAge   time.Duration\n\tInterval time.Duration\n}\n\n// FileMediaStore is a pure in-memory implementation of MediaStore.\n// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/).\ntype FileMediaStore struct {\n\tmu          sync.RWMutex\n\trefs        map[string]mediaEntry\n\tscopeToRefs map[string]map[string]struct{}\n\trefToScope  map[string]string\n\n\tcleanerCfg MediaCleanerConfig\n\tstop       chan struct{}\n\tstartOnce  sync.Once\n\tstopOnce   sync.Once\n\tnowFunc    func() time.Time // for testing\n}\n\n// NewFileMediaStore creates a new FileMediaStore without background cleanup.\nfunc NewFileMediaStore() *FileMediaStore {\n\treturn &FileMediaStore{\n\t\trefs:        make(map[string]mediaEntry),\n\t\tscopeToRefs: make(map[string]map[string]struct{}),\n\t\trefToScope:  make(map[string]string),\n\t\tnowFunc:     time.Now,\n\t}\n}\n\n// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup.\nfunc NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {\n\treturn &FileMediaStore{\n\t\trefs:        make(map[string]mediaEntry),\n\t\tscopeToRefs: make(map[string]map[string]struct{}),\n\t\trefToScope:  make(map[string]string),\n\t\tcleanerCfg:  cfg,\n\t\tstop:        make(chan struct{}),\n\t\tnowFunc:     time.Now,\n\t}\n}\n\n// Store registers a local file under the given scope. The file must exist.\nfunc (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) {\n\tif _, err := os.Stat(localPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"media store: %s: %w\", localPath, err)\n\t}\n\n\tref := \"media://\" + uuid.New().String()\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()}\n\tif s.scopeToRefs[scope] == nil {\n\t\ts.scopeToRefs[scope] = make(map[string]struct{})\n\t}\n\ts.scopeToRefs[scope][ref] = struct{}{}\n\ts.refToScope[ref] = scope\n\n\treturn ref, nil\n}\n\n// Resolve returns the local path for the given ref.\nfunc (s *FileMediaStore) Resolve(ref string) (string, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tentry, ok := s.refs[ref]\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"media store: unknown ref: %s\", ref)\n\t}\n\treturn entry.path, nil\n}\n\n// ResolveWithMeta returns the local path and metadata for the given ref.\nfunc (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\tentry, ok := s.refs[ref]\n\tif !ok {\n\t\treturn \"\", MediaMeta{}, fmt.Errorf(\"media store: unknown ref: %s\", ref)\n\t}\n\treturn entry.path, entry.meta, nil\n}\n\n// ReleaseAll removes all files under the given scope and cleans up mappings.\n// Phase 1 (under lock): remove entries from maps.\n// Phase 2 (no lock): delete files from disk.\nfunc (s *FileMediaStore) ReleaseAll(scope string) error {\n\t// Phase 1: collect paths and remove from maps under lock\n\tvar paths []string\n\n\ts.mu.Lock()\n\trefs, ok := s.scopeToRefs[scope]\n\tif !ok {\n\t\ts.mu.Unlock()\n\t\treturn nil\n\t}\n\n\tfor ref := range refs {\n\t\tif entry, exists := s.refs[ref]; exists {\n\t\t\tpaths = append(paths, entry.path)\n\t\t}\n\t\tdelete(s.refs, ref)\n\t\tdelete(s.refToScope, ref)\n\t}\n\tdelete(s.scopeToRefs, scope)\n\ts.mu.Unlock()\n\n\t// Phase 2: delete files without holding the lock\n\tfor _, p := range paths {\n\t\tif err := os.Remove(p); err != nil && !os.IsNotExist(err) {\n\t\t\tlogger.WarnCF(\"media\", \"release: failed to remove file\", map[string]any{\n\t\t\t\t\"path\":  p,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// CleanExpired removes all entries older than MaxAge.\n// Phase 1 (under lock): identify expired entries and remove from maps.\n// Phase 2 (no lock): delete files from disk to minimize lock contention.\nfunc (s *FileMediaStore) CleanExpired() int {\n\tif s.cleanerCfg.MaxAge <= 0 {\n\t\treturn 0\n\t}\n\n\t// Phase 1: collect expired entries under lock\n\ttype expiredEntry struct {\n\t\tref  string\n\t\tpath string\n\t}\n\n\ts.mu.Lock()\n\tcutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge)\n\tvar expired []expiredEntry\n\n\tfor ref, entry := range s.refs {\n\t\tif entry.storedAt.Before(cutoff) {\n\t\t\texpired = append(expired, expiredEntry{ref: ref, path: entry.path})\n\n\t\t\tif scope, ok := s.refToScope[ref]; ok {\n\t\t\t\tif scopeRefs, ok := s.scopeToRefs[scope]; ok {\n\t\t\t\t\tdelete(scopeRefs, ref)\n\t\t\t\t\tif len(scopeRefs) == 0 {\n\t\t\t\t\t\tdelete(s.scopeToRefs, scope)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tdelete(s.refs, ref)\n\t\t\tdelete(s.refToScope, ref)\n\t\t}\n\t}\n\ts.mu.Unlock()\n\n\t// Phase 2: delete files without holding the lock\n\tfor _, e := range expired {\n\t\tif err := os.Remove(e.path); err != nil && !os.IsNotExist(err) {\n\t\t\tlogger.WarnCF(\"media\", \"cleanup: failed to remove file\", map[string]any{\n\t\t\t\t\"path\":  e.path,\n\t\t\t\t\"error\": err.Error(),\n\t\t\t})\n\t\t}\n\t}\n\n\treturn len(expired)\n}\n\n// Start begins the background cleanup goroutine if cleanup is enabled.\n// Safe to call multiple times; only the first call starts the goroutine.\nfunc (s *FileMediaStore) Start() {\n\tif !s.cleanerCfg.Enabled || s.stop == nil {\n\t\treturn\n\t}\n\tif s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 {\n\t\tlogger.WarnCF(\"media\", \"cleanup: skipped due to invalid config\", map[string]any{\n\t\t\t\"interval\": s.cleanerCfg.Interval.String(),\n\t\t\t\"max_age\":  s.cleanerCfg.MaxAge.String(),\n\t\t})\n\t\treturn\n\t}\n\n\ts.startOnce.Do(func() {\n\t\tlogger.InfoCF(\"media\", \"cleanup enabled\", map[string]any{\n\t\t\t\"interval\": s.cleanerCfg.Interval.String(),\n\t\t\t\"max_age\":  s.cleanerCfg.MaxAge.String(),\n\t\t})\n\n\t\tgo func() {\n\t\t\tticker := time.NewTicker(s.cleanerCfg.Interval)\n\t\t\tdefer ticker.Stop()\n\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase <-ticker.C:\n\t\t\t\t\tif n := s.CleanExpired(); n > 0 {\n\t\t\t\t\t\tlogger.InfoCF(\"media\", \"cleanup: removed expired entries\", map[string]any{\n\t\t\t\t\t\t\t\"count\": n,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\tcase <-s.stop:\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t})\n}\n\n// Stop terminates the background cleanup goroutine.\n// Safe to call multiple times; only the first call closes the channel.\nfunc (s *FileMediaStore) Stop() {\n\tif s.stop == nil {\n\t\treturn\n\t}\n\ts.stopOnce.Do(func() {\n\t\tclose(s.stop)\n\t})\n}\n"
  },
  {
    "path": "pkg/media/store_test.go",
    "content": "package media\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc createTempFile(t *testing.T, dir, name string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\tif err := os.WriteFile(path, []byte(\"test content\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create temp file: %v\", err)\n\t}\n\treturn path\n}\n\nfunc TestStoreAndResolve(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tpath := createTempFile(t, dir, \"photo.jpg\")\n\n\tref, err := store.Store(path, MediaMeta{Filename: \"photo.jpg\", Source: \"telegram\"}, \"scope1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store failed: %v\", err)\n\t}\n\n\tif !strings.HasPrefix(ref, \"media://\") {\n\t\tt.Errorf(\"ref should start with media://, got %q\", ref)\n\t}\n\n\tresolved, err := store.Resolve(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve failed: %v\", err)\n\t}\n\tif resolved != path {\n\t\tt.Errorf(\"Resolve returned %q, want %q\", resolved, path)\n\t}\n}\n\nfunc TestReleaseAll(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tpaths := make([]string, 3)\n\trefs := make([]string, 3)\n\tfor i := range 3 {\n\t\tpaths[i] = createTempFile(t, dir, strings.Repeat(\"a\", i+1)+\".jpg\")\n\t\tvar err error\n\t\trefs[i], err = store.Store(paths[i], MediaMeta{Source: \"test\"}, \"scope1\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Store failed: %v\", err)\n\t\t}\n\t}\n\n\tif err := store.ReleaseAll(\"scope1\"); err != nil {\n\t\tt.Fatalf(\"ReleaseAll failed: %v\", err)\n\t}\n\n\t// Files should be deleted\n\tfor _, p := range paths {\n\t\tif _, err := os.Stat(p); !os.IsNotExist(err) {\n\t\t\tt.Errorf(\"file %q should have been deleted\", p)\n\t\t}\n\t}\n\n\t// Refs should be unresolvable\n\tfor _, ref := range refs {\n\t\tif _, err := store.Resolve(ref); err == nil {\n\t\t\tt.Errorf(\"Resolve(%q) should fail after ReleaseAll\", ref)\n\t\t}\n\t}\n}\n\nfunc TestMultiScopeIsolation(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tpathA := createTempFile(t, dir, \"fileA.jpg\")\n\tpathB := createTempFile(t, dir, \"fileB.jpg\")\n\n\trefA, _ := store.Store(pathA, MediaMeta{Source: \"test\"}, \"scopeA\")\n\trefB, _ := store.Store(pathB, MediaMeta{Source: \"test\"}, \"scopeB\")\n\n\t// Release only scopeA\n\tif err := store.ReleaseAll(\"scopeA\"); err != nil {\n\t\tt.Fatalf(\"ReleaseAll(scopeA) failed: %v\", err)\n\t}\n\n\t// scopeA file should be gone\n\tif _, err := os.Stat(pathA); !os.IsNotExist(err) {\n\t\tt.Error(\"file A should have been deleted\")\n\t}\n\tif _, err := store.Resolve(refA); err == nil {\n\t\tt.Error(\"refA should be unresolvable after release\")\n\t}\n\n\t// scopeB file should still exist\n\tif _, err := os.Stat(pathB); err != nil {\n\t\tt.Error(\"file B should still exist\")\n\t}\n\tresolved, err := store.Resolve(refB)\n\tif err != nil {\n\t\tt.Fatalf(\"refB should still resolve: %v\", err)\n\t}\n\tif resolved != pathB {\n\t\tt.Errorf(\"resolved %q, want %q\", resolved, pathB)\n\t}\n}\n\nfunc TestReleaseAllIdempotent(t *testing.T) {\n\tstore := NewFileMediaStore()\n\n\t// ReleaseAll on non-existent scope should not error\n\tif err := store.ReleaseAll(\"nonexistent\"); err != nil {\n\t\tt.Fatalf(\"ReleaseAll on empty scope should not error: %v\", err)\n\t}\n\n\t// Create and release, then release again\n\tdir := t.TempDir()\n\tpath := createTempFile(t, dir, \"file.jpg\")\n\t_, _ = store.Store(path, MediaMeta{Source: \"test\"}, \"scope1\")\n\n\tif err := store.ReleaseAll(\"scope1\"); err != nil {\n\t\tt.Fatalf(\"first ReleaseAll failed: %v\", err)\n\t}\n\tif err := store.ReleaseAll(\"scope1\"); err != nil {\n\t\tt.Fatalf(\"second ReleaseAll should not error: %v\", err)\n\t}\n}\n\nfunc TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tpath := createTempFile(t, dir, \"file.jpg\")\n\tref, err := store.Store(path, MediaMeta{Source: \"test\"}, \"scope1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store failed: %v\", err)\n\t}\n\n\t// Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't.\n\tstore.mu.Lock()\n\tdelete(store.refs, ref)\n\tstore.mu.Unlock()\n\n\tif err := store.ReleaseAll(\"scope1\"); err != nil {\n\t\tt.Fatalf(\"ReleaseAll failed: %v\", err)\n\t}\n\n\t// ReleaseAll should still clean mappings (even if it can't delete the file without the path).\n\tstore.mu.RLock()\n\tdefer store.mu.RUnlock()\n\tif _, ok := store.refToScope[ref]; ok {\n\t\tt.Error(\"refToScope should not contain ref after ReleaseAll\")\n\t}\n\tif _, ok := store.scopeToRefs[\"scope1\"]; ok {\n\t\tt.Error(\"scopeToRefs should not contain scope1 after ReleaseAll\")\n\t}\n}\n\nfunc TestStoreNonexistentFile(t *testing.T) {\n\tstore := NewFileMediaStore()\n\n\t_, err := store.Store(\"/nonexistent/path/file.jpg\", MediaMeta{Source: \"test\"}, \"scope1\")\n\tif err == nil {\n\t\tt.Error(\"Store should fail for nonexistent file\")\n\t}\n\t// Error message should include the underlying os error, not just \"file does not exist\"\n\tif !strings.Contains(err.Error(), \"no such file or directory\") &&\n\t\t!strings.Contains(err.Error(), \"cannot find\") {\n\t\tt.Errorf(\"Error should contain OS error detail, got: %v\", err)\n\t}\n}\n\nfunc TestResolveWithMeta(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tpath := createTempFile(t, dir, \"image.png\")\n\tmeta := MediaMeta{\n\t\tFilename:    \"image.png\",\n\t\tContentType: \"image/png\",\n\t\tSource:      \"telegram\",\n\t}\n\n\tref, err := store.Store(path, meta, \"scope1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store failed: %v\", err)\n\t}\n\n\tresolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"ResolveWithMeta failed: %v\", err)\n\t}\n\tif resolvedPath != path {\n\t\tt.Errorf(\"ResolveWithMeta path = %q, want %q\", resolvedPath, path)\n\t}\n\tif resolvedMeta.Filename != meta.Filename {\n\t\tt.Errorf(\"ResolveWithMeta Filename = %q, want %q\", resolvedMeta.Filename, meta.Filename)\n\t}\n\tif resolvedMeta.ContentType != meta.ContentType {\n\t\tt.Errorf(\"ResolveWithMeta ContentType = %q, want %q\", resolvedMeta.ContentType, meta.ContentType)\n\t}\n\tif resolvedMeta.Source != meta.Source {\n\t\tt.Errorf(\"ResolveWithMeta Source = %q, want %q\", resolvedMeta.Source, meta.Source)\n\t}\n\n\t// Unknown ref should fail\n\t_, _, err = store.ResolveWithMeta(\"media://nonexistent\")\n\tif err == nil {\n\t\tt.Error(\"ResolveWithMeta should fail for unknown ref\")\n\t}\n}\n\nfunc TestConcurrentSafety(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\tconst goroutines = 20\n\tconst filesPerGoroutine = 5\n\n\tvar wg sync.WaitGroup\n\twg.Add(goroutines)\n\n\tfor g := range goroutines {\n\t\tgo func(gIdx int) {\n\t\t\tdefer wg.Done()\n\t\t\tscope := strings.Repeat(\"s\", gIdx+1)\n\n\t\t\tfor i := range filesPerGoroutine {\n\t\t\t\tpath := createTempFile(t, dir, strings.Repeat(\"f\", gIdx*filesPerGoroutine+i+1)+\".tmp\")\n\t\t\t\tref, err := store.Store(path, MediaMeta{Source: \"test\"}, scope)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Store failed: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif _, err := store.Resolve(ref); err != nil {\n\t\t\t\t\tt.Errorf(\"Resolve failed: %v\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := store.ReleaseAll(scope); err != nil {\n\t\t\t\tt.Errorf(\"ReleaseAll failed: %v\", err)\n\t\t\t}\n\t\t}(g)\n\t}\n\n\twg.Wait()\n}\n\n// --- TTL cleanup tests ---\n\nfunc newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore {\n\ts := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  true,\n\t\tMaxAge:   maxAge,\n\t\tInterval: time.Hour, // won't tick in tests\n\t})\n\treturn s\n}\n\nfunc TestCleanExpiredRemovesOldEntries(t *testing.T) {\n\tdir := t.TempDir()\n\tnow := time.Now()\n\tstore := newTestStoreWithCleanup(10 * time.Minute)\n\tstore.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }\n\n\tpath := createTempFile(t, dir, \"old.jpg\")\n\tref, err := store.Store(path, MediaMeta{Source: \"test\"}, \"scope1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store failed: %v\", err)\n\t}\n\n\t// Advance clock to present\n\tstore.nowFunc = func() time.Time { return now }\n\tremoved := store.CleanExpired()\n\n\tif removed != 1 {\n\t\tt.Errorf(\"expected 1 removed, got %d\", removed)\n\t}\n\tif _, err := store.Resolve(ref); err == nil {\n\t\tt.Error(\"expired ref should be unresolvable\")\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Error(\"expired file should be deleted\")\n\t}\n}\n\nfunc TestCleanExpiredKeepsNonExpired(t *testing.T) {\n\tdir := t.TempDir()\n\tnow := time.Now()\n\tstore := newTestStoreWithCleanup(10 * time.Minute)\n\tstore.nowFunc = func() time.Time { return now }\n\n\tpath := createTempFile(t, dir, \"fresh.jpg\")\n\tref, err := store.Store(path, MediaMeta{Source: \"test\"}, \"scope1\")\n\tif err != nil {\n\t\tt.Fatalf(\"Store failed: %v\", err)\n\t}\n\n\tremoved := store.CleanExpired()\n\tif removed != 0 {\n\t\tt.Errorf(\"expected 0 removed, got %d\", removed)\n\t}\n\n\tif _, err := store.Resolve(ref); err != nil {\n\t\tt.Errorf(\"fresh ref should still resolve: %v\", err)\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Error(\"fresh file should still exist\")\n\t}\n}\n\nfunc TestCleanExpiredMixedAges(t *testing.T) {\n\tdir := t.TempDir()\n\tnow := time.Now()\n\tstore := newTestStoreWithCleanup(10 * time.Minute)\n\n\t// Store old entry\n\tstore.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }\n\toldPath := createTempFile(t, dir, \"old.jpg\")\n\toldRef, _ := store.Store(oldPath, MediaMeta{Source: \"test\"}, \"scope1\")\n\n\t// Store fresh entry\n\tstore.nowFunc = func() time.Time { return now }\n\tfreshPath := createTempFile(t, dir, \"fresh.jpg\")\n\tfreshRef, _ := store.Store(freshPath, MediaMeta{Source: \"test\"}, \"scope1\")\n\n\tremoved := store.CleanExpired()\n\tif removed != 1 {\n\t\tt.Errorf(\"expected 1 removed, got %d\", removed)\n\t}\n\n\tif _, err := store.Resolve(oldRef); err == nil {\n\t\tt.Error(\"old ref should be gone\")\n\t}\n\tif _, err := store.Resolve(freshRef); err != nil {\n\t\tt.Errorf(\"fresh ref should still resolve: %v\", err)\n\t}\n}\n\nfunc TestCleanExpiredCleansEmptyScopes(t *testing.T) {\n\tdir := t.TempDir()\n\tnow := time.Now()\n\tstore := newTestStoreWithCleanup(10 * time.Minute)\n\n\t// Store old entry as the only one in scope\n\tstore.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }\n\tpath := createTempFile(t, dir, \"only.jpg\")\n\tstore.Store(path, MediaMeta{Source: \"test\"}, \"lonely_scope\")\n\n\tstore.nowFunc = func() time.Time { return now }\n\tstore.CleanExpired()\n\n\tstore.mu.RLock()\n\tdefer store.mu.RUnlock()\n\tif _, ok := store.scopeToRefs[\"lonely_scope\"]; ok {\n\t\tt.Error(\"empty scope should be cleaned up\")\n\t}\n}\n\nfunc TestStartStopLifecycle(t *testing.T) {\n\tstore := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  true,\n\t\tMaxAge:   time.Minute,\n\t\tInterval: 50 * time.Millisecond,\n\t})\n\n\t// Start and stop should not panic\n\tstore.Start()\n\t// Double start should not spawn a second goroutine\n\tstore.Start()\n\ttime.Sleep(100 * time.Millisecond)\n\tstore.Stop()\n\n\t// Double stop should not panic\n\tstore.Stop()\n}\n\nfunc TestCleanExpiredZeroMaxAge(t *testing.T) {\n\tstore := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  true,\n\t\tMaxAge:   0,\n\t\tInterval: time.Hour,\n\t})\n\n\tdir := t.TempDir()\n\tpath := createTempFile(t, dir, \"file.jpg\")\n\tref, _ := store.Store(path, MediaMeta{Source: \"test\"}, \"scope1\")\n\n\t// Zero MaxAge should be a no-op\n\tremoved := store.CleanExpired()\n\tif removed != 0 {\n\t\tt.Errorf(\"expected 0 removed with zero MaxAge, got %d\", removed)\n\t}\n\tif _, err := store.Resolve(ref); err != nil {\n\t\tt.Errorf(\"ref should still resolve: %v\", err)\n\t}\n}\n\nfunc TestStartDisabledIsNoop(t *testing.T) {\n\tstore := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  false,\n\t\tMaxAge:   time.Minute,\n\t\tInterval: time.Minute,\n\t})\n\t// Should not start any goroutine or panic\n\tstore.Start()\n\tstore.Stop()\n}\n\nfunc TestStartZeroIntervalNoPanic(t *testing.T) {\n\tstore := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  true,\n\t\tMaxAge:   time.Minute,\n\t\tInterval: 0,\n\t})\n\t// Zero interval should not panic (time.NewTicker panics on <= 0)\n\tstore.Start()\n\tstore.Stop()\n}\n\nfunc TestStartZeroMaxAgeNoPanic(t *testing.T) {\n\tstore := NewFileMediaStoreWithCleanup(MediaCleanerConfig{\n\t\tEnabled:  true,\n\t\tMaxAge:   0,\n\t\tInterval: time.Minute,\n\t})\n\tstore.Start()\n\tstore.Stop()\n}\n\nfunc TestConcurrentCleanupSafety(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := newTestStoreWithCleanup(50 * time.Millisecond)\n\tstore.nowFunc = time.Now\n\n\tconst workers = 10\n\tconst ops = 20\n\tvar wg sync.WaitGroup\n\twg.Add(workers * 4)\n\n\t// Store workers\n\tfor w := range workers {\n\t\tgo func(wIdx int) {\n\t\t\tdefer wg.Done()\n\t\t\tscope := fmt.Sprintf(\"scope-%d\", wIdx)\n\t\t\tfor i := range ops {\n\t\t\t\tp := createTempFile(t, dir, fmt.Sprintf(\"w%d-f%d.tmp\", wIdx, i))\n\t\t\t\tstore.Store(p, MediaMeta{Source: \"test\"}, scope)\n\t\t\t}\n\t\t}(w)\n\t}\n\n\t// Resolve workers\n\tfor range workers {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor range ops {\n\t\t\t\tstore.Resolve(\"media://nonexistent\")\n\t\t\t}\n\t\t}()\n\t}\n\n\t// ReleaseAll workers\n\tfor w := range workers {\n\t\tgo func(wIdx int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor range ops {\n\t\t\t\tstore.ReleaseAll(fmt.Sprintf(\"scope-%d\", wIdx))\n\t\t\t}\n\t\t}(w)\n\t}\n\n\t// CleanExpired workers\n\tfor range workers {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor range ops {\n\t\t\t\tstore.CleanExpired()\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n}\n\nfunc TestRefToScopeConsistency(t *testing.T) {\n\tdir := t.TempDir()\n\tstore := NewFileMediaStore()\n\n\t// Store entries in two scopes\n\tref1, _ := store.Store(createTempFile(t, dir, \"a.jpg\"), MediaMeta{Source: \"test\"}, \"s1\")\n\tref2, _ := store.Store(createTempFile(t, dir, \"b.jpg\"), MediaMeta{Source: \"test\"}, \"s1\")\n\tref3, _ := store.Store(createTempFile(t, dir, \"c.jpg\"), MediaMeta{Source: \"test\"}, \"s2\")\n\n\tstore.mu.RLock()\n\tcheckRef := func(ref, expectedScope string) {\n\t\tt.Helper()\n\t\tif scope, ok := store.refToScope[ref]; !ok || scope != expectedScope {\n\t\t\tt.Errorf(\"refToScope[%s] = %q, want %q\", ref, scope, expectedScope)\n\t\t}\n\t}\n\tcheckRef(ref1, \"s1\")\n\tcheckRef(ref2, \"s1\")\n\tcheckRef(ref3, \"s2\")\n\tstore.mu.RUnlock()\n\n\t// Release s1 and verify refToScope is cleaned\n\tstore.ReleaseAll(\"s1\")\n\n\tstore.mu.RLock()\n\tdefer store.mu.RUnlock()\n\tif _, ok := store.refToScope[ref1]; ok {\n\t\tt.Error(\"refToScope should not contain ref1 after ReleaseAll\")\n\t}\n\tif _, ok := store.refToScope[ref2]; ok {\n\t\tt.Error(\"refToScope should not contain ref2 after ReleaseAll\")\n\t}\n\tif _, ok := store.refToScope[ref3]; !ok {\n\t\tt.Error(\"refToScope should still contain ref3\")\n\t}\n}\n"
  },
  {
    "path": "pkg/media/tempdir.go",
    "content": "package media\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n)\n\nconst TempDirName = \"picoclaw_media\"\n\n// TempDir returns the shared temporary directory used for downloaded media.\nfunc TempDir() string {\n\treturn filepath.Join(os.TempDir(), TempDirName)\n}\n"
  },
  {
    "path": "pkg/memory/jsonl.go",
    "content": "package memory\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nconst (\n\t// numLockShards is the fixed number of mutexes used to serialize\n\t// per-session access. Using a sharded array instead of a map keeps\n\t// memory bounded regardless of how many sessions are created over\n\t// the lifetime of the process — important for a long-running daemon.\n\tnumLockShards = 64\n\n\t// maxLineSize is the maximum size of a single JSON line in a .jsonl\n\t// file. Tool results (read_file, web search, etc.) can be large, so\n\t// we set a generous limit. The scanner starts at 64 KB and grows\n\t// only as needed up to this cap.\n\tmaxLineSize = 10 * 1024 * 1024 // 10 MB\n)\n\n// sessionMeta holds per-session metadata stored in a .meta.json file.\ntype sessionMeta struct {\n\tKey       string    `json:\"key\"`\n\tSummary   string    `json:\"summary\"`\n\tSkip      int       `json:\"skip\"`\n\tCount     int       `json:\"count\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// JSONLStore implements Store using append-only JSONL files.\n//\n// Each session is stored as two files:\n//\n//\t{sanitized_key}.jsonl      — one JSON-encoded message per line, append-only\n//\t{sanitized_key}.meta.json  — session metadata (summary, logical truncation offset)\n//\n// Messages are never physically deleted from the JSONL file. Instead,\n// TruncateHistory records a \"skip\" offset in the metadata file and\n// GetHistory ignores lines before that offset. This keeps all writes\n// append-only, which is both fast and crash-safe.\ntype JSONLStore struct {\n\tdir   string\n\tlocks [numLockShards]sync.Mutex\n}\n\n// NewJSONLStore creates a new JSONL-backed store rooted at dir.\nfunc NewJSONLStore(dir string) (*JSONLStore, error) {\n\terr := os.MkdirAll(dir, 0o755)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"memory: create directory: %w\", err)\n\t}\n\treturn &JSONLStore{dir: dir}, nil\n}\n\n// sessionLock returns a mutex for the given session key.\n// Keys are mapped to a fixed pool of shards via FNV hash, so\n// memory usage is O(1) regardless of total session count.\nfunc (s *JSONLStore) sessionLock(key string) *sync.Mutex {\n\th := fnv.New32a()\n\th.Write([]byte(key))\n\treturn &s.locks[h.Sum32()%numLockShards]\n}\n\nfunc (s *JSONLStore) jsonlPath(key string) string {\n\treturn filepath.Join(s.dir, sanitizeKey(key)+\".jsonl\")\n}\n\nfunc (s *JSONLStore) metaPath(key string) string {\n\treturn filepath.Join(s.dir, sanitizeKey(key)+\".meta.json\")\n}\n\n// sanitizeKey converts a session key to a safe filename component.\n// Mirrors pkg/session.sanitizeFilename so that migration paths match.\n// Replaces ':' with '_' (session key separator) and '/' and '\\' with '_'\n// so composite IDs (e.g. Telegram forum \"chatID/threadID\", Slack \"channel/thread_ts\")\n// do not create subdirectories or break on Windows.\nfunc sanitizeKey(key string) string {\n\ts := strings.ReplaceAll(key, \":\", \"_\")\n\ts = strings.ReplaceAll(s, \"/\", \"_\")\n\ts = strings.ReplaceAll(s, \"\\\\\", \"_\")\n\treturn s\n}\n\n// readMeta loads the metadata file for a session.\n// Returns a zero-value sessionMeta if the file does not exist.\nfunc (s *JSONLStore) readMeta(key string) (sessionMeta, error) {\n\tdata, err := os.ReadFile(s.metaPath(key))\n\tif os.IsNotExist(err) {\n\t\treturn sessionMeta{Key: key}, nil\n\t}\n\tif err != nil {\n\t\treturn sessionMeta{}, fmt.Errorf(\"memory: read meta: %w\", err)\n\t}\n\tvar meta sessionMeta\n\terr = json.Unmarshal(data, &meta)\n\tif err != nil {\n\t\treturn sessionMeta{}, fmt.Errorf(\"memory: decode meta: %w\", err)\n\t}\n\treturn meta, nil\n}\n\n// writeMeta atomically writes the metadata file using the project's\n// standard WriteFileAtomic (temp + fsync + rename).\nfunc (s *JSONLStore) writeMeta(key string, meta sessionMeta) error {\n\tdata, err := json.MarshalIndent(meta, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"memory: encode meta: %w\", err)\n\t}\n\treturn fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644)\n}\n\n// readMessages reads valid JSON lines from a .jsonl file, skipping\n// the first `skip` lines without unmarshaling them. This avoids the\n// cost of json.Unmarshal on logically truncated messages.\n// Malformed trailing lines (e.g. from a crash) are silently skipped.\nfunc readMessages(path string, skip int) ([]providers.Message, error) {\n\tf, err := os.Open(path)\n\tif os.IsNotExist(err) {\n\t\treturn []providers.Message{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"memory: open jsonl: %w\", err)\n\t}\n\tdefer f.Close()\n\n\tvar msgs []providers.Message\n\tscanner := bufio.NewScanner(f)\n\t// Allow large lines for tool results (read_file, web search, etc.).\n\tscanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)\n\n\tlineNum := 0\n\tfor scanner.Scan() {\n\t\tline := scanner.Bytes()\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tlineNum++\n\t\tif lineNum <= skip {\n\t\t\tcontinue\n\t\t}\n\t\tvar msg providers.Message\n\t\tif err := json.Unmarshal(line, &msg); err != nil {\n\t\t\t// Corrupt line — likely a partial write from a crash.\n\t\t\t// Log so operators know data was skipped, but don't\n\t\t\t// fail the entire read; this is the standard JSONL\n\t\t\t// recovery pattern.\n\t\t\tlog.Printf(\"memory: skipping corrupt line %d in %s: %v\",\n\t\t\t\tlineNum, filepath.Base(path), err)\n\t\t\tcontinue\n\t\t}\n\t\tmsgs = append(msgs, msg)\n\t}\n\tif scanner.Err() != nil {\n\t\treturn nil, fmt.Errorf(\"memory: scan jsonl: %w\", scanner.Err())\n\t}\n\n\tif msgs == nil {\n\t\tmsgs = []providers.Message{}\n\t}\n\treturn msgs, nil\n}\n\n// countLines counts the total number of non-empty lines in a .jsonl file.\n// Used by TruncateHistory to reconcile a stale meta.Count without\n// the overhead of unmarshaling every message.\nfunc countLines(path string) (int, error) {\n\tf, err := os.Open(path)\n\tif os.IsNotExist(err) {\n\t\treturn 0, nil\n\t}\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"memory: open jsonl: %w\", err)\n\t}\n\tdefer f.Close()\n\n\tn := 0\n\tscanner := bufio.NewScanner(f)\n\tscanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)\n\tfor scanner.Scan() {\n\t\tif len(scanner.Bytes()) > 0 {\n\t\t\tn++\n\t\t}\n\t}\n\treturn n, scanner.Err()\n}\n\nfunc (s *JSONLStore) AddMessage(\n\t_ context.Context, sessionKey, role, content string,\n) error {\n\treturn s.addMsg(sessionKey, providers.Message{\n\t\tRole:    role,\n\t\tContent: content,\n\t})\n}\n\nfunc (s *JSONLStore) AddFullMessage(\n\t_ context.Context, sessionKey string, msg providers.Message,\n) error {\n\treturn s.addMsg(sessionKey, msg)\n}\n\n// addMsg is the shared implementation for AddMessage and AddFullMessage.\nfunc (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\t// Append the message as a single JSON line.\n\tline, err := json.Marshal(msg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"memory: marshal message: %w\", err)\n\t}\n\tline = append(line, '\\n')\n\n\tf, err := os.OpenFile(\n\t\ts.jsonlPath(sessionKey),\n\t\tos.O_CREATE|os.O_WRONLY|os.O_APPEND,\n\t\t0o644,\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"memory: open jsonl for append: %w\", err)\n\t}\n\t_, writeErr := f.Write(line)\n\tif writeErr != nil {\n\t\tf.Close()\n\t\treturn fmt.Errorf(\"memory: append message: %w\", writeErr)\n\t}\n\t// Flush to physical storage before closing. This matches the\n\t// durability guarantee of writeMeta and rewriteJSONL (which use\n\t// WriteFileAtomic with fsync). Without Sync, a power loss could\n\t// leave the append in the kernel page cache only — lost on reboot.\n\tif syncErr := f.Sync(); syncErr != nil {\n\t\tf.Close()\n\t\treturn fmt.Errorf(\"memory: sync jsonl: %w\", syncErr)\n\t}\n\tif closeErr := f.Close(); closeErr != nil {\n\t\treturn fmt.Errorf(\"memory: close jsonl: %w\", closeErr)\n\t}\n\n\t// Update metadata.\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnow := time.Now()\n\tif meta.Count == 0 && meta.CreatedAt.IsZero() {\n\t\tmeta.CreatedAt = now\n\t}\n\tmeta.Count++\n\tmeta.UpdatedAt = now\n\n\treturn s.writeMeta(sessionKey, meta)\n}\n\nfunc (s *JSONLStore) GetHistory(\n\t_ context.Context, sessionKey string,\n) ([]providers.Message, error) {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Pass meta.Skip so readMessages skips those lines without\n\t// unmarshaling them — avoids wasted CPU on truncated messages.\n\tmsgs, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn msgs, nil\n}\n\nfunc (s *JSONLStore) GetSummary(\n\t_ context.Context, sessionKey string,\n) (string, error) {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.Summary, nil\n}\n\nfunc (s *JSONLStore) SetSummary(\n\t_ context.Context, sessionKey, summary string,\n) error {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnow := time.Now()\n\tif meta.CreatedAt.IsZero() {\n\t\tmeta.CreatedAt = now\n\t}\n\tmeta.Summary = summary\n\tmeta.UpdatedAt = now\n\n\treturn s.writeMeta(sessionKey, meta)\n}\n\nfunc (s *JSONLStore) TruncateHistory(\n\t_ context.Context, sessionKey string, keepLast int,\n) error {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Always reconcile meta.Count with the actual line count on disk.\n\t// A crash between the JSONL append and the meta update in addMsg\n\t// leaves meta.Count stale (e.g. file has 101 lines but meta says\n\t// 100). Counting lines is cheap — no unmarshal, just a scan — and\n\t// TruncateHistory is not a hot path, so always re-count.\n\tn, countErr := countLines(s.jsonlPath(sessionKey))\n\tif countErr != nil {\n\t\treturn countErr\n\t}\n\tmeta.Count = n\n\n\tif keepLast <= 0 {\n\t\tmeta.Skip = meta.Count\n\t} else {\n\t\teffective := meta.Count - meta.Skip\n\t\tif keepLast < effective {\n\t\t\tmeta.Skip = meta.Count - keepLast\n\t\t}\n\t}\n\tmeta.UpdatedAt = time.Now()\n\n\treturn s.writeMeta(sessionKey, meta)\n}\n\nfunc (s *JSONLStore) SetHistory(\n\t_ context.Context,\n\tsessionKey string,\n\thistory []providers.Message,\n) error {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnow := time.Now()\n\tif meta.CreatedAt.IsZero() {\n\t\tmeta.CreatedAt = now\n\t}\n\tmeta.Skip = 0\n\tmeta.Count = len(history)\n\tmeta.UpdatedAt = now\n\n\t// Write meta BEFORE rewriting the JSONL file. If we crash between\n\t// the two writes, meta has Skip=0 and the old file is still intact,\n\t// so GetHistory reads from line 1 — returning \"too many\" messages\n\t// rather than losing data. The next SetHistory call corrects this.\n\terr = s.writeMeta(sessionKey, meta)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn s.rewriteJSONL(sessionKey, history)\n}\n\n// Compact physically rewrites the JSONL file, dropping all logically\n// skipped lines. This reclaims disk space that accumulates after\n// repeated TruncateHistory calls.\n//\n// It is safe to call at any time; if there is nothing to compact\n// (skip == 0) the method returns immediately.\nfunc (s *JSONLStore) Compact(\n\t_ context.Context, sessionKey string,\n) error {\n\tl := s.sessionLock(sessionKey)\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tmeta, err := s.readMeta(sessionKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif meta.Skip == 0 {\n\t\treturn nil\n\t}\n\n\t// Read only the active messages, skipping truncated lines\n\t// without unmarshaling them.\n\tactive, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Write meta BEFORE rewriting the JSONL file. If the process\n\t// crashes between the two writes, meta has Skip=0 and the old\n\t// (uncompacted) file is still intact, so GetHistory reads from\n\t// line 1 — returning previously-truncated messages rather than\n\t// losing data. The next Compact or TruncateHistory corrects this.\n\tmeta.Skip = 0\n\tmeta.Count = len(active)\n\tmeta.UpdatedAt = time.Now()\n\n\terr = s.writeMeta(sessionKey, meta)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn s.rewriteJSONL(sessionKey, active)\n}\n\n// rewriteJSONL atomically replaces the JSONL file with the given messages\n// using the project's standard WriteFileAtomic (temp + fsync + rename).\nfunc (s *JSONLStore) rewriteJSONL(\n\tsessionKey string, msgs []providers.Message,\n) error {\n\tvar buf bytes.Buffer\n\tfor i, msg := range msgs {\n\t\tline, err := json.Marshal(msg)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"memory: marshal message %d: %w\", i, err)\n\t\t}\n\t\tbuf.Write(line)\n\t\tbuf.WriteByte('\\n')\n\t}\n\treturn fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644)\n}\n\nfunc (s *JSONLStore) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/memory/jsonl_test.go",
    "content": "package memory\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nfunc newTestStore(t *testing.T) *JSONLStore {\n\tt.Helper()\n\tstore, err := NewJSONLStore(t.TempDir())\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\treturn store\n}\n\nfunc TestNewJSONLStore_CreatesDirectory(t *testing.T) {\n\tdir := filepath.Join(t.TempDir(), \"nested\", \"sessions\")\n\tstore, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tdefer store.Close()\n\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"Stat: %v\", err)\n\t}\n\tif !info.IsDir() {\n\t\tt.Errorf(\"expected directory, got file\")\n\t}\n}\n\nfunc TestAddMessage_BasicRoundtrip(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\terr := store.AddMessage(ctx, \"s1\", \"user\", \"hello\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\terr = store.AddMessage(ctx, \"s1\", \"assistant\", \"hi there\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d\", len(history))\n\t}\n\tif history[0].Role != \"user\" || history[0].Content != \"hello\" {\n\t\tt.Errorf(\"msg[0] = %+v\", history[0])\n\t}\n\tif history[1].Role != \"assistant\" || history[1].Content != \"hi there\" {\n\t\tt.Errorf(\"msg[1] = %+v\", history[1])\n\t}\n}\n\nfunc TestAddMessage_AutoCreatesSession(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Adding a message to a non-existent session should work.\n\terr := store.AddMessage(ctx, \"new-session\", \"user\", \"first message\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"new-session\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1 message, got %d\", len(history))\n\t}\n}\n\nfunc TestAddFullMessage_WithToolCalls(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tmsg := providers.Message{\n\t\tRole:    \"assistant\",\n\t\tContent: \"Let me search that.\",\n\t\tToolCalls: []providers.ToolCall{\n\t\t\t{\n\t\t\t\tID:   \"call_abc\",\n\t\t\t\tType: \"function\",\n\t\t\t\tFunction: &providers.FunctionCall{\n\t\t\t\t\tName:      \"web_search\",\n\t\t\t\t\tArguments: `{\"q\":\"golang jsonl\"}`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\terr := store.AddFullMessage(ctx, \"tc\", msg)\n\tif err != nil {\n\t\tt.Fatalf(\"AddFullMessage: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"tc\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", len(history))\n\t}\n\tif len(history[0].ToolCalls) != 1 {\n\t\tt.Fatalf(\"expected 1 tool call, got %d\", len(history[0].ToolCalls))\n\t}\n\ttc := history[0].ToolCalls[0]\n\tif tc.ID != \"call_abc\" {\n\t\tt.Errorf(\"tool call ID = %q\", tc.ID)\n\t}\n\tif tc.Function == nil || tc.Function.Name != \"web_search\" {\n\t\tt.Errorf(\"tool call function = %+v\", tc.Function)\n\t}\n}\n\nfunc TestAddFullMessage_ToolCallID(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tmsg := providers.Message{\n\t\tRole:       \"tool\",\n\t\tContent:    \"search results here\",\n\t\tToolCallID: \"call_abc\",\n\t}\n\n\terr := store.AddFullMessage(ctx, \"tr\", msg)\n\tif err != nil {\n\t\tt.Fatalf(\"AddFullMessage: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"tr\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", len(history))\n\t}\n\tif history[0].ToolCallID != \"call_abc\" {\n\t\tt.Errorf(\"ToolCallID = %q\", history[0].ToolCallID)\n\t}\n}\n\nfunc TestGetHistory_EmptySession(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\thistory, err := store.GetHistory(ctx, \"nonexistent\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif history == nil {\n\t\tt.Fatal(\"expected non-nil empty slice\")\n\t}\n\tif len(history) != 0 {\n\t\tt.Errorf(\"expected 0 messages, got %d\", len(history))\n\t}\n}\n\nfunc TestGetHistory_Ordering(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 5; i++ {\n\t\terr := store.AddMessage(\n\t\t\tctx, \"order\",\n\t\t\t\"user\",\n\t\t\tstring(rune('a'+i)),\n\t\t)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage(%d): %v\", i, err)\n\t\t}\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"order\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 5 {\n\t\tt.Fatalf(\"expected 5, got %d\", len(history))\n\t}\n\tfor i := 0; i < 5; i++ {\n\t\texpected := string(rune('a' + i))\n\t\tif history[i].Content != expected {\n\t\t\tt.Errorf(\"msg[%d].Content = %q, want %q\", i, history[i].Content, expected)\n\t\t}\n\t}\n}\n\nfunc TestSetSummary_GetSummary(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// No summary yet.\n\tsummary, err := store.GetSummary(ctx, \"s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"\" {\n\t\tt.Errorf(\"expected empty, got %q\", summary)\n\t}\n\n\t// Set a summary.\n\terr = store.SetSummary(ctx, \"s1\", \"talked about Go\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetSummary: %v\", err)\n\t}\n\n\tsummary, err = store.GetSummary(ctx, \"s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"talked about Go\" {\n\t\tt.Errorf(\"summary = %q\", summary)\n\t}\n\n\t// Update summary.\n\terr = store.SetSummary(ctx, \"s1\", \"updated summary\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetSummary: %v\", err)\n\t}\n\n\tsummary, err = store.GetSummary(ctx, \"s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"updated summary\" {\n\t\tt.Errorf(\"summary = %q\", summary)\n\t}\n}\n\nfunc TestTruncateHistory_KeepLast(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 10; i++ {\n\t\terr := store.AddMessage(\n\t\t\tctx, \"trunc\",\n\t\t\t\"user\",\n\t\t\tstring(rune('a'+i)),\n\t\t)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\terr := store.TruncateHistory(ctx, \"trunc\", 4)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"trunc\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 4 {\n\t\tt.Fatalf(\"expected 4, got %d\", len(history))\n\t}\n\t// Should be the last 4: g, h, i, j\n\tif history[0].Content != \"g\" {\n\t\tt.Errorf(\"first kept = %q, want 'g'\", history[0].Content)\n\t}\n\tif history[3].Content != \"j\" {\n\t\tt.Errorf(\"last kept = %q, want 'j'\", history[3].Content)\n\t}\n}\n\nfunc TestTruncateHistory_KeepZero(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 5; i++ {\n\t\terr := store.AddMessage(ctx, \"empty\", \"user\", \"msg\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\terr := store.TruncateHistory(ctx, \"empty\", 0)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"empty\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", len(history))\n\t}\n}\n\nfunc TestTruncateHistory_KeepMoreThanExists(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 3; i++ {\n\t\terr := store.AddMessage(ctx, \"few\", \"user\", \"msg\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\t// Keep 100, but only 3 exist — should keep all.\n\terr := store.TruncateHistory(ctx, \"few\", 100)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"few\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 3 {\n\t\tt.Errorf(\"expected 3, got %d\", len(history))\n\t}\n}\n\nfunc TestSetHistory_ReplacesAll(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Add some initial messages.\n\tfor i := 0; i < 5; i++ {\n\t\terr := store.AddMessage(ctx, \"replace\", \"user\", \"old\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\t// Replace with new history.\n\tnewHistory := []providers.Message{\n\t\t{Role: \"user\", Content: \"new1\"},\n\t\t{Role: \"assistant\", Content: \"new2\"},\n\t}\n\terr := store.SetHistory(ctx, \"replace\", newHistory)\n\tif err != nil {\n\t\tt.Fatalf(\"SetHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"replace\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected 2, got %d\", len(history))\n\t}\n\tif history[0].Content != \"new1\" || history[1].Content != \"new2\" {\n\t\tt.Errorf(\"history = %+v\", history)\n\t}\n}\n\nfunc TestSetHistory_ResetsSkip(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Add messages and truncate.\n\tfor i := 0; i < 10; i++ {\n\t\terr := store.AddMessage(ctx, \"skip-reset\", \"user\", \"old\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\terr := store.TruncateHistory(ctx, \"skip-reset\", 3)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\t// SetHistory should reset skip to 0.\n\tnewHistory := []providers.Message{\n\t\t{Role: \"user\", Content: \"fresh\"},\n\t}\n\terr = store.SetHistory(ctx, \"skip-reset\", newHistory)\n\tif err != nil {\n\t\tt.Fatalf(\"SetHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"skip-reset\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", len(history))\n\t}\n\tif history[0].Content != \"fresh\" {\n\t\tt.Errorf(\"content = %q\", history[0].Content)\n\t}\n}\n\nfunc TestColonInKey(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\terr := store.AddMessage(ctx, \"telegram:123\", \"user\", \"hi\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"telegram:123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", len(history))\n\t}\n\n\t// Verify the file is named with underscore.\n\tjsonlFile := filepath.Join(store.dir, \"telegram_123.jsonl\")\n\tif _, statErr := os.Stat(jsonlFile); statErr != nil {\n\t\tt.Errorf(\"expected file %s to exist: %v\", jsonlFile, statErr)\n\t}\n}\n\nfunc TestCompact_RemovesSkippedMessages(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Write 10 messages, then truncate to keep last 3.\n\tfor i := 0; i < 10; i++ {\n\t\terr := store.AddMessage(ctx, \"compact\", \"user\", string(rune('a'+i)))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\terr := store.TruncateHistory(ctx, \"compact\", 3)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\t// Before compact: file still has 10 lines.\n\tallOnDisk, err := readMessages(store.jsonlPath(\"compact\"), 0)\n\tif err != nil {\n\t\tt.Fatalf(\"readMessages: %v\", err)\n\t}\n\tif len(allOnDisk) != 10 {\n\t\tt.Fatalf(\"before compact: expected 10 on disk, got %d\", len(allOnDisk))\n\t}\n\n\t// Compact.\n\terr = store.Compact(ctx, \"compact\")\n\tif err != nil {\n\t\tt.Fatalf(\"Compact: %v\", err)\n\t}\n\n\t// After compact: file should have only 3 lines.\n\tallOnDisk, err = readMessages(store.jsonlPath(\"compact\"), 0)\n\tif err != nil {\n\t\tt.Fatalf(\"readMessages: %v\", err)\n\t}\n\tif len(allOnDisk) != 3 {\n\t\tt.Fatalf(\"after compact: expected 3 on disk, got %d\", len(allOnDisk))\n\t}\n\n\t// GetHistory should still return the same 3 messages.\n\thistory, err := store.GetHistory(ctx, \"compact\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 3 {\n\t\tt.Fatalf(\"expected 3, got %d\", len(history))\n\t}\n\tif history[0].Content != \"h\" || history[2].Content != \"j\" {\n\t\tt.Errorf(\"wrong content: %+v\", history)\n\t}\n}\n\nfunc TestCompact_NoOpWhenNoSkip(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 5; i++ {\n\t\terr := store.AddMessage(ctx, \"noop\", \"user\", \"msg\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\t// Compact without prior truncation — should be a no-op.\n\terr := store.Compact(ctx, \"noop\")\n\tif err != nil {\n\t\tt.Fatalf(\"Compact: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"noop\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 5 {\n\t\tt.Errorf(\"expected 5, got %d\", len(history))\n\t}\n}\n\nfunc TestCompact_ThenAppend(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 8; i++ {\n\t\terr := store.AddMessage(ctx, \"cap\", \"user\", string(rune('a'+i)))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\terr := store.TruncateHistory(ctx, \"cap\", 2)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\terr = store.Compact(ctx, \"cap\")\n\tif err != nil {\n\t\tt.Fatalf(\"Compact: %v\", err)\n\t}\n\n\t// Append after compaction should work correctly.\n\terr = store.AddMessage(ctx, \"cap\", \"user\", \"new\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage after compact: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"cap\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 3 {\n\t\tt.Fatalf(\"expected 3, got %d\", len(history))\n\t}\n\t// g, h (kept from truncation), new (appended after compaction).\n\tif history[0].Content != \"g\" {\n\t\tt.Errorf(\"first = %q, want 'g'\", history[0].Content)\n\t}\n\tif history[2].Content != \"new\" {\n\t\tt.Errorf(\"last = %q, want 'new'\", history[2].Content)\n\t}\n}\n\nfunc TestTruncateHistory_StaleMetaCount(t *testing.T) {\n\t// Simulates a crash between JSONL append and meta update in addMsg:\n\t// file has N+1 lines but meta.Count is still N. TruncateHistory must\n\t// reconcile with the real line count so that keepLast is accurate.\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Write 10 messages normally (meta.Count = 10).\n\tfor i := 0; i < 10; i++ {\n\t\terr := store.AddMessage(ctx, \"stale\", \"user\", string(rune('a'+i)))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\t// Simulate crash: append a line to JSONL but do NOT update meta.\n\t// This leaves meta.Count = 10 while the file has 11 lines.\n\tjsonlPath := store.jsonlPath(\"stale\")\n\tf, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"open for append: %v\", err)\n\t}\n\t_, err = f.WriteString(`{\"role\":\"user\",\"content\":\"orphan\"}` + \"\\n\")\n\tif err != nil {\n\t\tt.Fatalf(\"write orphan: %v\", err)\n\t}\n\tf.Close()\n\n\t// TruncateHistory(keepLast=4) should keep the last 4 of 11 lines,\n\t// not the last 4 of 10.\n\terr = store.TruncateHistory(ctx, \"stale\", 4)\n\tif err != nil {\n\t\tt.Fatalf(\"TruncateHistory: %v\", err)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"stale\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 4 {\n\t\tt.Fatalf(\"expected 4, got %d\", len(history))\n\t}\n\t// Last 4 of [a,b,c,d,e,f,g,h,i,j,orphan] = [h,i,j,orphan]\n\tif history[0].Content != \"h\" {\n\t\tt.Errorf(\"first kept = %q, want 'h'\", history[0].Content)\n\t}\n\tif history[3].Content != \"orphan\" {\n\t\tt.Errorf(\"last kept = %q, want 'orphan'\", history[3].Content)\n\t}\n}\n\nfunc TestCrashRecovery_PartialLine(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Write a valid message first.\n\terr := store.AddMessage(ctx, \"crash\", \"user\", \"valid\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\n\t// Simulate a crash by appending a partial JSON line directly.\n\tjsonlPath := store.jsonlPath(\"crash\")\n\tf, err := os.OpenFile(jsonlPath, os.O_WRONLY|os.O_APPEND, 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"open for append: %v\", err)\n\t}\n\t_, err = f.WriteString(`{\"role\":\"user\",\"content\":\"incomple`)\n\tif err != nil {\n\t\tt.Fatalf(\"write partial: %v\", err)\n\t}\n\tf.Close()\n\n\t// GetHistory should return only the valid message.\n\thistory, err := store.GetHistory(ctx, \"crash\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1 valid message, got %d\", len(history))\n\t}\n\tif history[0].Content != \"valid\" {\n\t\tt.Errorf(\"content = %q\", history[0].Content)\n\t}\n}\n\nfunc TestPersistence_AcrossInstances(t *testing.T) {\n\tdir := t.TempDir()\n\tctx := context.Background()\n\n\t// Write with first instance.\n\tstore1, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\terr = store1.AddMessage(ctx, \"persist\", \"user\", \"remember me\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\terr = store1.SetSummary(ctx, \"persist\", \"a test session\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetSummary: %v\", err)\n\t}\n\tstore1.Close()\n\n\t// Read with second instance.\n\tstore2, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tdefer store2.Close()\n\n\thistory, err := store2.GetHistory(ctx, \"persist\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 || history[0].Content != \"remember me\" {\n\t\tt.Errorf(\"history = %+v\", history)\n\t}\n\n\tsummary, err := store2.GetSummary(ctx, \"persist\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"a test session\" {\n\t\tt.Errorf(\"summary = %q\", summary)\n\t}\n}\n\nfunc TestConcurrent_AddAndRead(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tvar wg sync.WaitGroup\n\tconst goroutines = 10\n\tconst msgsPerGoroutine = 20\n\n\t// Concurrent writes.\n\tfor g := 0; g < goroutines; g++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor i := 0; i < msgsPerGoroutine; i++ {\n\t\t\t\t_ = store.AddMessage(ctx, \"concurrent\", \"user\", \"msg\")\n\t\t\t}\n\t\t}()\n\t}\n\twg.Wait()\n\n\thistory, err := store.GetHistory(ctx, \"concurrent\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\texpected := goroutines * msgsPerGoroutine\n\tif len(history) != expected {\n\t\tt.Errorf(\"expected %d messages, got %d\", expected, len(history))\n\t}\n}\n\nfunc TestConcurrent_SummarizeRace(t *testing.T) {\n\t// Simulates the #704 race: one goroutine adds messages while\n\t// another truncates + sets summary — like summarizeSession().\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// Seed with some messages.\n\tfor i := 0; i < 20; i++ {\n\t\terr := store.AddMessage(ctx, \"race\", \"user\", \"seed\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t\t}\n\t}\n\n\tvar wg sync.WaitGroup\n\n\t// Writer goroutine (main agent loop).\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tfor i := 0; i < 50; i++ {\n\t\t\t_ = store.AddMessage(ctx, \"race\", \"user\", \"new\")\n\t\t}\n\t}()\n\n\t// Summarizer goroutine (background task).\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tfor i := 0; i < 10; i++ {\n\t\t\t_ = store.SetSummary(ctx, \"race\", \"summary\")\n\t\t\t_ = store.TruncateHistory(ctx, \"race\", 5)\n\t\t}\n\t}()\n\n\twg.Wait()\n\n\t// Verify the store is still in a consistent state.\n\t_, err := store.GetHistory(ctx, \"race\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory after race: %v\", err)\n\t}\n\t_, err = store.GetSummary(ctx, \"race\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary after race: %v\", err)\n\t}\n}\n\nfunc TestMultipleSessions_Isolation(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\terr := store.AddMessage(ctx, \"s1\", \"user\", \"msg for s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\terr = store.AddMessage(ctx, \"s2\", \"user\", \"msg for s2\")\n\tif err != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", err)\n\t}\n\n\th1, err := store.GetHistory(ctx, \"s1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory s1: %v\", err)\n\t}\n\th2, err := store.GetHistory(ctx, \"s2\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory s2: %v\", err)\n\t}\n\n\tif len(h1) != 1 || h1[0].Content != \"msg for s1\" {\n\t\tt.Errorf(\"s1 history = %+v\", h1)\n\t}\n\tif len(h2) != 1 || h2[0].Content != \"msg for s2\" {\n\t\tt.Errorf(\"s2 history = %+v\", h2)\n\t}\n}\n\nfunc BenchmarkAddMessage(b *testing.B) {\n\tdir := b.TempDir()\n\tstore, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tb.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tdefer store.Close()\n\tctx := context.Background()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = store.AddMessage(ctx, \"bench\", \"user\", \"benchmark message content\")\n\t}\n}\n\nfunc BenchmarkGetHistory_100(b *testing.B) {\n\tdir := b.TempDir()\n\tstore, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tb.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tdefer store.Close()\n\tctx := context.Background()\n\n\tfor i := 0; i < 100; i++ {\n\t\t_ = store.AddMessage(ctx, \"bench\", \"user\", \"message content\")\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_, _ = store.GetHistory(ctx, \"bench\")\n\t}\n}\n\nfunc BenchmarkGetHistory_1000(b *testing.B) {\n\tdir := b.TempDir()\n\tstore, err := NewJSONLStore(dir)\n\tif err != nil {\n\t\tb.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tdefer store.Close()\n\tctx := context.Background()\n\n\tfor i := 0; i < 1000; i++ {\n\t\t_ = store.AddMessage(ctx, \"bench\", \"user\", \"message content\")\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_, _ = store.GetHistory(ctx, \"bench\")\n\t}\n}\n"
  },
  {
    "path": "pkg/memory/migration.go",
    "content": "package memory\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// jsonSession mirrors pkg/session.Session for migration purposes.\ntype jsonSession struct {\n\tKey      string              `json:\"key\"`\n\tMessages []providers.Message `json:\"messages\"`\n\tSummary  string              `json:\"summary,omitempty\"`\n\tCreated  time.Time           `json:\"created\"`\n\tUpdated  time.Time           `json:\"updated\"`\n}\n\n// MigrateFromJSON reads legacy sessions/*.json files from sessionsDir,\n// writes them into the Store, and renames each migrated file to\n// .json.migrated as a backup. Returns the number of sessions migrated.\n//\n// Files that fail to parse are logged and skipped. Already-migrated\n// files (.json.migrated) are ignored, making the function idempotent.\nfunc MigrateFromJSON(\n\tctx context.Context, sessionsDir string, store Store,\n) (int, error) {\n\tentries, err := os.ReadDir(sessionsDir)\n\tif os.IsNotExist(err) {\n\t\treturn 0, nil\n\t}\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"memory: read sessions dir: %w\", err)\n\t}\n\n\tmigrated := 0\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !strings.HasSuffix(name, \".json\") {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip JSONL metadata files. They are part of the new storage format,\n\t\t// not legacy session snapshots, and re-importing them would overwrite\n\t\t// the paired .jsonl history with an empty message list.\n\t\tif strings.HasSuffix(name, \".meta.json\") {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip already-migrated files.\n\t\tif strings.HasSuffix(name, \".migrated\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tsrcPath := filepath.Join(sessionsDir, name)\n\n\t\tdata, readErr := os.ReadFile(srcPath)\n\t\tif readErr != nil {\n\t\t\tlog.Printf(\"memory: migrate: skip %s: %v\", name, readErr)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar sess jsonSession\n\t\tif parseErr := json.Unmarshal(data, &sess); parseErr != nil {\n\t\t\tlog.Printf(\"memory: migrate: skip %s: %v\", name, parseErr)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Use the key from the JSON content, not the filename.\n\t\t// Filenames are sanitized (\":\" → \"_\") but keys are not.\n\t\tkey := sess.Key\n\t\tif key == \"\" {\n\t\t\tkey = strings.TrimSuffix(name, \".json\")\n\t\t}\n\n\t\t// Use SetHistory (atomic replace) instead of per-message\n\t\t// AddFullMessage. This makes migration idempotent: if the\n\t\t// process crashes after writing messages but before the\n\t\t// rename below, a retry replaces the partial data cleanly\n\t\t// instead of duplicating messages.\n\t\tif setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil {\n\t\t\treturn migrated, fmt.Errorf(\n\t\t\t\t\"memory: migrate %s: set history: %w\",\n\t\t\t\tname, setErr,\n\t\t\t)\n\t\t}\n\n\t\tif sess.Summary != \"\" {\n\t\t\tif sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil {\n\t\t\t\treturn migrated, fmt.Errorf(\n\t\t\t\t\t\"memory: migrate %s: set summary: %w\",\n\t\t\t\t\tname, sumErr,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\t// Rename to .migrated as backup (not delete).\n\t\trenameErr := os.Rename(srcPath, srcPath+\".migrated\")\n\t\tif renameErr != nil {\n\t\t\tlog.Printf(\"memory: migrate: rename %s: %v\", name, renameErr)\n\t\t}\n\n\t\tmigrated++\n\t}\n\n\treturn migrated, nil\n}\n"
  },
  {
    "path": "pkg/memory/migration_test.go",
    "content": "package memory\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nfunc writeJSONSession(\n\tt *testing.T, dir string, filename string, sess jsonSession,\n) {\n\tt.Helper()\n\tdata, err := json.MarshalIndent(sess, \"\", \"  \")\n\tif err != nil {\n\t\tt.Fatalf(\"marshal session: %v\", err)\n\t}\n\terr = os.WriteFile(filepath.Join(dir, filename), data, 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"write session file: %v\", err)\n\t}\n}\n\nfunc TestMigrateFromJSON_Basic(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\twriteJSONSession(t, sessionsDir, \"test.json\", jsonSession{\n\t\tKey: \"test\",\n\t\tMessages: []providers.Message{\n\t\t\t{Role: \"user\", Content: \"hello\"},\n\t\t\t{Role: \"assistant\", Content: \"hi\"},\n\t\t},\n\t\tSummary: \"A greeting.\",\n\t\tCreated: time.Now(),\n\t\tUpdated: time.Now(),\n\t})\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1 migrated, got %d\", count)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"test\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d\", len(history))\n\t}\n\tif history[0].Content != \"hello\" || history[1].Content != \"hi\" {\n\t\tt.Errorf(\"unexpected messages: %+v\", history)\n\t}\n\n\tsummary, err := store.GetSummary(ctx, \"test\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"A greeting.\" {\n\t\tt.Errorf(\"summary = %q\", summary)\n\t}\n}\n\nfunc TestMigrateFromJSON_WithToolCalls(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\twriteJSONSession(t, sessionsDir, \"tools.json\", jsonSession{\n\t\tKey: \"tools\",\n\t\tMessages: []providers.Message{\n\t\t\t{\n\t\t\t\tRole:    \"assistant\",\n\t\t\t\tContent: \"Searching...\",\n\t\t\t\tToolCalls: []providers.ToolCall{\n\t\t\t\t\t{\n\t\t\t\t\t\tID:   \"call_1\",\n\t\t\t\t\t\tType: \"function\",\n\t\t\t\t\t\tFunction: &providers.FunctionCall{\n\t\t\t\t\t\t\tName:      \"web_search\",\n\t\t\t\t\t\t\tArguments: `{\"q\":\"test\"}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tRole:       \"tool\",\n\t\t\t\tContent:    \"result\",\n\t\t\t\tToolCallID: \"call_1\",\n\t\t\t},\n\t\t},\n\t\tCreated: time.Now(),\n\t\tUpdated: time.Now(),\n\t})\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1, got %d\", count)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"tools\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected 2 messages, got %d\", len(history))\n\t}\n\tif len(history[0].ToolCalls) != 1 {\n\t\tt.Fatalf(\"expected 1 tool call, got %d\", len(history[0].ToolCalls))\n\t}\n\tif history[0].ToolCalls[0].Function.Name != \"web_search\" {\n\t\tt.Errorf(\"function = %q\", history[0].ToolCalls[0].Function.Name)\n\t}\n\tif history[1].ToolCallID != \"call_1\" {\n\t\tt.Errorf(\"ToolCallID = %q\", history[1].ToolCallID)\n\t}\n}\n\nfunc TestMigrateFromJSON_MultipleFiles(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tfor i := 0; i < 3; i++ {\n\t\tkey := string(rune('a' + i))\n\t\twriteJSONSession(t, sessionsDir, key+\".json\", jsonSession{\n\t\t\tKey:      key,\n\t\t\tMessages: []providers.Message{{Role: \"user\", Content: \"msg \" + key}},\n\t\t\tCreated:  time.Now(),\n\t\t\tUpdated:  time.Now(),\n\t\t})\n\t}\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 3 {\n\t\tt.Errorf(\"expected 3, got %d\", count)\n\t}\n\n\tfor i := 0; i < 3; i++ {\n\t\tkey := string(rune('a' + i))\n\t\thistory, histErr := store.GetHistory(ctx, key)\n\t\tif histErr != nil {\n\t\t\tt.Fatalf(\"GetHistory(%q): %v\", key, histErr)\n\t\t}\n\t\tif len(history) != 1 {\n\t\t\tt.Errorf(\"session %q: expected 1 msg, got %d\", key, len(history))\n\t\t}\n\t}\n}\n\nfunc TestMigrateFromJSON_InvalidJSON(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// One valid, one invalid.\n\twriteJSONSession(t, sessionsDir, \"good.json\", jsonSession{\n\t\tKey:      \"good\",\n\t\tMessages: []providers.Message{{Role: \"user\", Content: \"ok\"}},\n\t\tCreated:  time.Now(),\n\t\tUpdated:  time.Now(),\n\t})\n\terr := os.WriteFile(\n\t\tfilepath.Join(sessionsDir, \"bad.json\"),\n\t\t[]byte(\"{invalid json\"),\n\t\t0o644,\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"write bad file: %v\", err)\n\t}\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1 (bad file skipped), got %d\", count)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"good\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Errorf(\"expected 1 message, got %d\", len(history))\n\t}\n}\n\nfunc TestMigrateFromJSON_RenamesFiles(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\twriteJSONSession(t, sessionsDir, \"rename.json\", jsonSession{\n\t\tKey:      \"rename\",\n\t\tMessages: []providers.Message{{Role: \"user\", Content: \"hi\"}},\n\t\tCreated:  time.Now(),\n\t\tUpdated:  time.Now(),\n\t})\n\n\t_, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\n\t// Original .json should not exist.\n\t_, statErr := os.Stat(filepath.Join(sessionsDir, \"rename.json\"))\n\tif !os.IsNotExist(statErr) {\n\t\tt.Error(\"rename.json should have been renamed\")\n\t}\n\t// .json.migrated should exist.\n\t_, statErr = os.Stat(\n\t\tfilepath.Join(sessionsDir, \"rename.json.migrated\"),\n\t)\n\tif statErr != nil {\n\t\tt.Errorf(\"rename.json.migrated should exist: %v\", statErr)\n\t}\n}\n\nfunc TestMigrateFromJSON_Idempotent(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\twriteJSONSession(t, sessionsDir, \"idem.json\", jsonSession{\n\t\tKey:      \"idem\",\n\t\tMessages: []providers.Message{{Role: \"user\", Content: \"once\"}},\n\t\tCreated:  time.Now(),\n\t\tUpdated:  time.Now(),\n\t})\n\n\tcount1, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"first migration: %v\", err)\n\t}\n\tif count1 != 1 {\n\t\tt.Errorf(\"first run: expected 1, got %d\", count1)\n\t}\n\n\t// Second run should find only .migrated files, skip them.\n\tcount2, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"second migration: %v\", err)\n\t}\n\tif count2 != 0 {\n\t\tt.Errorf(\"second run: expected 0, got %d\", count2)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"idem\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Errorf(\"expected 1 message, got %d\", len(history))\n\t}\n}\n\nfunc TestMigrateFromJSON_ColonInKey(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\t// File is named telegram_123 (sanitized), but the key inside is telegram:123.\n\twriteJSONSession(t, sessionsDir, \"telegram_123.json\", jsonSession{\n\t\tKey:      \"telegram:123\",\n\t\tMessages: []providers.Message{{Role: \"user\", Content: \"from telegram\"}},\n\t\tCreated:  time.Now(),\n\t\tUpdated:  time.Now(),\n\t})\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1, got %d\", count)\n\t}\n\n\t// Accessible via the original key \"telegram:123\".\n\thistory, err := store.GetHistory(ctx, \"telegram:123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1 message, got %d\", len(history))\n\t}\n\tif history[0].Content != \"from telegram\" {\n\t\tt.Errorf(\"content = %q\", history[0].Content)\n\t}\n\n\t// In the file-based store, \"telegram:123\" and \"telegram_123\" both\n\t// sanitize to the same filename, so they share storage. This is\n\t// expected — the colon-to-underscore mapping is a one-way function.\n\thistory2, err := store.GetHistory(ctx, \"telegram_123\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history2) != 1 {\n\t\tt.Errorf(\"expected 1 (same file), got %d\", len(history2))\n\t}\n}\n\nfunc TestMigrateFromJSON_RetryAfterCrash(t *testing.T) {\n\t// Simulates a crash during migration: first run writes messages\n\t// but doesn't rename the .json file. Second run must replace\n\t// (not duplicate) the messages thanks to SetHistory semantics.\n\tsessionsDir := t.TempDir()\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\twriteJSONSession(t, sessionsDir, \"retry.json\", jsonSession{\n\t\tKey: \"retry\",\n\t\tMessages: []providers.Message{\n\t\t\t{Role: \"user\", Content: \"one\"},\n\t\t\t{Role: \"assistant\", Content: \"two\"},\n\t\t},\n\t\tCreated: time.Now(),\n\t\tUpdated: time.Now(),\n\t})\n\n\t// First migration succeeds — writes messages and renames file.\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"first migration: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", count)\n\t}\n\n\t// Simulate \"crash before rename\": restore the .json file.\n\tsrc := filepath.Join(sessionsDir, \"retry.json.migrated\")\n\tdst := filepath.Join(sessionsDir, \"retry.json\")\n\tif renameErr := os.Rename(src, dst); renameErr != nil {\n\t\tt.Fatalf(\"restore .json: %v\", renameErr)\n\t}\n\n\t// Second migration should re-import without duplicating messages.\n\tcount, err = MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"second migration: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Fatalf(\"expected 1, got %d\", count)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"retry\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\t// Must be exactly 2 messages (not 4 from duplication).\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"expected 2 messages (no duplicates), got %d\", len(history))\n\t}\n\tif history[0].Content != \"one\" || history[1].Content != \"two\" {\n\t\tt.Errorf(\"unexpected messages: %+v\", history)\n\t}\n}\n\nfunc TestMigrateFromJSON_NonexistentDir(t *testing.T) {\n\tstore := newTestStore(t)\n\tctx := context.Background()\n\n\tcount, err := MigrateFromJSON(ctx, \"/nonexistent/path\", store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", count)\n\t}\n}\n\nfunc TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) {\n\tsessionsDir := t.TempDir()\n\tstore, err := NewJSONLStore(sessionsDir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore: %v\", err)\n\t}\n\tctx := context.Background()\n\n\tif addErr := store.AddMessage(ctx, \"agent:main:pico:direct:pico:test\", \"user\", \"keep me\"); addErr != nil {\n\t\tt.Fatalf(\"AddMessage: %v\", addErr)\n\t}\n\tif summaryErr := store.SetSummary(ctx, \"agent:main:pico:direct:pico:test\", \"keep summary\"); summaryErr != nil {\n\t\tt.Fatalf(\"SetSummary: %v\", summaryErr)\n\t}\n\n\tmetaPath := filepath.Join(sessionsDir, \"agent_main_pico_direct_pico_test.meta.json\")\n\tif _, statErr := os.Stat(metaPath); statErr != nil {\n\t\tt.Fatalf(\"meta file missing before migration: %v\", statErr)\n\t}\n\n\tcount, err := MigrateFromJSON(ctx, sessionsDir, store)\n\tif err != nil {\n\t\tt.Fatalf(\"MigrateFromJSON: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Fatalf(\"expected 0 migrated, got %d\", count)\n\t}\n\n\thistory, err := store.GetHistory(ctx, \"agent:main:pico:direct:pico:test\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetHistory: %v\", err)\n\t}\n\tif len(history) != 1 || history[0].Content != \"keep me\" {\n\t\tt.Fatalf(\"history = %+v, want preserved single message\", history)\n\t}\n\n\tsummary, err := store.GetSummary(ctx, \"agent:main:pico:direct:pico:test\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetSummary: %v\", err)\n\t}\n\tif summary != \"keep summary\" {\n\t\tt.Fatalf(\"summary = %q, want %q\", summary, \"keep summary\")\n\t}\n\n\tif _, statErr := os.Stat(metaPath); statErr != nil {\n\t\tt.Fatalf(\"meta file should remain in place: %v\", statErr)\n\t}\n\tif _, statErr := os.Stat(metaPath + \".migrated\"); !os.IsNotExist(statErr) {\n\t\tt.Fatalf(\"meta file should not be renamed, stat err = %v\", statErr)\n\t}\n}\n"
  },
  {
    "path": "pkg/memory/store.go",
    "content": "package memory\n\nimport (\n\t\"context\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// Store defines an interface for persistent session storage.\n// Each method is an atomic operation — there is no separate Save() call.\ntype Store interface {\n\t// AddMessage appends a simple text message to a session.\n\tAddMessage(ctx context.Context, sessionKey, role, content string) error\n\n\t// AddFullMessage appends a complete message (with tool calls, etc.) to a session.\n\tAddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error\n\n\t// GetHistory returns all messages for a session in insertion order.\n\t// Returns an empty slice (not nil) if the session does not exist.\n\tGetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error)\n\n\t// GetSummary returns the conversation summary for a session.\n\t// Returns an empty string if no summary exists.\n\tGetSummary(ctx context.Context, sessionKey string) (string, error)\n\n\t// SetSummary updates the conversation summary for a session.\n\tSetSummary(ctx context.Context, sessionKey, summary string) error\n\n\t// TruncateHistory removes all but the last keepLast messages from a session.\n\t// If keepLast <= 0, all messages are removed.\n\tTruncateHistory(ctx context.Context, sessionKey string, keepLast int) error\n\n\t// SetHistory replaces all messages in a session with the provided history.\n\tSetHistory(ctx context.Context, sessionKey string, history []providers.Message) error\n\n\t// Compact reclaims storage by physically removing logically truncated\n\t// data. Backends that do not accumulate dead data may return nil.\n\tCompact(ctx context.Context, sessionKey string) error\n\n\t// Close releases any resources held by the store.\n\tClose() error\n}\n"
  },
  {
    "path": "pkg/migrate/internal/common.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc ResolveTargetHome(override string) (string, error) {\n\tif override != \"\" {\n\t\treturn ExpandHome(override), nil\n\t}\n\tif envHome := os.Getenv(config.EnvHome); envHome != \"\" {\n\t\treturn ExpandHome(envHome), nil\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolving home directory: %w\", err)\n\t}\n\treturn filepath.Join(home, \".picoclaw\"), nil\n}\n\nfunc ExpandHome(path string) string {\n\tif path == \"\" {\n\t\treturn path\n\t}\n\tif path[0] == '~' {\n\t\thome, _ := os.UserHomeDir()\n\t\tif len(path) > 1 && path[1] == '/' {\n\t\t\treturn home + path[1:]\n\t\t}\n\t\treturn home\n\t}\n\treturn path\n}\n\nfunc ResolveWorkspace(homeDir string) string {\n\treturn filepath.Join(homeDir, \"workspace\")\n}\n\nfunc PlanWorkspaceMigration(\n\tsrcWorkspace, dstWorkspace string,\n\tmigrateableFiles []string,\n\tmigrateableDirs []string,\n\tforce bool,\n) ([]Action, error) {\n\tvar actions []Action\n\n\tfor _, filename := range migrateableFiles {\n\t\tsrc := filepath.Join(srcWorkspace, filename)\n\t\tdst := filepath.Join(dstWorkspace, filename)\n\t\taction := planFileCopy(src, dst, force)\n\t\tif action.Type != ActionSkip || action.Description != \"\" {\n\t\t\tactions = append(actions, action)\n\t\t}\n\t}\n\n\tfor _, dirname := range migrateableDirs {\n\t\tsrcDir := filepath.Join(srcWorkspace, dirname)\n\t\tif _, err := os.Stat(srcDir); os.IsNotExist(err) {\n\t\t\tcontinue\n\t\t}\n\t\tdirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tactions = append(actions, dirActions...)\n\t}\n\n\treturn actions, nil\n}\n\nfunc planFileCopy(src, dst string, force bool) Action {\n\tif _, err := os.Stat(src); os.IsNotExist(err) {\n\t\treturn Action{\n\t\t\tType:        ActionSkip,\n\t\t\tSource:      src,\n\t\t\tTarget:      dst,\n\t\t\tDescription: \"source file not found\",\n\t\t}\n\t}\n\n\t_, dstExists := os.Stat(dst)\n\tif dstExists == nil && !force {\n\t\treturn Action{\n\t\t\tType:        ActionBackup,\n\t\t\tSource:      src,\n\t\t\tTarget:      dst,\n\t\t\tDescription: \"destination exists, will backup and overwrite\",\n\t\t}\n\t}\n\n\treturn Action{\n\t\tType:        ActionCopy,\n\t\tSource:      src,\n\t\tTarget:      dst,\n\t\tDescription: \"copy file\",\n\t}\n}\n\nfunc planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {\n\tvar actions []Action\n\n\terr := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trelPath, err := filepath.Rel(srcDir, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdst := filepath.Join(dstDir, relPath)\n\n\t\tif info.IsDir() {\n\t\t\tactions = append(actions, Action{\n\t\t\t\tType:        ActionCreateDir,\n\t\t\t\tTarget:      dst,\n\t\t\t\tDescription: \"create directory\",\n\t\t\t})\n\t\t\treturn nil\n\t\t}\n\n\t\taction := planFileCopy(path, dst, force)\n\t\tactions = append(actions, action)\n\t\treturn nil\n\t})\n\n\treturn actions, err\n}\n\nfunc RelPath(path, base string) string {\n\trel, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn filepath.Base(path)\n\t}\n\treturn rel\n}\n\nfunc CopyFile(src, dst string) error {\n\tsrcFile, err := os.Open(src)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer srcFile.Close()\n\n\tinfo, err := srcFile.Stat()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer dstFile.Close()\n\n\t_, err = io.Copy(dstFile, srcFile)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/migrate/internal/common_test.go",
    "content": "package internal\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestExpandHome(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"\", \"\"},\n\t\t{\"/absolute/path\", \"/absolute/path\"},\n\t\t{\"relative/path\", \"relative/path\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult := ExpandHome(tt.input)\n\t\tassert.Equal(t, tt.expected, result)\n\t}\n}\n\nfunc TestExpandHomeWithTilde(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\tresult := ExpandHome(\"~/path\")\n\tassert.Equal(t, home+\"/path\", result)\n\n\tresult = ExpandHome(\"~\")\n\tassert.Equal(t, home, result)\n}\n\nfunc TestResolveWorkspace(t *testing.T) {\n\tresult := ResolveWorkspace(\"/home/user/.picoclaw\")\n\tassert.Equal(t, \"/home/user/.picoclaw/workspace\", result)\n}\n\nfunc TestRelPath(t *testing.T) {\n\tresult := RelPath(\"/home/user/.picoclaw/workspace/file.txt\", \"/home/user/.picoclaw\")\n\tassert.Equal(t, \"workspace/file.txt\", result)\n}\n\nfunc TestRelPathError(t *testing.T) {\n\tresult := RelPath(\"relative/path\", \"/different/base\")\n\tassert.Equal(t, \"path\", result)\n}\n\nfunc TestResolveTargetHome(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\tresult, err := ResolveTargetHome(\"\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, filepath.Join(home, \".picoclaw\"), result)\n}\n\nfunc TestResolveTargetHomeWithOverride(t *testing.T) {\n\tresult, err := ResolveTargetHome(\"/custom/path\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"/custom/path\", result)\n}\n\nfunc TestCopyFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\tsourceFile := filepath.Join(tmpDir, \"source.txt\")\n\terr := os.WriteFile(sourceFile, []byte(\"test content\"), 0o644)\n\trequire.NoError(t, err)\n\n\tdstFile := filepath.Join(tmpDir, \"dest.txt\")\n\terr = CopyFile(sourceFile, dstFile)\n\trequire.NoError(t, err)\n\n\tcontent, err := os.ReadFile(dstFile)\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"test content\", string(content))\n}\n\nfunc TestCopyFileSourceNotFound(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\terr := CopyFile(filepath.Join(tmpDir, \"nonexistent.txt\"), filepath.Join(tmpDir, \"dest.txt\"))\n\trequire.Error(t, err)\n}\n\nfunc TestPlanWorkspaceMigration(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsrcWorkspace := filepath.Join(tmpDir, \"src\", \"workspace\")\n\tdstWorkspace := filepath.Join(tmpDir, \"dst\", \"workspace\")\n\n\terr := os.MkdirAll(srcWorkspace, 0o755)\n\trequire.NoError(t, err)\n\n\terr = os.WriteFile(filepath.Join(srcWorkspace, \"file1.txt\"), []byte(\"content\"), 0o644)\n\trequire.NoError(t, err)\n\n\terr = os.MkdirAll(filepath.Join(srcWorkspace, \"subdir\"), 0o755)\n\trequire.NoError(t, err)\n\n\terr = os.WriteFile(filepath.Join(srcWorkspace, \"subdir\", \"file2.txt\"), []byte(\"content\"), 0o644)\n\trequire.NoError(t, err)\n\n\tactions, err := PlanWorkspaceMigration(\n\t\tsrcWorkspace,\n\t\tdstWorkspace,\n\t\t[]string{\"file1.txt\"},\n\t\t[]string{\"subdir\"},\n\t\tfalse,\n\t)\n\trequire.NoError(t, err)\n\n\tassert.GreaterOrEqual(t, len(actions), 1)\n}\n\nfunc TestPlanWorkspaceMigrationExistingFile(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tforce          bool\n\t\twantActionType ActionType\n\t}{\n\t\t{\n\t\t\tname:           \"backup when not forced\",\n\t\t\tforce:          false,\n\t\t\twantActionType: ActionBackup,\n\t\t},\n\t\t{\n\t\t\tname:           \"copy when forced\",\n\t\t\tforce:          true,\n\t\t\twantActionType: ActionCopy,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tsrcWorkspace := filepath.Join(tmpDir, \"src\", \"workspace\")\n\t\t\tdstWorkspace := filepath.Join(tmpDir, \"dst\", \"workspace\")\n\n\t\t\terr := os.MkdirAll(srcWorkspace, 0o755)\n\t\t\trequire.NoError(t, err)\n\n\t\t\terr = os.MkdirAll(dstWorkspace, 0o755)\n\t\t\trequire.NoError(t, err)\n\n\t\t\terr = os.WriteFile(filepath.Join(srcWorkspace, \"file1.txt\"), []byte(\"source\"), 0o644)\n\t\t\trequire.NoError(t, err)\n\n\t\t\terr = os.WriteFile(filepath.Join(dstWorkspace, \"file1.txt\"), []byte(\"existing\"), 0o644)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tactions, err := PlanWorkspaceMigration(\n\t\t\t\tsrcWorkspace,\n\t\t\t\tdstWorkspace,\n\t\t\t\t[]string{\"file1.txt\"},\n\t\t\t\t[]string{},\n\t\t\t\ttt.force,\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trequire.GreaterOrEqual(t, len(actions), 1)\n\t\t\tassert.Equal(t, tt.wantActionType, actions[0].Type)\n\t\t})\n\t}\n}\n\nfunc TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\tactions, err := PlanWorkspaceMigration(\n\t\tfilepath.Join(tmpDir, \"nonexistent\"),\n\t\tfilepath.Join(tmpDir, \"dst\", \"workspace\"),\n\t\t[]string{\"file1.txt\"},\n\t\t[]string{},\n\t\tfalse,\n\t)\n\trequire.NoError(t, err)\n\trequire.Len(t, actions, 1)\n\tassert.Equal(t, ActionSkip, actions[0].Type)\n\tassert.Contains(t, actions[0].Description, \"source file not found\")\n}\n"
  },
  {
    "path": "pkg/migrate/internal/types.go",
    "content": "package internal\n\ntype Options struct {\n\tDryRun        bool\n\tConfigOnly    bool\n\tWorkspaceOnly bool\n\tForce         bool\n\tRefresh       bool\n\tSource        string\n\tSourceHome    string\n\tTargetHome    string\n}\n\ntype Operation interface {\n\tGetSourceName() string\n\tGetSourceHome() (string, error)\n\tGetSourceWorkspace() (string, error)\n\tGetSourceConfigFile() (string, error)\n\tExecuteConfigMigration(srcConfigPath, dstConfigPath string) error\n\tGetMigrateableFiles() []string\n\tGetMigrateableDirs() []string\n}\n\ntype HandlerFactory func(opts Options) Operation\n\ntype ActionType int\n\nconst (\n\tActionCopy ActionType = iota\n\tActionSkip\n\tActionBackup\n\tActionConvertConfig\n\tActionCreateDir\n\tActionMergeConfig\n)\n\ntype Action struct {\n\tType        ActionType\n\tSource      string\n\tTarget      string\n\tDescription string\n}\n\ntype Result struct {\n\tFilesCopied    int\n\tFilesSkipped   int\n\tBackupsCreated int\n\tConfigMigrated bool\n\tDirsCreated    int\n\tWarnings       []string\n\tErrors         []error\n}\n"
  },
  {
    "path": "pkg/migrate/migrate.go",
    "content": "package migrate\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/migrate/internal\"\n\t\"github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw\"\n)\n\ntype (\n\tOptions        = internal.Options\n\tOperation      = internal.Operation\n\tActionType     = internal.ActionType\n\tAction         = internal.Action\n\tResult         = internal.Result\n\tHandlerFactory = internal.HandlerFactory\n)\n\nconst (\n\tActionCopy          = internal.ActionCopy\n\tActionSkip          = internal.ActionSkip\n\tActionBackup        = internal.ActionBackup\n\tActionConvertConfig = internal.ActionConvertConfig\n\tActionCreateDir     = internal.ActionCreateDir\n\tActionMergeConfig   = internal.ActionMergeConfig\n)\n\ntype MigrateInstance struct {\n\toptions  Options\n\thandlers map[string]Operation\n}\n\nfunc NewMigrateInstance(opts Options) *MigrateInstance {\n\tinstance := &MigrateInstance{\n\t\toptions:  opts,\n\t\thandlers: make(map[string]Operation),\n\t}\n\n\topenclaw_handler, err := openclaw.NewOpenclawHandler(opts)\n\tif err == nil {\n\t\tinstance.Register(openclaw_handler.GetSourceName(), openclaw_handler)\n\t}\n\n\treturn instance\n}\n\nfunc (m *MigrateInstance) Register(moduleName string, module Operation) {\n\tm.handlers[moduleName] = module\n}\n\nfunc (m *MigrateInstance) getCurrentHandler() (Operation, error) {\n\tsource := m.options.Source\n\tif source == \"\" {\n\t\tsource = \"openclaw\"\n\t}\n\thandler, ok := m.handlers[source]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"Source '%s' not found\", source)\n\t}\n\treturn handler, nil\n}\n\nfunc (m *MigrateInstance) Run(opts Options) (*Result, error) {\n\thandler, err := m.getCurrentHandler()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif opts.ConfigOnly && opts.WorkspaceOnly {\n\t\treturn nil, fmt.Errorf(\"--config-only and --workspace-only are mutually exclusive\")\n\t}\n\n\tif opts.Refresh {\n\t\topts.WorkspaceOnly = true\n\t}\n\n\tsourceHome, err := handler.GetSourceHome()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttargetHome, err := internal.ResolveTargetHome(opts.TargetHome)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err = os.Stat(sourceHome); os.IsNotExist(err) {\n\t\treturn nil, fmt.Errorf(\"Source installation not found at %s\", sourceHome)\n\t}\n\n\tactions, warnings, err := m.Plan(opts, sourceHome, targetHome)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfmt.Println(\"Migrating from Source to PicoClaw\")\n\tfmt.Printf(\"  Source:      %s\\n\", sourceHome)\n\tfmt.Printf(\"  Target: %s\\n\", targetHome)\n\tfmt.Println()\n\n\tif opts.DryRun {\n\t\tPrintPlan(actions, warnings)\n\t\treturn &Result{Warnings: warnings}, nil\n\t}\n\n\tif !opts.Force {\n\t\tPrintPlan(actions, warnings)\n\t\tif !Confirm() {\n\t\t\tfmt.Println(\"Aborted.\")\n\t\t\treturn &Result{Warnings: warnings}, nil\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\tresult := m.Execute(actions, sourceHome, targetHome)\n\tresult.Warnings = warnings\n\treturn result, nil\n}\n\nfunc (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) {\n\tvar actions []Action\n\tvar warnings []string\n\thandler, err := m.getCurrentHandler()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tforce := opts.Force || opts.Refresh\n\n\tif !opts.WorkspaceOnly {\n\t\tconfigPath, err := handler.GetSourceConfigFile()\n\t\tif err != nil {\n\t\t\tif opts.ConfigOnly {\n\t\t\t\treturn nil, nil, err\n\t\t\t}\n\t\t\twarnings = append(warnings, fmt.Sprintf(\"Config migration skipped: %v\", err))\n\t\t} else {\n\t\t\tactions = append(actions, Action{\n\t\t\t\tType:        ActionConvertConfig,\n\t\t\t\tSource:      configPath,\n\t\t\t\tTarget:      filepath.Join(targetHome, \"config.json\"),\n\t\t\t\tDescription: \"convert Source config to PicoClaw format\",\n\t\t\t})\n\t\t}\n\t}\n\n\tif !opts.ConfigOnly {\n\t\tsrcWorkspace, err := handler.GetSourceWorkspace()\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"getting source workspace: %w\", err)\n\t\t}\n\t\tdstWorkspace := internal.ResolveWorkspace(targetHome)\n\n\t\tif _, err := os.Stat(srcWorkspace); err == nil {\n\t\t\twsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace,\n\t\t\t\thandler.GetMigrateableFiles(),\n\t\t\t\thandler.GetMigrateableDirs(),\n\t\t\t\tforce)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"planning workspace migration: %w\", err)\n\t\t\t}\n\t\t\tactions = append(actions, wsActions...)\n\t\t} else {\n\t\t\twarnings = append(warnings, \"Source workspace directory not found, skipping workspace migration\")\n\t\t}\n\t}\n\n\treturn actions, warnings, nil\n}\n\nfunc (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result {\n\tresult := &Result{}\n\thandler, err := m.getCurrentHandler()\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tfor _, action := range actions {\n\t\tswitch action.Type {\n\t\tcase ActionConvertConfig:\n\t\t\tif err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, fmt.Errorf(\"config migration: %w\", err))\n\t\t\t\tfmt.Printf(\"  ✗ Config migration failed: %v\\n\", err)\n\t\t\t} else {\n\t\t\t\tresult.ConfigMigrated = true\n\t\t\t\tfmt.Printf(\"  ✓ Converted config: %s\\n\", action.Target)\n\t\t\t}\n\t\tcase ActionCreateDir:\n\t\t\tif err := os.MkdirAll(action.Target, 0o755); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, err)\n\t\t\t} else {\n\t\t\t\tresult.DirsCreated++\n\t\t\t}\n\t\tcase ActionBackup:\n\t\t\tbakPath := action.Target + \".bak\"\n\t\t\tif err := internal.CopyFile(action.Target, bakPath); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, fmt.Errorf(\"backup %s: %w\", action.Target, err))\n\t\t\t\tfmt.Printf(\"  ✗ Backup failed: %s\\n\", action.Target)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresult.BackupsCreated++\n\t\t\tfmt.Printf(\n\t\t\t\t\"  ✓ Backed up %s -> %s.bak\\n\",\n\t\t\t\tfilepath.Base(action.Target),\n\t\t\t\tfilepath.Base(action.Target),\n\t\t\t)\n\n\t\t\tif err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := internal.CopyFile(action.Source, action.Target); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, fmt.Errorf(\"copy %s: %w\", action.Source, err))\n\t\t\t\tfmt.Printf(\"  ✗ Copy failed: %s\\n\", action.Source)\n\t\t\t} else {\n\t\t\t\tresult.FilesCopied++\n\t\t\t\tfmt.Printf(\"  ✓ Copied %s\\n\", internal.RelPath(action.Source, sourceHome))\n\t\t\t}\n\t\tcase ActionCopy:\n\t\t\tif err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := internal.CopyFile(action.Source, action.Target); err != nil {\n\t\t\t\tresult.Errors = append(result.Errors, fmt.Errorf(\"copy %s: %w\", action.Source, err))\n\t\t\t\tfmt.Printf(\"  ✗ Copy failed: %s\\n\", action.Source)\n\t\t\t} else {\n\t\t\t\tresult.FilesCopied++\n\t\t\t\tfmt.Printf(\"  ✓ Copied %s\\n\", internal.RelPath(action.Source, sourceHome))\n\t\t\t}\n\t\tcase ActionSkip:\n\t\t\tresult.FilesSkipped++\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc Confirm() bool {\n\tfmt.Print(\"Proceed with migration? (y/n): \")\n\tvar response string\n\tfmt.Scanln(&response)\n\treturn strings.ToLower(strings.TrimSpace(response)) == \"y\"\n}\n\nfunc (m *MigrateInstance) PrintSummary(result *Result) {\n\tfmt.Println()\n\tparts := []string{}\n\tif result.FilesCopied > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%d files copied\", result.FilesCopied))\n\t}\n\tif result.ConfigMigrated {\n\t\tparts = append(parts, \"1 config converted\")\n\t}\n\tif result.BackupsCreated > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%d backups created\", result.BackupsCreated))\n\t}\n\tif result.FilesSkipped > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"%d files skipped\", result.FilesSkipped))\n\t}\n\n\tif len(parts) > 0 {\n\t\tfmt.Printf(\"Migration complete! %s.\\n\", strings.Join(parts, \", \"))\n\t} else {\n\t\tfmt.Println(\"Migration complete! No actions taken.\")\n\t}\n\n\tif len(result.Errors) > 0 {\n\t\tfmt.Println()\n\t\tfmt.Printf(\"%d errors occurred:\\n\", len(result.Errors))\n\t\tfor _, e := range result.Errors {\n\t\t\tfmt.Printf(\"  - %v\\n\", e)\n\t\t}\n\t}\n}\n\nfunc PrintPlan(actions []Action, warnings []string) {\n\tfmt.Println(\"Planned actions:\")\n\tcopies := 0\n\tskips := 0\n\tbackups := 0\n\tconfigCount := 0\n\n\tfor _, action := range actions {\n\t\tswitch action.Type {\n\t\tcase ActionConvertConfig:\n\t\t\tfmt.Printf(\"  [config]  %s -> %s\\n\", action.Source, action.Target)\n\t\t\tconfigCount++\n\t\tcase ActionCopy:\n\t\t\tfmt.Printf(\"  [copy]    %s\\n\", filepath.Base(action.Source))\n\t\t\tcopies++\n\t\tcase ActionBackup:\n\t\t\tfmt.Printf(\"  [backup]  %s (exists, will backup and overwrite)\\n\", filepath.Base(action.Target))\n\t\t\tbackups++\n\t\t\tcopies++\n\t\tcase ActionSkip:\n\t\t\tif action.Description != \"\" {\n\t\t\t\tfmt.Printf(\"  [skip]    %s (%s)\\n\", filepath.Base(action.Source), action.Description)\n\t\t\t}\n\t\t\tskips++\n\t\tcase ActionCreateDir:\n\t\t\tfmt.Printf(\"  [mkdir]   %s\\n\", action.Target)\n\t\t}\n\t}\n\n\tif len(warnings) > 0 {\n\t\tfmt.Println()\n\t\tfmt.Println(\"Warnings:\")\n\t\tfor _, w := range warnings {\n\t\t\tfmt.Printf(\"  - %s\\n\", w)\n\t\t}\n\t}\n\n\tfmt.Println()\n\tfmt.Printf(\"%d files to copy, %d configs to convert, %d backups needed, %d skipped\\n\",\n\t\tcopies, configCount, backups, skips)\n}\n"
  },
  {
    "path": "pkg/migrate/migrate_test.go",
    "content": "package migrate\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewMigrateInstance(t *testing.T) {\n\topts := Options{\n\t\tSource: \"openclaw\",\n\t}\n\tinstance := NewMigrateInstance(opts)\n\trequire.NotNil(t, instance)\n\tassert.Equal(t, \"openclaw\", instance.options.Source)\n}\n\nfunc TestMigrateInstanceRegister(t *testing.T) {\n\tinstance := NewMigrateInstance(Options{})\n\trequire.NotNil(t, instance)\n\n\tmockHandler := &mockOperation{}\n\tinstance.Register(\"test-source\", mockHandler)\n\n\thandler, ok := instance.handlers[\"test-source\"]\n\trequire.True(t, ok)\n\tassert.Equal(t, mockHandler, handler)\n}\n\nfunc TestMigrateInstanceGetCurrentHandler(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\tinstance := NewMigrateInstance(Options{SourceHome: tmpDir})\n\trequire.NotNil(t, instance)\n\n\thandler, err := instance.getCurrentHandler()\n\trequire.NoError(t, err)\n\trequire.NotNil(t, handler)\n\tassert.Equal(t, \"openclaw\", handler.GetSourceName())\n}\n\nfunc TestMigrateInstanceGetCurrentHandlerWithSource(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\topts := Options{\n\t\tSource:     \"openclaw\",\n\t\tSourceHome: tmpDir,\n\t}\n\tinstance := NewMigrateInstance(opts)\n\n\thandler, err := instance.getCurrentHandler()\n\trequire.NoError(t, err)\n\trequire.NotNil(t, handler)\n\tassert.Equal(t, \"openclaw\", handler.GetSourceName())\n}\n\nfunc TestMigrateInstanceGetCurrentHandlerNotFound(t *testing.T) {\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{},\n\t\thandlers: make(map[string]Operation),\n\t}\n\n\t_, err := instance.getCurrentHandler()\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"not found\")\n}\n\nfunc TestMigrateInstancePlanWithInvalidSource(t *testing.T) {\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{},\n\t\thandlers: make(map[string]Operation),\n\t}\n\n\t_, _, err := instance.Plan(Options{}, \"/tmp/source\", \"/tmp/target\")\n\trequire.Error(t, err)\n}\n\nfunc TestMigrateInstancePlanConfigOnlyAndWorkspaceOnlyMutuallyExclusive(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\tinstance := NewMigrateInstance(Options{SourceHome: tmpDir})\n\trequire.NotNil(t, instance)\n\n\t_, err = instance.Run(Options{\n\t\tConfigOnly:    true,\n\t\tWorkspaceOnly: true,\n\t})\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"mutually exclusive\")\n}\n\nfunc TestMigrateInstancePlanRefreshSetsWorkspaceOnly(t *testing.T) {\n\topts := Options{\n\t\tRefresh:    true,\n\t\tSourceHome: \"/tmp/nonexistent\",\n\t}\n\tinstance := NewMigrateInstance(opts)\n\trequire.NotNil(t, instance)\n\n\t_, err := instance.Run(opts)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"not found\")\n}\n\nfunc TestMigrateInstancePlanSourceNotFound(t *testing.T) {\n\topts := Options{\n\t\tSourceHome: \"/tmp/nonexistent-source-home\",\n\t}\n\tinstance := NewMigrateInstance(opts)\n\n\t_, err := instance.Run(opts)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"not found\")\n}\n\nfunc TestMigrateInstanceExecute(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsourceDir := filepath.Join(tmpDir, \"source\")\n\ttargetDir := filepath.Join(tmpDir, \"target\")\n\tworkspaceDir := filepath.Join(sourceDir, \"workspace\")\n\n\terr := os.MkdirAll(workspaceDir, 0o755)\n\trequire.NoError(t, err)\n\n\terr = os.WriteFile(filepath.Join(workspaceDir, \"test.txt\"), []byte(\"test\"), 0o644)\n\trequire.NoError(t, err)\n\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{Source: \"mock\"},\n\t\thandlers: make(map[string]Operation),\n\t}\n\tinstance.Register(\"mock\", &mockOperation{sourceHome: sourceDir, sourceWs: workspaceDir})\n\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionCopy,\n\t\t\tSource:      filepath.Join(workspaceDir, \"test.txt\"),\n\t\t\tTarget:      filepath.Join(targetDir, \"workspace\", \"test.txt\"),\n\t\t\tDescription: \"copy file\",\n\t\t},\n\t}\n\n\tresult := instance.Execute(actions, workspaceDir, targetDir)\n\trequire.NotNil(t, result)\n\tassert.Equal(t, 1, result.FilesCopied)\n\n\t_, err = os.Stat(filepath.Join(targetDir, \"workspace\", \"test.txt\"))\n\tassert.NoError(t, err)\n}\n\nfunc TestMigrateInstanceExecuteWithInvalidSource(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsourceDir := filepath.Join(tmpDir, \"source\")\n\terr := os.MkdirAll(sourceDir, 0o755)\n\trequire.NoError(t, err)\n\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{Source: \"mock\"},\n\t\thandlers: make(map[string]Operation),\n\t}\n\tinstance.Register(\"mock\", &mockOperation{sourceHome: sourceDir})\n\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionCopy,\n\t\t\tSource:      filepath.Join(sourceDir, \"nonexistent.txt\"),\n\t\t\tTarget:      filepath.Join(tmpDir, \"target.txt\"),\n\t\t\tDescription: \"copy file\",\n\t\t},\n\t}\n\n\tresult := instance.Execute(actions, sourceDir, tmpDir)\n\trequire.NotNil(t, result)\n\tassert.Equal(t, 0, result.FilesCopied)\n\tassert.Greater(t, len(result.Errors), 0)\n}\n\nfunc TestMigrateInstanceExecuteCreateDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{Source: \"mock\"},\n\t\thandlers: make(map[string]Operation),\n\t}\n\tinstance.Register(\"mock\", &mockOperation{})\n\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionCreateDir,\n\t\t\tTarget:      filepath.Join(tmpDir, \"new\", \"dir\"),\n\t\t\tDescription: \"create directory\",\n\t\t},\n\t}\n\n\tresult := instance.Execute(actions, \"\", \"\")\n\trequire.NotNil(t, result)\n\tassert.Equal(t, 1, result.DirsCreated)\n\n\t_, err := os.Stat(filepath.Join(tmpDir, \"new\", \"dir\"))\n\tassert.NoError(t, err)\n}\n\nfunc TestMigrateInstanceExecuteBackup(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\tsourceFile := filepath.Join(tmpDir, \"source.txt\")\n\ttargetFile := filepath.Join(tmpDir, \"target.txt\")\n\n\terr := os.WriteFile(sourceFile, []byte(\"source\"), 0o644)\n\trequire.NoError(t, err)\n\n\terr = os.WriteFile(targetFile, []byte(\"target\"), 0o644)\n\trequire.NoError(t, err)\n\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{Source: \"mock\"},\n\t\thandlers: make(map[string]Operation),\n\t}\n\tinstance.Register(\"mock\", &mockOperation{})\n\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionBackup,\n\t\t\tSource:      sourceFile,\n\t\t\tTarget:      targetFile,\n\t\t\tDescription: \"backup and overwrite\",\n\t\t},\n\t}\n\n\tresult := instance.Execute(actions, tmpDir, tmpDir)\n\trequire.NotNil(t, result)\n\tassert.Equal(t, 1, result.BackupsCreated)\n\tassert.Equal(t, 1, result.FilesCopied)\n\n\tbakFile := targetFile + \".bak\"\n\t_, err = os.Stat(bakFile)\n\tassert.NoError(t, err)\n\n\tcontent, err := os.ReadFile(targetFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"source\", string(content))\n}\n\nfunc TestMigrateInstanceExecuteSkip(t *testing.T) {\n\tinstance := &MigrateInstance{\n\t\toptions:  Options{Source: \"mock\"},\n\t\thandlers: make(map[string]Operation),\n\t}\n\tinstance.Register(\"mock\", &mockOperation{})\n\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionSkip,\n\t\t\tSource:      \"/tmp/source.txt\",\n\t\t\tTarget:      \"/tmp/target.txt\",\n\t\t\tDescription: \"skip file\",\n\t\t},\n\t}\n\n\tresult := instance.Execute(actions, \"\", \"\")\n\trequire.NotNil(t, result)\n\tassert.Equal(t, 1, result.FilesSkipped)\n}\n\nfunc TestMigrateInstancePrintSummary(t *testing.T) {\n\tinstance := NewMigrateInstance(Options{})\n\n\tresult := &Result{\n\t\tFilesCopied:    5,\n\t\tConfigMigrated: true,\n\t\tBackupsCreated: 2,\n\t\tFilesSkipped:   3,\n\t\tWarnings:       []string{\"warning 1\"},\n\t\tErrors:         []error{},\n\t}\n\n\tinstance.PrintSummary(result)\n}\n\nfunc TestMigrateInstancePrintSummaryWithErrors(t *testing.T) {\n\tinstance := NewMigrateInstance(Options{})\n\n\tresult := &Result{\n\t\tFilesCopied:    0,\n\t\tConfigMigrated: false,\n\t\tBackupsCreated: 0,\n\t\tFilesSkipped:   0,\n\t\tWarnings:       []string{},\n\t\tErrors:         []error{assert.AnError},\n\t}\n\n\tinstance.PrintSummary(result)\n}\n\nfunc TestMigrateInstancePrintSummaryNoActions(t *testing.T) {\n\tinstance := NewMigrateInstance(Options{})\n\n\tresult := &Result{\n\t\tFilesCopied:    0,\n\t\tConfigMigrated: false,\n\t\tBackupsCreated: 0,\n\t\tFilesSkipped:   0,\n\t\tWarnings:       []string{},\n\t\tErrors:         []error{},\n\t}\n\n\tinstance.PrintSummary(result)\n}\n\nfunc TestPrintPlan(t *testing.T) {\n\tactions := []Action{\n\t\t{\n\t\t\tType:        ActionConvertConfig,\n\t\t\tSource:      \"/source/config.json\",\n\t\t\tTarget:      \"/target/config.json\",\n\t\t\tDescription: \"convert config\",\n\t\t},\n\t\t{\n\t\t\tType:        ActionCopy,\n\t\t\tSource:      \"/source/file.txt\",\n\t\t\tTarget:      \"/target/file.txt\",\n\t\t\tDescription: \"copy file\",\n\t\t},\n\t\t{\n\t\t\tType:        ActionBackup,\n\t\t\tSource:      \"/source/existing.txt\",\n\t\t\tTarget:      \"/target/existing.txt\",\n\t\t\tDescription: \"backup and overwrite\",\n\t\t},\n\t\t{\n\t\t\tType:        ActionSkip,\n\t\t\tSource:      \"/source/skipped.txt\",\n\t\t\tTarget:      \"/target/skipped.txt\",\n\t\t\tDescription: \"skip file\",\n\t\t},\n\t\t{\n\t\t\tType:        ActionCreateDir,\n\t\t\tTarget:      \"/target/newdir\",\n\t\t\tDescription: \"create directory\",\n\t\t},\n\t}\n\n\twarnings := []string{\n\t\t\"Warning: source directory not found\",\n\t}\n\n\tPrintPlan(actions, warnings)\n}\n\nfunc TestPrintPlanEmpty(t *testing.T) {\n\tPrintPlan([]Action{}, []string{})\n}\n\ntype mockOperation struct {\n\tsourceHome   string\n\tsourceConfig string\n\tsourceWs     string\n\tmigrateFiles []string\n\tmigrateDirs  []string\n}\n\nfunc (m *mockOperation) GetSourceName() string { return \"mock\" }\nfunc (m *mockOperation) GetSourceHome() (string, error) {\n\tif m.sourceHome != \"\" {\n\t\treturn m.sourceHome, nil\n\t}\n\treturn \"/tmp/mock\", nil\n}\n\nfunc (m *mockOperation) GetSourceWorkspace() (string, error) {\n\tif m.sourceWs != \"\" {\n\t\treturn m.sourceWs, nil\n\t}\n\tif m.sourceHome != \"\" {\n\t\treturn filepath.Join(m.sourceHome, \"workspace\"), nil\n\t}\n\treturn \"/tmp/mock/workspace\", nil\n}\n\nfunc (m *mockOperation) GetSourceConfigFile() (string, error) {\n\tif m.sourceConfig != \"\" {\n\t\treturn m.sourceConfig, nil\n\t}\n\treturn \"/tmp/mock/config.json\", nil\n}\nfunc (m *mockOperation) ExecuteConfigMigration(src, dst string) error { return nil }\nfunc (m *mockOperation) GetMigrateableFiles() []string {\n\tif m.migrateFiles != nil {\n\t\treturn m.migrateFiles\n\t}\n\treturn []string{}\n}\n\nfunc (m *mockOperation) GetMigrateableDirs() []string {\n\tif m.migrateDirs != nil {\n\t\treturn m.migrateDirs\n\t}\n\treturn []string{}\n}\n"
  },
  {
    "path": "pkg/migrate/sources/openclaw/common.go",
    "content": "package openclaw\n\nvar migrateableFiles = []string{\n\t\"AGENTS.md\",\n\t\"SOUL.md\",\n\t\"USER.md\",\n\t\"HEARTBEAT.md\",\n}\n\nvar migrateableDirs = []string{\n\t\"memory\",\n\t\"skills\",\n}\n\nvar supportedChannels = map[string]bool{\n\t\"whatsapp\":  true,\n\t\"telegram\":  true,\n\t\"feishu\":    true,\n\t\"discord\":   true,\n\t\"maixcam\":   true,\n\t\"qq\":        true,\n\t\"dingtalk\":  true,\n\t\"slack\":     true,\n\t\"matrix\":    true,\n\t\"line\":      true,\n\t\"onebot\":    true,\n\t\"wecom\":     true,\n\t\"wecom_app\": true,\n}\n"
  },
  {
    "path": "pkg/migrate/sources/openclaw/openclaw_config.go",
    "content": "package openclaw\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\ntype OpenClawConfig struct {\n\tAuth     *OpenClawAuth     `json:\"auth\"`\n\tModels   *OpenClawModels   `json:\"models\"`\n\tAgents   *OpenClawAgents   `json:\"agents\"`\n\tTools    *OpenClawTools    `json:\"tools\"`\n\tChannels *OpenClawChannels `json:\"channels\"`\n\tCron     json.RawMessage   `json:\"cron\"`\n\tHooks    json.RawMessage   `json:\"hooks\"`\n\tSkills   *OpenClawSkills   `json:\"skills\"`\n\tMemory   json.RawMessage   `json:\"memory\"`\n\tSession  json.RawMessage   `json:\"session\"`\n}\n\ntype OpenClawAuth struct {\n\tProfiles json.RawMessage `json:\"profiles\"`\n\tOrder    json.RawMessage `json:\"order\"`\n}\n\ntype OpenClawModels struct {\n\tProviders map[string]json.RawMessage `json:\"providers\"`\n}\n\ntype ProviderConfig struct {\n\tBaseUrl string        `json:\"baseUrl\"`\n\tApi     string        `json:\"api\"`\n\tModels  []ModelConfig `json:\"models\"`\n\tApiKey  string        `json:\"apiKey\"`\n}\n\ntype OpenClawModelConfig struct {\n\tID            string   `json:\"id\"`\n\tName          string   `json:\"name\"`\n\tReasoning     bool     `json:\"reasoning\"`\n\tInput         []string `json:\"input\"`\n\tCost          Cost     `json:\"cost\"`\n\tContextWindow int      `json:\"contextWindow\"`\n\tMaxTokens     int      `json:\"maxTokens\"`\n\tApi           string   `json:\"api,omitempty\"`\n}\n\ntype Cost struct {\n\tInput      float64 `json:\"input\"`\n\tOutput     float64 `json:\"output\"`\n\tCacheRead  float64 `json:\"cacheRead\"`\n\tCacheWrite float64 `json:\"cacheWrite\"`\n}\n\ntype OpenClawTools struct {\n\tProfile *string  `json:\"profile\"`\n\tAllow   []string `json:\"allow\"`\n\tDeny    []string `json:\"deny\"`\n}\n\ntype OpenClawAgents struct {\n\tDefaults *OpenClawAgentDefaults `json:\"defaults\"`\n\tList     []OpenClawAgentEntry   `json:\"list\"`\n}\n\ntype OpenClawAgentDefaults struct {\n\tModel     *OpenClawAgentModel `json:\"model\"`\n\tWorkspace *string             `json:\"workspace\"`\n\tTools     *OpenClawAgentTools `json:\"tools\"`\n\tIdentity  *string             `json:\"identity\"`\n}\n\ntype OpenClawAgentModel struct {\n\tSimple    string   `json:\"-\"`\n\tPrimary   *string  `json:\"primary\"`\n\tFallbacks []string `json:\"fallbacks\"`\n}\n\nfunc (m *OpenClawAgentModel) GetPrimary() string {\n\tif m.Simple != \"\" {\n\t\treturn m.Simple\n\t}\n\tif m.Primary != nil {\n\t\treturn *m.Primary\n\t}\n\treturn \"\"\n}\n\nfunc (m *OpenClawAgentModel) GetFallbacks() []string {\n\treturn m.Fallbacks\n}\n\ntype OpenClawAgentEntry struct {\n\tID        string              `json:\"id\"`\n\tName      *string             `json:\"name\"`\n\tModel     *OpenClawAgentModel `json:\"model\"`\n\tTools     *OpenClawAgentTools `json:\"tools\"`\n\tWorkspace *string             `json:\"workspace\"`\n\tSkills    []string            `json:\"skills\"`\n\tIdentity  *string             `json:\"identity\"`\n}\n\ntype OpenClawAgentTools struct {\n\tProfile   *string  `json:\"profile\"`\n\tAllow     []string `json:\"allow\"`\n\tDeny      []string `json:\"deny\"`\n\tAlsoAllow []string `json:\"alsoAllow\"`\n}\n\ntype OpenClawChannels struct {\n\tTelegram    *OpenClawTelegramConfig    `json:\"telegram\"`\n\tDiscord     *OpenClawDiscordConfig     `json:\"discord\"`\n\tSlack       *OpenClawSlackConfig       `json:\"slack\"`\n\tWhatsApp    *OpenClawWhatsAppConfig    `json:\"whatsapp\"`\n\tSignal      *OpenClawSignalConfig      `json:\"signal\"`\n\tMatrix      *OpenClawMatrixConfig      `json:\"matrix\"`\n\tGoogleChat  *OpenClawGoogleChatConfig  `json:\"googlechat\"`\n\tTeams       *OpenClawTeamsConfig       `json:\"msteams\"`\n\tIRC         *OpenClawIrcConfig         `json:\"irc\"`\n\tMattermost  *OpenClawMattermostConfig  `json:\"mattermost\"`\n\tFeishu      *OpenClawFeishuConfig      `json:\"feishu\"`\n\tIMessage    *OpenClawIMessageConfig    `json:\"imessage\"`\n\tBlueBubbles *OpenClawBlueBubblesConfig `json:\"bluebubbles\"`\n\tQQ          *OpenClawQQConfig          `json:\"qq\"`\n\tDingTalk    *OpenClawDingTalkConfig    `json:\"dingtalk\"`\n\tMaixCam     *OpenClawMaixCamConfig     `json:\"maixcam\"`\n}\n\ntype OpenClawTelegramConfig struct {\n\tBotToken      *string  `json:\"botToken\"`\n\tAllowFrom     []string `json:\"allowFrom\"`\n\tGroupPolicy   *string  `json:\"groupPolicy\"`\n\tDmPolicy      *string  `json:\"dmPolicy\"`\n\tEnabled       *bool    `json:\"enabled\"`\n\tUseMarkdownV2 *bool    `json:\"useMarkdownV2\"`\n}\n\ntype OpenClawDiscordConfig struct {\n\tToken       *string         `json:\"token\"`\n\tGuilds      json.RawMessage `json:\"guilds\"`\n\tDmPolicy    *string         `json:\"dmPolicy\"`\n\tGroupPolicy *string         `json:\"groupPolicy\"`\n\tAllowFrom   []string        `json:\"allowFrom\"`\n\tEnabled     *bool           `json:\"enabled\"`\n}\n\ntype OpenClawSlackConfig struct {\n\tBotToken    *string  `json:\"botToken\"`\n\tAppToken    *string  `json:\"appToken\"`\n\tDmPolicy    *string  `json:\"dmPolicy\"`\n\tGroupPolicy *string  `json:\"groupPolicy\"`\n\tAllowFrom   []string `json:\"allowFrom\"`\n\tEnabled     *bool    `json:\"enabled\"`\n}\n\ntype OpenClawWhatsAppConfig struct {\n\tAuthDir     *string  `json:\"authDir\"`\n\tDmPolicy    *string  `json:\"dmPolicy\"`\n\tAllowFrom   []string `json:\"allowFrom\"`\n\tGroupPolicy *string  `json:\"groupPolicy\"`\n\tEnabled     *bool    `json:\"enabled\"`\n\tBridgeURL   *string  `json:\"bridgeUrl\"`\n}\n\ntype OpenClawSignalConfig struct {\n\tHttpUrl   *string  `json:\"httpUrl\"`\n\tHttpHost  *string  `json:\"httpHost\"`\n\tHttpPort  *int     `json:\"httpPort\"`\n\tAccount   *string  `json:\"account\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawMatrixConfig struct {\n\tHomeserver  *string  `json:\"homeserver\"`\n\tUserID      *string  `json:\"userId\"`\n\tAccessToken *string  `json:\"accessToken\"`\n\tRooms       []string `json:\"rooms\"`\n\tDmPolicy    *string  `json:\"dmPolicy\"`\n\tAllowFrom   []string `json:\"allowFrom\"`\n\tEnabled     *bool    `json:\"enabled\"`\n}\n\ntype OpenClawGoogleChatConfig struct {\n\tServiceAccountFile *string `json:\"serviceAccountFile\"`\n\tWebhookPath        *string `json:\"webhookPath\"`\n\tBotUser            *string `json:\"botUser\"`\n\tDmPolicy           *string `json:\"dmPolicy\"`\n\tEnabled            *bool   `json:\"enabled\"`\n}\n\ntype OpenClawTeamsConfig struct {\n\tAppID       *string  `json:\"appId\"`\n\tAppPassword *string  `json:\"appPassword\"`\n\tTenantID    *string  `json:\"tenantId\"`\n\tDmPolicy    *string  `json:\"dmPolicy\"`\n\tAllowFrom   []string `json:\"allowFrom\"`\n\tEnabled     *bool    `json:\"enabled\"`\n}\n\ntype OpenClawIrcConfig struct {\n\tHost      *string  `json:\"host\"`\n\tPort      *int     `json:\"port\"`\n\tTLS       *bool    `json:\"tls\"`\n\tNick      *string  `json:\"nick\"`\n\tPassword  *string  `json:\"password\"`\n\tChannels  []string `json:\"channels\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawMattermostConfig struct {\n\tBotToken  *string  `json:\"botToken\"`\n\tBaseURL   *string  `json:\"baseUrl\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawFeishuConfig struct {\n\tAppID             *string  `json:\"appId\"`\n\tAppSecret         *string  `json:\"appSecret\"`\n\tDomain            *string  `json:\"domain\"`\n\tDmPolicy          *string  `json:\"dmPolicy\"`\n\tEnabled           *bool    `json:\"enabled\"`\n\tVerificationToken *string  `json:\"verificationToken\"`\n\tEncryptKey        *string  `json:\"encryptKey\"`\n\tAllowFrom         []string `json:\"allowFrom\"`\n}\n\ntype OpenClawIMessageConfig struct {\n\tCliPath   *string  `json:\"cliPath\"`\n\tDbPath    *string  `json:\"dbPath\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawBlueBubblesConfig struct {\n\tServerURL *string  `json:\"serverUrl\"`\n\tPassword  *string  `json:\"password\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawQQConfig struct {\n\tAppID     *string  `json:\"appId\"`\n\tAppSecret *string  `json:\"appSecret\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawDingTalkConfig struct {\n\tAppID     *string  `json:\"appId\"`\n\tAppSecret *string  `json:\"appSecret\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawMaixCamConfig struct {\n\tHost      *string  `json:\"host\"`\n\tPort      *int     `json:\"port\"`\n\tDmPolicy  *string  `json:\"dmPolicy\"`\n\tAllowFrom []string `json:\"allowFrom\"`\n\tEnabled   *bool    `json:\"enabled\"`\n}\n\ntype OpenClawSkills struct {\n\tEntries map[string]json.RawMessage `json:\"entries\"`\n\tLoad    json.RawMessage            `json:\"load\"`\n}\n\ntype OpenClawProviderConfig struct {\n\tAPIKey  string `json:\"api_key\"`\n\tBaseURL string `json:\"base_url\"`\n}\n\nfunc (c *OpenClawConfig) GetEnabled() bool {\n\treturn true\n}\n\nfunc LoadOpenClawConfig(path string) (*OpenClawConfig, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read config: %w\", err)\n\t}\n\n\tvar config OpenClawConfig\n\tif err := json.Unmarshal(data, &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse JSON: %w\", err)\n\t}\n\n\treturn &config, nil\n}\n\nfunc LoadOpenClawConfigFromDir(dir string) (*OpenClawConfig, error) {\n\tcandidates := []string{\n\t\tfilepath.Join(dir, \"openclaw.json\"),\n\t\tfilepath.Join(dir, \"config.json\"),\n\t}\n\n\tfor _, p := range candidates {\n\t\tif _, err := os.Stat(p); err == nil {\n\t\t\treturn LoadOpenClawConfig(p)\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"no config file found in %s\", dir)\n}\n\nfunc GetProviderConfig(models *OpenClawModels) map[string]OpenClawProviderConfig {\n\tresult := make(map[string]OpenClawProviderConfig)\n\tif models == nil || models.Providers == nil {\n\t\treturn result\n\t}\n\n\tfor name, raw := range models.Providers {\n\t\tvar prov OpenClawProviderConfig\n\t\tif err := json.Unmarshal(raw, &prov); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tmappedName := mapProvider(name)\n\t\tresult[mappedName] = prov\n\t}\n\n\treturn result\n}\n\nfunc GetProviderConfigFromDir(dir string) map[string]ProviderConfig {\n\tresult := make(map[string]ProviderConfig)\n\tp := filepath.Join(dir, \"agents\", \"main\", \"agent\", \"models.json\")\n\n\tif _, err := os.Stat(p); err != nil {\n\t\treturn result\n\t}\n\n\tdata, err := os.ReadFile(p)\n\tif err != nil {\n\t\treturn result\n\t}\n\tvar models OpenClawModels\n\tif err := json.Unmarshal(data, &models); err != nil {\n\t\treturn result\n\t}\n\n\tfor name, raw := range models.Providers {\n\t\tvar prov ProviderConfig\n\t\tif err := json.Unmarshal(raw, &prov); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tmappedName := mapProvider(name)\n\t\tresult[mappedName] = prov\n\t}\n\treturn result\n}\n\nfunc (c *OpenClawConfig) IsChannelEnabled(name string) bool {\n\tswitch name {\n\tcase \"telegram\":\n\t\treturn c.Channels.Telegram == nil || c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled\n\tcase \"discord\":\n\t\treturn c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled\n\tcase \"slack\":\n\t\treturn c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled\n\tcase \"matrix\":\n\t\treturn c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled\n\tcase \"whatsapp\":\n\t\treturn c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled\n\tcase \"feishu\":\n\t\treturn c.Channels.Feishu == nil || c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc GetChannelAllowFrom(ch any) []string {\n\tswitch c := ch.(type) {\n\tcase *OpenClawTelegramConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tcase *OpenClawDiscordConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tcase *OpenClawSlackConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tcase *OpenClawMatrixConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tcase *OpenClawWhatsAppConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tcase *OpenClawFeishuConfig:\n\t\tif c == nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn c.AllowFrom\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc (c *OpenClawConfig) GetDefaultModel() (provider, model string) {\n\tif c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Model == nil {\n\t\treturn \"anthropic\", \"claude-sonnet-4-20250514\"\n\t}\n\n\tprimary := c.Agents.Defaults.Model.GetPrimary()\n\tif primary == \"\" {\n\t\treturn \"anthropic\", \"claude-sonnet-4-20250514\"\n\t}\n\n\tparts := strings.Split(primary, \"/\")\n\tif len(parts) > 1 {\n\t\treturn mapProvider(parts[0]), parts[1]\n\t}\n\n\treturn \"anthropic\", primary\n}\n\nfunc (c *OpenClawConfig) GetDefaultWorkspace() string {\n\tif c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Workspace == nil {\n\t\treturn \"\"\n\t}\n\treturn rewriteWorkspacePath(*c.Agents.Defaults.Workspace)\n}\n\nfunc (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry {\n\tif c.Agents == nil {\n\t\treturn nil\n\t}\n\treturn c.Agents.List\n}\n\nfunc (c *OpenClawConfig) HasSkills() bool {\n\treturn c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0\n}\n\nfunc (c *OpenClawConfig) HasMemory() bool {\n\treturn c.Memory != nil && len(c.Memory) > 0\n}\n\nfunc (c *OpenClawConfig) HasCron() bool {\n\treturn c.Cron != nil && len(c.Cron) > 0\n}\n\nfunc (c *OpenClawConfig) HasHooks() bool {\n\treturn c.Hooks != nil && len(c.Hooks) > 0\n}\n\nfunc (c *OpenClawConfig) HasSession() bool {\n\treturn c.Session != nil && len(c.Session) > 0\n}\n\nfunc (c *OpenClawConfig) HasAuthProfiles() bool {\n\treturn c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0\n}\n\nfunc (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) {\n\tcfg := &PicoClawConfig{}\n\tvar warnings []string\n\n\tprovider, modelName := c.GetDefaultModel()\n\tcfg.Agents.Defaults.Workspace = c.GetDefaultWorkspace()\n\tcfg.Agents.Defaults.ModelName = modelName\n\n\tproviderConfigs := GetProviderConfigFromDir(sourceHome)\n\tdefaultAPIKey := \"\"\n\tdefaultBaseURL := \"\"\n\n\tif provCfg, ok := providerConfigs[provider]; ok {\n\t\tdefaultAPIKey = provCfg.ApiKey\n\t\tdefaultBaseURL = provCfg.BaseUrl\n\t}\n\n\tcfg.ModelList = []ModelConfig{\n\t\t{\n\t\t\tModelName: modelName,\n\t\t\tModel:     fmt.Sprintf(\"%s/%s\", provider, modelName),\n\t\t\tAPIKey:    defaultAPIKey,\n\t\t\tAPIBase:   defaultBaseURL,\n\t\t},\n\t}\n\n\tfor provName, provCfg := range providerConfigs {\n\t\tif provName == provider {\n\t\t\tcontinue\n\t\t}\n\t\tif provCfg.ApiKey != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcfg.ModelList = append(cfg.ModelList, ModelConfig{\n\t\t\tModelName: fmt.Sprintf(\"%s\", provName),\n\t\t\tModel:     fmt.Sprintf(\"%s/%s\", provName, provName),\n\t\t\tAPIKey:    provCfg.ApiKey,\n\t\t\tAPIBase:   provCfg.BaseUrl,\n\t\t})\n\t}\n\n\tcfg.Channels = c.convertChannels(&warnings)\n\n\tagentList := c.convertAgents(&warnings)\n\tif len(agentList) > 0 {\n\t\tcfg.Agents.List = agentList\n\t}\n\n\tif c.HasSkills() {\n\t\twarnings = append(\n\t\t\twarnings,\n\t\t\tfmt.Sprintf(\n\t\t\t\t\"Skills (%d entries) not automatically migrated - reinstall via picoclaw CLI\",\n\t\t\t\tlen(c.Skills.Entries),\n\t\t\t),\n\t\t)\n\t}\n\tif c.HasMemory() {\n\t\twarnings = append(warnings, \"Memory backend config not migrated - PicoClaw uses SQLite with vector embeddings\")\n\t}\n\tif c.HasCron() {\n\t\twarnings = append(\n\t\t\twarnings,\n\t\t\t\"Cron job scheduling not supported in PicoClaw - consider using external schedulers\",\n\t\t)\n\t}\n\tif c.HasHooks() {\n\t\twarnings = append(warnings, \"Webhook hooks not supported in PicoClaw - use event system instead\")\n\t}\n\tif c.HasSession() {\n\t\twarnings = append(warnings, \"Session scope config differs - PicoClaw uses per-agent sessions by default\")\n\t}\n\tif c.HasAuthProfiles() {\n\t\twarnings = append(\n\t\t\twarnings,\n\t\t\t\"Auth profiles (API keys, OAuth tokens) not migrated for security - set env vars manually\",\n\t\t)\n\t}\n\n\treturn cfg, warnings, nil\n}\n\ntype ModelConfig struct {\n\tModelName string `json:\"model_name\"`\n\tModel     string `json:\"model\"`\n\tAPIBase   string `json:\"api_base,omitempty\"`\n\tAPIKey    string `json:\"api_key\"`\n\tProxy     string `json:\"proxy,omitempty\"`\n}\n\ntype PicoClawConfig struct {\n\tAgents    AgentsConfig   `json:\"agents\"`\n\tBindings  []AgentBinding `json:\"bindings,omitempty\"`\n\tChannels  ChannelsConfig `json:\"channels\"`\n\tModelList []ModelConfig  `json:\"model_list\"`\n\tGateway   GatewayConfig  `json:\"gateway\"`\n\tTools     ToolsConfig    `json:\"tools\"`\n}\n\ntype AgentsConfig struct {\n\tDefaults AgentDefaults `json:\"defaults\"`\n\tList     []AgentConfig `json:\"list,omitempty\"`\n}\n\ntype AgentDefaults struct {\n\tWorkspace           string   `json:\"workspace\"`\n\tRestrictToWorkspace bool     `json:\"restrict_to_workspace\"`\n\tProvider            string   `json:\"provider\"`\n\tModelName           string   `json:\"model_name\"`\n\tModel               string   `json:\"model,omitempty\"`\n\tModelFallbacks      []string `json:\"model_fallbacks,omitempty\"`\n\tImageModel          string   `json:\"image_model,omitempty\"`\n\tImageModelFallbacks []string `json:\"image_model_fallbacks,omitempty\"`\n\tMaxTokens           int      `json:\"max_tokens\"`\n\tTemperature         *float64 `json:\"temperature,omitempty\"`\n\tMaxToolIterations   int      `json:\"max_tool_iterations\"`\n}\n\ntype AgentConfig struct {\n\tID        string            `json:\"id\"`\n\tDefault   bool              `json:\"default,omitempty\"`\n\tName      string            `json:\"name,omitempty\"`\n\tWorkspace string            `json:\"workspace,omitempty\"`\n\tModel     *AgentModelConfig `json:\"model,omitempty\"`\n\tSkills    []string          `json:\"skills,omitempty\"`\n}\n\ntype AgentModelConfig struct {\n\tPrimary   string   `json:\"primary,omitempty\"`\n\tFallbacks []string `json:\"fallbacks,omitempty\"`\n}\n\ntype AgentBinding struct {\n\tAgentID string       `json:\"agent_id\"`\n\tMatch   BindingMatch `json:\"match\"`\n}\n\ntype BindingMatch struct {\n\tChannel   string     `json:\"channel\"`\n\tAccountID string     `json:\"account_id,omitempty\"`\n\tPeer      *PeerMatch `json:\"peer,omitempty\"`\n\tGuildID   string     `json:\"guild_id,omitempty\"`\n\tTeamID    string     `json:\"team_id,omitempty\"`\n}\n\ntype PeerMatch struct {\n\tKind string `json:\"kind\"`\n\tID   string `json:\"id\"`\n}\n\ntype ChannelsConfig struct {\n\tWhatsApp WhatsAppConfig `json:\"whatsapp\"`\n\tTelegram TelegramConfig `json:\"telegram\"`\n\tFeishu   FeishuConfig   `json:\"feishu\"`\n\tDiscord  DiscordConfig  `json:\"discord\"`\n\tMaixCam  MaixCamConfig  `json:\"maixcam\"`\n\tQQ       QQConfig       `json:\"qq\"`\n\tDingTalk DingTalkConfig `json:\"dingtalk\"`\n\tSlack    SlackConfig    `json:\"slack\"`\n\tMatrix   MatrixConfig   `json:\"matrix\"`\n\tLINE     LINEConfig     `json:\"line\"`\n}\n\ntype WhatsAppConfig struct {\n\tEnabled   bool     `json:\"enabled\"`\n\tBridgeURL string   `json:\"bridge_url\"`\n\tAllowFrom []string `json:\"allow_from\"`\n}\n\ntype TelegramConfig struct {\n\tEnabled       bool     `json:\"enabled\"`\n\tToken         string   `json:\"token\"`\n\tProxy         string   `json:\"proxy\"`\n\tAllowFrom     []string `json:\"allow_from\"`\n\tUseMarkdownV2 bool     `json:\"use_markdown_v2\"`\n}\n\ntype FeishuConfig struct {\n\tEnabled           bool     `json:\"enabled\"`\n\tAppID             string   `json:\"app_id\"`\n\tAppSecret         string   `json:\"app_secret\"`\n\tEncryptKey        string   `json:\"encrypt_key\"`\n\tVerificationToken string   `json:\"verification_token\"`\n\tAllowFrom         []string `json:\"allow_from\"`\n}\n\ntype DiscordConfig struct {\n\tEnabled     bool     `json:\"enabled\"`\n\tToken       string   `json:\"token\"`\n\tMentionOnly bool     `json:\"mention_only\"`\n\tAllowFrom   []string `json:\"allow_from\"`\n}\n\ntype MaixCamConfig struct {\n\tEnabled   bool     `json:\"enabled\"`\n\tHost      string   `json:\"host\"`\n\tPort      int      `json:\"port\"`\n\tAllowFrom []string `json:\"allow_from\"`\n}\n\ntype QQConfig struct {\n\tEnabled   bool     `json:\"enabled\"`\n\tAppID     string   `json:\"app_id\"`\n\tAppSecret string   `json:\"app_secret\"`\n\tAllowFrom []string `json:\"allow_from\"`\n}\n\ntype DingTalkConfig struct {\n\tEnabled      bool     `json:\"enabled\"`\n\tClientID     string   `json:\"client_id\"`\n\tClientSecret string   `json:\"client_secret\"`\n\tAllowFrom    []string `json:\"allow_from\"`\n}\n\ntype SlackConfig struct {\n\tEnabled   bool     `json:\"enabled\"`\n\tBotToken  string   `json:\"bot_token\"`\n\tAppToken  string   `json:\"app_token\"`\n\tAllowFrom []string `json:\"allow_from\"`\n}\n\ntype MatrixConfig struct {\n\tEnabled     bool     `json:\"enabled\"`\n\tHomeserver  string   `json:\"homeserver\"`\n\tUserID      string   `json:\"user_id\"`\n\tAccessToken string   `json:\"access_token\"`\n\tAllowFrom   []string `json:\"allow_from\"`\n}\n\ntype LINEConfig struct {\n\tEnabled            bool     `json:\"enabled\"`\n\tChannelSecret      string   `json:\"channel_secret\"`\n\tChannelAccessToken string   `json:\"channel_access_token\"`\n\tWebhookHost        string   `json:\"webhook_host\"`\n\tWebhookPort        int      `json:\"webhook_port\"`\n\tWebhookPath        string   `json:\"webhook_path\"`\n\tAllowFrom          []string `json:\"allow_from\"`\n}\n\ntype GatewayConfig struct {\n\tHost string `json:\"host\"`\n\tPort int    `json:\"port\"`\n}\n\ntype ToolsConfig struct {\n\tWeb  WebToolsConfig `json:\"web\"`\n\tCron CronConfig     `json:\"cron\"`\n\tExec ExecConfig     `json:\"exec\"`\n}\n\ntype WebToolsConfig struct {\n\tBrave      BraveConfig      `json:\"brave\"`\n\tTavily     TavilyConfig     `json:\"tavily\"`\n\tDuckDuckGo DuckDuckGoConfig `json:\"duckduckgo\"`\n\tPerplexity PerplexityConfig `json:\"perplexity\"`\n\tProxy      string           `json:\"proxy,omitempty\"`\n}\n\ntype BraveConfig struct {\n\tEnabled    bool     `json:\"enabled\"`\n\tAPIKey     string   `json:\"api_key\"`\n\tAPIKeys    []string `json:\"api_keys\"`\n\tMaxResults int      `json:\"max_results\"`\n}\n\ntype TavilyConfig struct {\n\tEnabled    bool     `json:\"enabled\"`\n\tAPIKey     string   `json:\"api_key\"`\n\tAPIKeys    []string `json:\"api_keys\"`\n\tBaseURL    string   `json:\"base_url\"`\n\tMaxResults int      `json:\"max_results\"`\n}\n\ntype DuckDuckGoConfig struct {\n\tEnabled    bool `json:\"enabled\"`\n\tMaxResults int  `json:\"max_results\"`\n}\n\ntype PerplexityConfig struct {\n\tEnabled    bool     `json:\"enabled\"`\n\tAPIKey     string   `json:\"api_key\"`\n\tAPIKeys    []string `json:\"api_keys\"`\n\tMaxResults int      `json:\"max_results\"`\n}\n\ntype CronConfig struct {\n\tExecTimeoutMinutes int `json:\"exec_timeout_minutes\"`\n}\n\ntype ExecConfig struct {\n\tEnableDenyPatterns bool     `json:\"enable_deny_patterns\"`\n\tCustomDenyPatterns []string `json:\"custom_deny_patterns\"`\n}\n\nfunc (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {\n\tchannels := ChannelsConfig{}\n\n\tif c.Channels == nil {\n\t\treturn channels\n\t}\n\n\tif c.Channels.Telegram != nil {\n\t\tenabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled\n\t\tuseMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2\n\t\tchannels.Telegram = TelegramConfig{\n\t\t\tEnabled:       enabled,\n\t\t\tAllowFrom:     c.Channels.Telegram.AllowFrom,\n\t\t\tUseMarkdownV2: useMarkdownV2,\n\t\t}\n\t\tif c.Channels.Telegram.BotToken != nil {\n\t\t\tchannels.Telegram.Token = *c.Channels.Telegram.BotToken\n\t\t}\n\t}\n\n\tif c.Channels.Discord != nil {\n\t\tenabled := c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled\n\t\tchannels.Discord = DiscordConfig{\n\t\t\tEnabled:   enabled,\n\t\t\tAllowFrom: c.Channels.Discord.AllowFrom,\n\t\t}\n\t\tif c.Channels.Discord.Token != nil {\n\t\t\tchannels.Discord.Token = *c.Channels.Discord.Token\n\t\t}\n\t}\n\n\tif c.Channels.Slack != nil {\n\t\tenabled := c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled\n\t\tchannels.Slack = SlackConfig{\n\t\t\tEnabled:   enabled,\n\t\t\tAllowFrom: c.Channels.Slack.AllowFrom,\n\t\t}\n\t\tif c.Channels.Slack.BotToken != nil {\n\t\t\tchannels.Slack.BotToken = *c.Channels.Slack.BotToken\n\t\t}\n\t\tif c.Channels.Slack.AppToken != nil {\n\t\t\tchannels.Slack.AppToken = *c.Channels.Slack.AppToken\n\t\t}\n\t}\n\n\tif c.Channels.WhatsApp != nil {\n\t\tenabled := c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled\n\t\tchannels.WhatsApp = WhatsAppConfig{\n\t\t\tEnabled:   enabled,\n\t\t\tAllowFrom: c.Channels.WhatsApp.AllowFrom,\n\t\t}\n\t\tif c.Channels.WhatsApp.BridgeURL != nil {\n\t\t\tchannels.WhatsApp.BridgeURL = *c.Channels.WhatsApp.BridgeURL\n\t\t}\n\t}\n\n\tif c.Channels.Feishu != nil {\n\t\tenabled := c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled\n\t\tchannels.Feishu = FeishuConfig{\n\t\t\tEnabled:   enabled,\n\t\t\tAllowFrom: c.Channels.Feishu.AllowFrom,\n\t\t}\n\t\tif c.Channels.Feishu.AppID != nil {\n\t\t\tchannels.Feishu.AppID = *c.Channels.Feishu.AppID\n\t\t}\n\t\tif c.Channels.Feishu.AppSecret != nil {\n\t\t\tchannels.Feishu.AppSecret = *c.Channels.Feishu.AppSecret\n\t\t}\n\t\tif c.Channels.Feishu.EncryptKey != nil {\n\t\t\tchannels.Feishu.EncryptKey = *c.Channels.Feishu.EncryptKey\n\t\t}\n\t\tif c.Channels.Feishu.VerificationToken != nil {\n\t\t\tchannels.Feishu.VerificationToken = *c.Channels.Feishu.VerificationToken\n\t\t}\n\t}\n\n\tif c.Channels.QQ != nil && supportedChannels[\"qq\"] {\n\t\tchannels.QQ = QQConfig{\n\t\t\tEnabled:   true,\n\t\t\tAllowFrom: c.Channels.QQ.AllowFrom,\n\t\t}\n\t\tif c.Channels.QQ.AppID != nil {\n\t\t\tchannels.QQ.AppID = *c.Channels.QQ.AppID\n\t\t}\n\t\tif c.Channels.QQ.AppSecret != nil {\n\t\t\tchannels.QQ.AppSecret = *c.Channels.QQ.AppSecret\n\t\t}\n\t}\n\n\tif c.Channels.DingTalk != nil && supportedChannels[\"dingtalk\"] {\n\t\tchannels.DingTalk = DingTalkConfig{\n\t\t\tEnabled:   true,\n\t\t\tAllowFrom: c.Channels.DingTalk.AllowFrom,\n\t\t}\n\t\tif c.Channels.DingTalk.AppID != nil {\n\t\t\tchannels.DingTalk.ClientID = *c.Channels.DingTalk.AppID\n\t\t}\n\t\tif c.Channels.DingTalk.AppSecret != nil {\n\t\t\tchannels.DingTalk.ClientSecret = *c.Channels.DingTalk.AppSecret\n\t\t}\n\t}\n\n\tif c.Channels.MaixCam != nil && supportedChannels[\"maixcam\"] {\n\t\tchannels.MaixCam = MaixCamConfig{\n\t\t\tEnabled:   true,\n\t\t\tAllowFrom: c.Channels.MaixCam.AllowFrom,\n\t\t}\n\t\tif c.Channels.MaixCam.Host != nil {\n\t\t\tchannels.MaixCam.Host = *c.Channels.MaixCam.Host\n\t\t}\n\t\tif c.Channels.MaixCam.Port != nil {\n\t\t\tchannels.MaixCam.Port = *c.Channels.MaixCam.Port\n\t\t}\n\t}\n\n\tif c.Channels.Matrix != nil && supportedChannels[\"matrix\"] {\n\t\tenabled := c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled\n\t\tchannels.Matrix = MatrixConfig{\n\t\t\tEnabled:   enabled,\n\t\t\tAllowFrom: c.Channels.Matrix.AllowFrom,\n\t\t}\n\t\tif c.Channels.Matrix.Homeserver != nil {\n\t\t\tchannels.Matrix.Homeserver = *c.Channels.Matrix.Homeserver\n\t\t}\n\t\tif c.Channels.Matrix.UserID != nil {\n\t\t\tchannels.Matrix.UserID = *c.Channels.Matrix.UserID\n\t\t}\n\t\tif c.Channels.Matrix.AccessToken != nil {\n\t\t\tchannels.Matrix.AccessToken = *c.Channels.Matrix.AccessToken\n\t\t}\n\t}\n\n\tif c.Channels.Signal != nil {\n\t\t*warnings = append(*warnings, \"Channel 'signal': No PicoClaw adapter available\")\n\t}\n\tif c.Channels.IRC != nil {\n\t\t*warnings = append(*warnings, \"Channel 'irc': No PicoClaw adapter available\")\n\t}\n\tif c.Channels.Mattermost != nil {\n\t\t*warnings = append(*warnings, \"Channel 'mattermost': No PicoClaw adapter available\")\n\t}\n\tif c.Channels.IMessage != nil {\n\t\t*warnings = append(*warnings, \"Channel 'imessage': macOS-only channel - requires manual setup\")\n\t}\n\tif c.Channels.BlueBubbles != nil {\n\t\t*warnings = append(\n\t\t\t*warnings,\n\t\t\t\"Channel 'bluebubbles': No PicoClaw adapter available - consider iMessage instead\",\n\t\t)\n\t}\n\n\treturn channels\n}\n\nfunc (c *OpenClawConfig) convertAgents(warnings *[]string) []AgentConfig {\n\tvar agents []AgentConfig\n\n\tif c.Agents == nil {\n\t\treturn agents\n\t}\n\n\tfor _, entry := range c.Agents.List {\n\t\tagentID := entry.ID\n\t\tif agentID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tagentName := agentID\n\t\tif entry.Name != nil {\n\t\t\tagentName = *entry.Name\n\t\t}\n\n\t\tagentCfg := AgentConfig{\n\t\t\tID:      agentID,\n\t\t\tName:    agentName,\n\t\t\tDefault: len(agents) == 0,\n\t\t}\n\n\t\tif entry.Workspace != nil {\n\t\t\tagentCfg.Workspace = rewriteWorkspacePath(*entry.Workspace)\n\t\t}\n\n\t\tif entry.Model != nil {\n\t\t\tprimary := entry.Model.GetPrimary()\n\t\t\tif primary != \"\" {\n\t\t\t\tagentCfg.Model = &AgentModelConfig{\n\t\t\t\t\tPrimary:   primary,\n\t\t\t\t\tFallbacks: entry.Model.GetFallbacks(),\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(entry.Skills) > 0 {\n\t\t\tagentCfg.Skills = entry.Skills\n\t\t}\n\n\t\tagents = append(agents, agentCfg)\n\t}\n\n\treturn agents\n}\n\nfunc (c *PicoClawConfig) ToStandardConfig() *config.Config {\n\tcfg := config.DefaultConfig()\n\n\tcfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace\n\tcfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider\n\tcfg.Agents.Defaults.ModelName = c.Agents.Defaults.ModelName\n\tcfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks\n\n\tfor _, m := range c.ModelList {\n\t\tcfg.ModelList = append(cfg.ModelList, config.ModelConfig{\n\t\t\tModelName: m.ModelName,\n\t\t\tModel:     m.Model,\n\t\t\tAPIBase:   m.APIBase,\n\t\t\tAPIKey:    m.APIKey,\n\t\t\tProxy:     m.Proxy,\n\t\t})\n\t}\n\n\tcfg.Channels = c.Channels.ToStandardChannels()\n\tcfg.Gateway = c.Gateway.ToStandardGateway()\n\tcfg.Tools = c.Tools.ToStandardTools()\n\n\tcfg.Agents.List = make([]config.AgentConfig, len(c.Agents.List))\n\tfor i, a := range c.Agents.List {\n\t\tcfg.Agents.List[i] = config.AgentConfig{\n\t\t\tID:        a.ID,\n\t\t\tDefault:   a.Default,\n\t\t\tName:      a.Name,\n\t\t\tWorkspace: a.Workspace,\n\t\t\tSkills:    a.Skills,\n\t\t}\n\t\tif a.Model != nil {\n\t\t\tcfg.Agents.List[i].Model = &config.AgentModelConfig{\n\t\t\t\tPrimary:   a.Model.Primary,\n\t\t\t\tFallbacks: a.Model.Fallbacks,\n\t\t\t}\n\t\t}\n\t}\n\n\treturn cfg\n}\n\nfunc (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {\n\treturn config.ChannelsConfig{\n\t\tWhatsApp: config.WhatsAppConfig{\n\t\t\tEnabled:   c.WhatsApp.Enabled,\n\t\t\tBridgeURL: c.WhatsApp.BridgeURL,\n\t\t},\n\t\tTelegram: config.TelegramConfig{\n\t\t\tEnabled: c.Telegram.Enabled,\n\t\t\tToken:   c.Telegram.Token,\n\t\t\tProxy:   c.Telegram.Proxy,\n\t\t},\n\t\tFeishu: config.FeishuConfig{\n\t\t\tEnabled:           c.Feishu.Enabled,\n\t\t\tAppID:             c.Feishu.AppID,\n\t\t\tAppSecret:         c.Feishu.AppSecret,\n\t\t\tEncryptKey:        c.Feishu.EncryptKey,\n\t\t\tVerificationToken: c.Feishu.VerificationToken,\n\t\t},\n\t\tDiscord: config.DiscordConfig{\n\t\t\tEnabled:     c.Discord.Enabled,\n\t\t\tToken:       c.Discord.Token,\n\t\t\tMentionOnly: c.Discord.MentionOnly,\n\t\t},\n\t\tMaixCam: config.MaixCamConfig{\n\t\t\tEnabled: c.MaixCam.Enabled,\n\t\t\tHost:    c.MaixCam.Host,\n\t\t\tPort:    c.MaixCam.Port,\n\t\t},\n\t\tQQ: config.QQConfig{\n\t\t\tEnabled:   c.QQ.Enabled,\n\t\t\tAppID:     c.QQ.AppID,\n\t\t\tAppSecret: c.QQ.AppSecret,\n\t\t},\n\t\tDingTalk: config.DingTalkConfig{\n\t\t\tEnabled:      c.DingTalk.Enabled,\n\t\t\tClientID:     c.DingTalk.ClientID,\n\t\t\tClientSecret: c.DingTalk.ClientSecret,\n\t\t},\n\t\tSlack: config.SlackConfig{\n\t\t\tEnabled:  c.Slack.Enabled,\n\t\t\tBotToken: c.Slack.BotToken,\n\t\t\tAppToken: c.Slack.AppToken,\n\t\t},\n\t\tMatrix: config.MatrixConfig{\n\t\t\tEnabled:      c.Matrix.Enabled,\n\t\t\tHomeserver:   c.Matrix.Homeserver,\n\t\t\tUserID:       c.Matrix.UserID,\n\t\t\tAccessToken:  c.Matrix.AccessToken,\n\t\t\tAllowFrom:    c.Matrix.AllowFrom,\n\t\t\tJoinOnInvite: true,\n\t\t},\n\t\tLINE: config.LINEConfig{\n\t\t\tEnabled:            c.LINE.Enabled,\n\t\t\tChannelSecret:      c.LINE.ChannelSecret,\n\t\t\tChannelAccessToken: c.LINE.ChannelAccessToken,\n\t\t\tWebhookHost:        c.LINE.WebhookHost,\n\t\t\tWebhookPort:        c.LINE.WebhookPort,\n\t\t\tWebhookPath:        c.LINE.WebhookPath,\n\t\t},\n\t}\n}\n\nfunc (c GatewayConfig) ToStandardGateway() config.GatewayConfig {\n\treturn config.GatewayConfig{\n\t\tHost: c.Host,\n\t\tPort: c.Port,\n\t}\n}\n\nfunc (c ToolsConfig) ToStandardTools() config.ToolsConfig {\n\treturn config.ToolsConfig{\n\t\tWeb: config.WebToolsConfig{\n\t\t\tBrave: config.BraveConfig{\n\t\t\t\tEnabled:    c.Web.Brave.Enabled,\n\t\t\t\tAPIKey:     c.Web.Brave.APIKey,\n\t\t\t\tAPIKeys:    c.Web.Brave.APIKeys,\n\t\t\t\tMaxResults: c.Web.Brave.MaxResults,\n\t\t\t},\n\t\t\tTavily: config.TavilyConfig{\n\t\t\t\tEnabled:    c.Web.Tavily.Enabled,\n\t\t\t\tAPIKey:     c.Web.Tavily.APIKey,\n\t\t\t\tBaseURL:    c.Web.Tavily.BaseURL,\n\t\t\t\tMaxResults: c.Web.Tavily.MaxResults,\n\t\t\t},\n\t\t\tDuckDuckGo: config.DuckDuckGoConfig{\n\t\t\t\tEnabled:    c.Web.DuckDuckGo.Enabled,\n\t\t\t\tMaxResults: c.Web.DuckDuckGo.MaxResults,\n\t\t\t},\n\t\t\tPerplexity: config.PerplexityConfig{\n\t\t\t\tEnabled:    c.Web.Perplexity.Enabled,\n\t\t\t\tAPIKey:     c.Web.Perplexity.APIKey,\n\t\t\t\tMaxResults: c.Web.Perplexity.MaxResults,\n\t\t\t},\n\t\t\tProxy: c.Web.Proxy,\n\t\t},\n\t\tCron: config.CronToolsConfig{\n\t\t\tExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes,\n\t\t},\n\t\tExec: config.ExecConfig{\n\t\t\tEnableDenyPatterns: c.Exec.EnableDenyPatterns,\n\t\t\tCustomDenyPatterns: c.Exec.CustomDenyPatterns,\n\t\t\tAllowRemote:        config.DefaultConfig().Tools.Exec.AllowRemote,\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/migrate/sources/openclaw/openclaw_config_test.go",
    "content": "package openclaw\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestLoadOpenClawConfig(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"model\": {\n\t\t\t\t\t\"primary\": \"anthropic/claude-sonnet-4-20250514\"\n\t\t\t\t},\n\t\t\t\t\"workspace\": \"~/.openclaw/workspace\"\n\t\t\t},\n\t\t\t\"list\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": \"main\",\n\t\t\t\t\t\"name\": \"Main Agent\",\n\t\t\t\t\t\"model\": {\n\t\t\t\t\t\t\"primary\": \"openai/gpt-4o\",\n\t\t\t\t\t\t\"fallbacks\": [\"claude-3-opus\"]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t\"channels\": {\n\t\t\t\"telegram\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"botToken\": \"test-token\",\n\t\t\t\t\"allowFrom\": [\"user1\", \"user2\"]\n\t\t\t},\n\t\t\t\"discord\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"token\": \"discord-token\"\n\t\t\t}\n\t\t},\n\t\t\"models\": {\n\t\t\t\"providers\": {\n\t\t\t\t\"anthropic\": {\n\t\t\t\t\t\"api_key\": \"sk-ant-test\",\n\t\t\t\t\t\"base_url\": \"https://api.anthropic.com\"\n\t\t\t\t},\n\t\t\t\t\"openai\": {\n\t\t\t\t\t\"api_key\": \"sk-test\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif cfg.Agents == nil {\n\t\tt.Error(\"agents should not be nil\")\n\t}\n\n\tif cfg.Agents.Defaults == nil {\n\t\tt.Error(\"agents.defaults should not be nil\")\n\t}\n\n\tprovider, model := cfg.GetDefaultModel()\n\tif provider != \"anthropic\" {\n\t\tt.Errorf(\"expected provider 'anthropic', got '%s'\", provider)\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"expected model 'claude-sonnet-4-20250514', got '%s'\", model)\n\t}\n\n\tworkspace := cfg.GetDefaultWorkspace()\n\tif workspace != \"~/.picoclaw/workspace\" {\n\t\tt.Errorf(\"expected workspace '~/.picoclaw/workspace', got '%s'\", workspace)\n\t}\n\n\tagents := cfg.GetAgents()\n\tif len(agents) != 1 {\n\t\tt.Errorf(\"expected 1 agent, got %d\", len(agents))\n\t}\n\tif agents[0].ID != \"main\" {\n\t\tt.Errorf(\"expected agent id 'main', got '%s'\", agents[0].ID)\n\t}\n\n\tif cfg.Channels == nil {\n\t\tt.Error(\"channels should not be nil\")\n\t}\n\tif cfg.Channels.Telegram == nil {\n\t\tt.Error(\"telegram channel should not be nil\")\n\t}\n\tif cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != \"test-token\" {\n\t\tt.Error(\"telegram bot token not parsed correctly\")\n\t}\n}\n\nfunc TestGetProviderConfig(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"models\": {\n\t\t\t\"providers\": {\n\t\t\t\t\"anthropic\": {\n\t\t\t\t\t\"api_key\": \"sk-ant-test\",\n\t\t\t\t\t\"base_url\": \"https://api.anthropic.com\",\n\t\t\t\t\t\"max_tokens\": 4096\n\t\t\t\t},\n\t\t\t\t\"openai\": {\n\t\t\t\t\t\"api_key\": \"sk-test\",\n\t\t\t\t\t\"base_url\": \"https://api.openai.com\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tproviders := GetProviderConfig(cfg.Models)\n\tif len(providers) != 2 {\n\t\tt.Errorf(\"expected 2 providers, got %d\", len(providers))\n\t}\n\n\tif anthropic, ok := providers[\"anthropic\"]; ok {\n\t\tif anthropic.APIKey != \"sk-ant-test\" {\n\t\t\tt.Errorf(\"expected anthropic api_key 'sk-ant-test', got '%s'\", anthropic.APIKey)\n\t\t}\n\t\tif anthropic.BaseURL != \"https://api.anthropic.com\" {\n\t\t\tt.Errorf(\"expected anthropic base_url 'https://api.anthropic.com', got '%s'\", anthropic.BaseURL)\n\t\t}\n\t} else {\n\t\tt.Error(\"anthropic provider not found\")\n\t}\n\n\tif openai, ok := providers[\"openai\"]; ok {\n\t\tif openai.APIKey != \"sk-test\" {\n\t\t\tt.Errorf(\"expected openai api_key 'sk-test', got '%s'\", openai.APIKey)\n\t\t}\n\t} else {\n\t\tt.Error(\"openai provider not found\")\n\t}\n}\n\nfunc TestConvertToPicoClaw(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"model\": {\n\t\t\t\t\t\"primary\": \"anthropic/claude-sonnet-4-20250514\"\n\t\t\t\t},\n\t\t\t\t\"workspace\": \"~/.openclaw/workspace\"\n\t\t\t},\n\t\t\t\"list\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": \"main\",\n\t\t\t\t\t\"name\": \"Main Agent\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"id\": \"assistant\",\n\t\t\t\t\t\"name\": \"Assistant\",\n\t\t\t\t\t\"skills\": [\"skill1\", \"skill2\"]\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t\"channels\": {\n\t\t\t\"telegram\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"botToken\": \"test-token\",\n\t\t\t\t\"allowFrom\": [\"user1\", \"user2\"]\n\t\t\t},\n\t\t\t\"discord\": {\n\t\t\t\t\"enabled\": false,\n\t\t\t\t\"token\": \"discord-token\"\n\t\t\t},\n\t\t\t\"whatsapp\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"bridgeUrl\": \"http://localhost:3000\"\n\t\t\t},\n\t\t\t\"feishu\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"appId\": \"app-id\",\n\t\t\t\t\"appSecret\": \"app-secret\",\n\t\t\t\t\"allowFrom\": [\"user3\"]\n\t\t\t},\n\t\t\t\"signal\": {\n\t\t\t\t\"enabled\": true\n\t\t\t}\n\t\t},\n\t\t\"models\": {\n\t\t\t\"providers\": {\n\t\t\t\t\"anthropic\": {\n\t\t\t\t\t\"api_key\": \"sk-ant-test\"\n\t\t\t\t},\n\t\t\t\t\"openai\": {\n\t\t\t\t\t\"api_key\": \"sk-test\"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"skills\": {\n\t\t\t\"entries\": {\n\t\t\t\t\"skill1\": {}\n\t\t\t}\n\t\t},\n\t\t\"memory\": {\"enabled\": true},\n\t\t\"cron\": {\"enabled\": true}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tpicoCfg, warnings, err := cfg.ConvertToPicoClaw(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to convert config: %v\", err)\n\t}\n\n\tif picoCfg.Agents.Defaults.ModelName != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"expected model 'claude-sonnet-4-20250514', got '%s'\", picoCfg.Agents.Defaults.ModelName)\n\t}\n\tif picoCfg.Agents.Defaults.Workspace != \"~/.picoclaw/workspace\" {\n\t\tt.Errorf(\"expected workspace '~/.picoclaw/workspace', got '%s'\", picoCfg.Agents.Defaults.Workspace)\n\t}\n\n\tif len(picoCfg.Agents.List) != 2 {\n\t\tt.Errorf(\"expected 2 agents, got %d\", len(picoCfg.Agents.List))\n\t}\n\tif picoCfg.Agents.List[0].ID != \"main\" {\n\t\tt.Errorf(\"expected first agent id 'main', got '%s'\", picoCfg.Agents.List[0].ID)\n\t}\n\tif picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 {\n\t\tt.Errorf(\"expected 2 skills for assistant agent\")\n\t}\n\n\tif !picoCfg.Channels.Telegram.Enabled {\n\t\tt.Error(\"telegram should be enabled\")\n\t}\n\tif picoCfg.Channels.Telegram.Token != \"test-token\" {\n\t\tt.Errorf(\"expected telegram token 'test-token', got '%s'\", picoCfg.Channels.Telegram.Token)\n\t}\n\n\tif picoCfg.Channels.WhatsApp.BridgeURL != \"http://localhost:3000\" {\n\t\tt.Errorf(\"expected whatsapp bridge URL 'http://localhost:3000', got '%s'\", picoCfg.Channels.WhatsApp.BridgeURL)\n\t}\n\n\tif picoCfg.Channels.Feishu.AppID != \"app-id\" {\n\t\tt.Errorf(\"expected feishu app ID 'app-id', got '%s'\", picoCfg.Channels.Feishu.AppID)\n\t}\n\n\tif len(picoCfg.ModelList) != 1 {\n\t\tt.Errorf(\"expected 1 model config (no models.json provided), got %d\", len(picoCfg.ModelList))\n\t}\n\n\tfoundWarning := false\n\tfor _, w := range warnings {\n\t\tif len(w) > 0 {\n\t\t\tfoundWarning = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundWarning {\n\t\tt.Log(\"warnings should be generated for skills, memory, cron, and unsupported channels\")\n\t}\n}\n\nfunc TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) {\n\tcfg := (&PicoClawConfig{\n\t\tTools: ToolsConfig{\n\t\t\tExec: ExecConfig{\n\t\t\t\tEnableDenyPatterns: true,\n\t\t\t},\n\t\t},\n\t}).ToStandardConfig()\n\n\tif !cfg.Tools.Exec.AllowRemote {\n\t\tt.Fatal(\"ToStandardConfig() should preserve the default tools.exec.allow_remote=true\")\n\t}\n}\n\nfunc TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"model\": {\n\t\t\t\t\t\"primary\": \"anthropic/claude-sonnet-4-20250514\"\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t\"channels\": {\n\t\t\t\"qq\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"appId\": \"qq-app-id\",\n\t\t\t\t\"appSecret\": \"qq-app-secret\"\n\t\t\t},\n\t\t\t\"dingtalk\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"appId\": \"ding-app-id\",\n\t\t\t\t\"appSecret\": \"ding-app-secret\"\n\t\t\t},\n\t\t\t\"maixcam\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"host\": \"192.168.1.100\",\n\t\t\t\t\"port\": 9000\n\t\t\t},\n\t\t\t\"slack\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"botToken\": \"xoxb-test\",\n\t\t\t\t\"appToken\": \"xapp-test\"\n\t\t\t}\n\t\t}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tpicoCfg, _, err := cfg.ConvertToPicoClaw(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to convert config: %v\", err)\n\t}\n\n\tif !picoCfg.Channels.QQ.Enabled {\n\t\tt.Error(\"qq should be enabled\")\n\t}\n\tif picoCfg.Channels.QQ.AppID != \"qq-app-id\" {\n\t\tt.Errorf(\"expected qq app ID 'qq-app-id', got '%s'\", picoCfg.Channels.QQ.AppID)\n\t}\n\n\tif !picoCfg.Channels.DingTalk.Enabled {\n\t\tt.Error(\"dingtalk should be enabled\")\n\t}\n\tif picoCfg.Channels.DingTalk.ClientID != \"ding-app-id\" {\n\t\tt.Errorf(\"expected dingtalk client ID 'ding-app-id', got '%s'\", picoCfg.Channels.DingTalk.ClientID)\n\t}\n\n\tif !picoCfg.Channels.MaixCam.Enabled {\n\t\tt.Error(\"maixcam should be enabled\")\n\t}\n\tif picoCfg.Channels.MaixCam.Host != \"192.168.1.100\" {\n\t\tt.Errorf(\"expected maixcam host '192.168.1.100', got '%s'\", picoCfg.Channels.MaixCam.Host)\n\t}\n\tif picoCfg.Channels.MaixCam.Port != 9000 {\n\t\tt.Errorf(\"expected maixcam port 9000, got %d\", picoCfg.Channels.MaixCam.Port)\n\t}\n\n\tif !picoCfg.Channels.Slack.Enabled {\n\t\tt.Error(\"slack should be enabled\")\n\t}\n\tif picoCfg.Channels.Slack.BotToken != \"xoxb-test\" {\n\t\tt.Errorf(\"expected slack bot token 'xoxb-test', got '%s'\", picoCfg.Channels.Slack.BotToken)\n\t}\n\tif picoCfg.Channels.Slack.AppToken != \"xapp-test\" {\n\t\tt.Errorf(\"expected slack app token 'xapp-test', got '%s'\", picoCfg.Channels.Slack.AppToken)\n\t}\n}\n\nfunc TestConvertToPicoClawWithMatrix(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"channels\": {\n\t\t\t\"matrix\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"homeserver\": \"https://matrix.example.com\",\n\t\t\t\t\"userId\": \"@bot:matrix.example.com\",\n\t\t\t\t\"accessToken\": \"syt_test_token\",\n\t\t\t\t\"allowFrom\": [\"@alice:matrix.example.com\"]\n\t\t\t}\n\t\t}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tpicoCfg, warnings, err := cfg.ConvertToPicoClaw(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to convert config: %v\", err)\n\t}\n\n\tif !picoCfg.Channels.Matrix.Enabled {\n\t\tt.Error(\"matrix should be enabled\")\n\t}\n\tif picoCfg.Channels.Matrix.Homeserver != \"https://matrix.example.com\" {\n\t\tt.Errorf(\"expected matrix homeserver, got %q\", picoCfg.Channels.Matrix.Homeserver)\n\t}\n\tif picoCfg.Channels.Matrix.UserID != \"@bot:matrix.example.com\" {\n\t\tt.Errorf(\"expected matrix user_id, got %q\", picoCfg.Channels.Matrix.UserID)\n\t}\n\tif picoCfg.Channels.Matrix.AccessToken != \"syt_test_token\" {\n\t\tt.Errorf(\"expected matrix access_token, got %q\", picoCfg.Channels.Matrix.AccessToken)\n\t}\n\tif len(picoCfg.Channels.Matrix.AllowFrom) != 1 ||\n\t\tpicoCfg.Channels.Matrix.AllowFrom[0] != \"@alice:matrix.example.com\" {\n\t\tt.Errorf(\"unexpected matrix allow_from: %#v\", picoCfg.Channels.Matrix.AllowFrom)\n\t}\n\n\tfor _, w := range warnings {\n\t\tif strings.Contains(w, \"Channel 'matrix'\") {\n\t\t\tt.Fatalf(\"matrix should no longer be reported as unsupported, warning=%q\", w)\n\t\t}\n\t}\n}\n\nfunc TestConvertToPicoClawWithMatrixDisabled(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\n\t\t\"channels\": {\n\t\t\t\"matrix\": {\n\t\t\t\t\"enabled\": false,\n\t\t\t\t\"homeserver\": \"https://matrix.example.com\",\n\t\t\t\t\"userId\": \"@bot:matrix.example.com\",\n\t\t\t\t\"accessToken\": \"syt_test_token\"\n\t\t\t}\n\t\t}\n\t}`\n\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tpicoCfg, _, err := cfg.ConvertToPicoClaw(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to convert config: %v\", err)\n\t}\n\n\tif picoCfg.Channels.Matrix.Enabled {\n\t\tt.Error(\"matrix should respect enabled=false from source config\")\n\t}\n}\n\nfunc TestOpenClawAgentModel(t *testing.T) {\n\tmodel := &OpenClawAgentModel{\n\t\tPrimary:   strPtr(\"anthropic/claude-3-opus\"),\n\t\tFallbacks: []string{\"claude-3-sonnet\", \"claude-3-haiku\"},\n\t}\n\n\tprimary := model.GetPrimary()\n\tif primary != \"anthropic/claude-3-opus\" {\n\t\tt.Errorf(\"expected primary 'anthropic/claude-3-opus', got '%s'\", primary)\n\t}\n\n\tfallbacks := model.GetFallbacks()\n\tif len(fallbacks) != 2 {\n\t\tt.Errorf(\"expected 2 fallbacks, got %d\", len(fallbacks))\n\t}\n\n\tmodel2 := &OpenClawAgentModel{\n\t\tSimple: \"claude-3-opus\",\n\t}\n\n\tprimary2 := model2.GetPrimary()\n\tif primary2 != \"claude-3-opus\" {\n\t\tt.Errorf(\"expected primary 'claude-3-opus' from Simple, got '%s'\", primary2)\n\t}\n}\n\nfunc TestChannelEnabled(t *testing.T) {\n\tcfg := &OpenClawConfig{\n\t\tChannels: &OpenClawChannels{\n\t\t\tTelegram: &OpenClawTelegramConfig{\n\t\t\t\tEnabled: boolPtr(true),\n\t\t\t},\n\t\t\tDiscord: &OpenClawDiscordConfig{\n\t\t\t\tEnabled: boolPtr(false),\n\t\t\t},\n\t\t\tSlack: &OpenClawSlackConfig{\n\t\t\t\tEnabled: boolPtr(true),\n\t\t\t},\n\t\t},\n\t}\n\n\tif !cfg.IsChannelEnabled(\"telegram\") {\n\t\tt.Error(\"telegram should be enabled\")\n\t}\n\tif cfg.IsChannelEnabled(\"discord\") {\n\t\tt.Error(\"discord should be disabled\")\n\t}\n\tif !cfg.IsChannelEnabled(\"slack\") {\n\t\tt.Error(\"slack should be enabled (explicitly set)\")\n\t}\n\tif !cfg.IsChannelEnabled(\"matrix\") {\n\t\tt.Error(\"matrix should be enabled (nil config defaults to enabled)\")\n\t}\n\tif cfg.IsChannelEnabled(\"line\") {\n\t\tt.Error(\"line should return false (not in switch cases)\")\n\t}\n}\n\nfunc TestGetDefaultModel(t *testing.T) {\n\tcfg := &OpenClawConfig{\n\t\tAgents: &OpenClawAgents{\n\t\t\tDefaults: &OpenClawAgentDefaults{\n\t\t\t\tModel: &OpenClawAgentModel{\n\t\t\t\t\tPrimary: strPtr(\"openai/gpt-4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tprovider, model := cfg.GetDefaultModel()\n\tif provider != \"openai\" {\n\t\tt.Errorf(\"expected provider 'openai', got '%s'\", provider)\n\t}\n\tif model != \"gpt-4\" {\n\t\tt.Errorf(\"expected model 'gpt-4', got '%s'\", model)\n\t}\n}\n\nfunc TestGetDefaultModelWithNoDefaults(t *testing.T) {\n\tcfg := &OpenClawConfig{}\n\n\tprovider, model := cfg.GetDefaultModel()\n\tif provider != \"anthropic\" {\n\t\tt.Errorf(\"expected default provider 'anthropic', got '%s'\", provider)\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"expected default model 'claude-sonnet-4-20250514', got '%s'\", model)\n\t}\n}\n\nfunc TestHasFunctions(t *testing.T) {\n\tcfg := &OpenClawConfig{\n\t\tSkills:  &OpenClawSkills{Entries: map[string]json.RawMessage{\"skill1\": nil}},\n\t\tMemory:  json.RawMessage(`{\"enabled\": true}`),\n\t\tCron:    json.RawMessage(`{\"enabled\": true}`),\n\t\tHooks:   json.RawMessage(`{\"enabled\": true}`),\n\t\tSession: json.RawMessage(`{\"enabled\": true}`),\n\t\tAuth:    &OpenClawAuth{Profiles: json.RawMessage(`{\"profile1\": {}}`)},\n\t}\n\n\tif !cfg.HasSkills() {\n\t\tt.Error(\"should have skills\")\n\t}\n\tif !cfg.HasMemory() {\n\t\tt.Error(\"should have memory\")\n\t}\n\tif !cfg.HasCron() {\n\t\tt.Error(\"should have cron\")\n\t}\n\tif !cfg.HasHooks() {\n\t\tt.Error(\"should have hooks\")\n\t}\n\tif !cfg.HasSession() {\n\t\tt.Error(\"should have session\")\n\t}\n\tif !cfg.HasAuthProfiles() {\n\t\tt.Error(\"should have auth profiles\")\n\t}\n\n\tcfg2 := &OpenClawConfig{}\n\tif cfg2.HasSkills() {\n\t\tt.Error(\"should not have skills\")\n\t}\n\tif cfg2.HasMemory() {\n\t\tt.Error(\"should not have memory\")\n\t}\n}\n\nfunc TestLoadOpenClawConfigFromDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\n\ttestConfig := `{\"agents\": {}}`\n\terr := os.WriteFile(configPath, []byte(testConfig), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write test config: %v\", err)\n\t}\n\n\tcfg, err := LoadOpenClawConfigFromDir(tmpDir)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to load config from dir: %v\", err)\n\t}\n\n\tif cfg.Agents == nil {\n\t\tt.Error(\"agents should not be nil\")\n\t}\n\n\t_, err = LoadOpenClawConfigFromDir(\"/nonexistent/dir\")\n\tif err == nil {\n\t\tt.Error(\"should return error for nonexistent dir\")\n\t}\n}\n\nfunc TestToStandardConfig(t *testing.T) {\n\tpicoCfg := &PicoClawConfig{\n\t\tAgents: AgentsConfig{\n\t\t\tDefaults: AgentDefaults{\n\t\t\t\tProvider:  \"anthropic\",\n\t\t\t\tModelName: \"claude-sonnet-4-20250514\",\n\t\t\t\tWorkspace: \"~/.picoclaw/workspace\",\n\t\t\t},\n\t\t\tList: []AgentConfig{\n\t\t\t\t{\n\t\t\t\t\tID:      \"main\",\n\t\t\t\t\tName:    \"Main Agent\",\n\t\t\t\t\tDefault: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tModelList: []ModelConfig{\n\t\t\t{\n\t\t\t\tModelName: \"claude-sonnet-4-20250514\",\n\t\t\t\tModel:     \"anthropic/claude-sonnet-4-20250514\",\n\t\t\t\tAPIKey:    \"sk-ant-test\",\n\t\t\t},\n\t\t},\n\t\tChannels: ChannelsConfig{\n\t\t\tTelegram: TelegramConfig{\n\t\t\t\tEnabled:   true,\n\t\t\t\tToken:     \"test-token\",\n\t\t\t\tAllowFrom: []string{\"user1\"},\n\t\t\t},\n\t\t\tWhatsApp: WhatsAppConfig{\n\t\t\t\tEnabled:   true,\n\t\t\t\tBridgeURL: \"http://localhost:3000\",\n\t\t\t},\n\t\t},\n\t\tGateway: GatewayConfig{\n\t\t\tHost: \"0.0.0.0\",\n\t\t\tPort: 8080,\n\t\t},\n\t}\n\n\tstdCfg := picoCfg.ToStandardConfig()\n\n\tif stdCfg.Agents.Defaults.Provider != \"anthropic\" {\n\t\tt.Errorf(\"expected provider 'anthropic', got '%s'\", stdCfg.Agents.Defaults.Provider)\n\t}\n\tif stdCfg.Agents.Defaults.ModelName != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"expected model name 'claude-sonnet-4-20250514', got '%s'\", stdCfg.Agents.Defaults.ModelName)\n\t}\n\tif stdCfg.Agents.Defaults.Workspace != \"~/.picoclaw/workspace\" {\n\t\tt.Errorf(\"expected workspace '~/.picoclaw/workspace', got '%s'\", stdCfg.Agents.Defaults.Workspace)\n\t}\n\n\tif len(stdCfg.Agents.List) != 1 {\n\t\tt.Errorf(\"expected 1 agent, got %d\", len(stdCfg.Agents.List))\n\t}\n\tif stdCfg.Agents.List[0].ID != \"main\" {\n\t\tt.Errorf(\"expected agent id 'main', got '%s'\", stdCfg.Agents.List[0].ID)\n\t}\n\n\tfoundModel := false\n\tvar foundAPIKey string\n\tfor _, m := range stdCfg.ModelList {\n\t\tif m.ModelName == \"claude-sonnet-4-20250514\" {\n\t\t\tfoundModel = true\n\t\t\tfoundAPIKey = m.APIKey\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundModel {\n\t\tt.Error(\"expected to find claude-sonnet-4-20250514 model config\")\n\t}\n\tif foundAPIKey != \"sk-ant-test\" {\n\t\tt.Errorf(\"expected api key 'sk-ant-test', got '%s'\", foundAPIKey)\n\t}\n\n\tif !stdCfg.Channels.Telegram.Enabled {\n\t\tt.Error(\"telegram should be enabled\")\n\t}\n\tif stdCfg.Channels.Telegram.Token != \"test-token\" {\n\t\tt.Errorf(\"expected token 'test-token', got '%s'\", stdCfg.Channels.Telegram.Token)\n\t}\n\n\tif stdCfg.Gateway.Port != 8080 {\n\t\tt.Errorf(\"expected gateway port 8080, got %d\", stdCfg.Gateway.Port)\n\t}\n}\n\nfunc TestLoadProviderConfigFromAgentsDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\tagentsDir := filepath.Join(tmpDir, \"agents\", \"main\", \"agent\")\n\terr := os.MkdirAll(agentsDir, 0o755)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create agents dir: %v\", err)\n\t}\n\n\tmodelsJSON := `{\n\t\t\"providers\": {\n\t\t\t\"anthropic\": {\n\t\t\t\t\"baseUrl\": \"https://api.anthropic.com\",\n\t\t\t\t\"api\": \"anthropic\",\n\t\t\t\t\"apiKey\": \"sk-ant-from-models\",\n\t\t\t\t\"models\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"claude-sonnet-4-20250514\",\n\t\t\t\t\t\t\"name\": \"Claude Sonnet 4\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t\"openai\": {\n\t\t\t\t\"baseUrl\": \"https://api.openai.com\",\n\t\t\t\t\"api\": \"openai\",\n\t\t\t\t\"apiKey\": \"sk-from-models\",\n\t\t\t\t\"models\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"gpt-4o\",\n\t\t\t\t\t\t\"name\": \"GPT-4o\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t\"zhipu\": {\n\t\t\t\t\"baseUrl\": \"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\t\t\"api\": \"openai\",\n\t\t\t\t\"apiKey\": \"zhipu-key\",\n\t\t\t\t\"models\": []\n\t\t\t}\n\t\t}\n\t}`\n\n\terr = os.WriteFile(filepath.Join(agentsDir, \"models.json\"), []byte(modelsJSON), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to write models.json: %v\", err)\n\t}\n\n\tproviders := GetProviderConfigFromDir(tmpDir)\n\tif len(providers) != 3 {\n\t\tt.Errorf(\"expected 3 providers, got %d\", len(providers))\n\t}\n\n\tif anthropic, ok := providers[\"anthropic\"]; ok {\n\t\tif anthropic.ApiKey != \"sk-ant-from-models\" {\n\t\t\tt.Errorf(\"expected anthropic apiKey 'sk-ant-from-models', got '%s'\", anthropic.ApiKey)\n\t\t}\n\t\tif anthropic.BaseUrl != \"https://api.anthropic.com\" {\n\t\t\tt.Errorf(\"expected anthropic baseUrl 'https://api.anthropic.com', got '%s'\", anthropic.BaseUrl)\n\t\t}\n\t} else {\n\t\tt.Error(\"anthropic provider not found\")\n\t}\n\n\tif openai, ok := providers[\"openai\"]; ok {\n\t\tif openai.ApiKey != \"sk-from-models\" {\n\t\t\tt.Errorf(\"expected openai apiKey 'sk-from-models', got '%s'\", openai.ApiKey)\n\t\t}\n\t\tif openai.BaseUrl != \"https://api.openai.com\" {\n\t\t\tt.Errorf(\"expected openai baseUrl 'https://api.openai.com', got '%s'\", openai.BaseUrl)\n\t\t}\n\t} else {\n\t\tt.Error(\"openai provider not found\")\n\t}\n\n\tif zhipu, ok := providers[\"zhipu\"]; ok {\n\t\tif zhipu.ApiKey != \"zhipu-key\" {\n\t\t\tt.Errorf(\"expected zhipu apiKey 'zhipu-key', got '%s'\", zhipu.ApiKey)\n\t\t}\n\t\tif zhipu.BaseUrl != \"https://open.bigmodel.cn/api/paas/v4\" {\n\t\t\tt.Errorf(\"expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'\", zhipu.BaseUrl)\n\t\t}\n\t} else {\n\t\tt.Error(\"zhipu provider not found\")\n\t}\n}\n\nfunc TestGetProviderConfigFromDirNotExist(t *testing.T) {\n\tproviders := GetProviderConfigFromDir(\"/nonexistent/path\")\n\tif len(providers) != 0 {\n\t\tt.Errorf(\"expected 0 providers for nonexistent path, got %d\", len(providers))\n\t}\n}\n\nfunc strPtr(s string) *string {\n\treturn &s\n}\n\nfunc boolPtr(b bool) *bool {\n\treturn &b\n}\n"
  },
  {
    "path": "pkg/migrate/sources/openclaw/openclaw_handler.go",
    "content": "package openclaw\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/migrate/internal\"\n)\n\n// OpenclawHomeEnvVar is the environment variable that overrides the source\n// openclaw home directory when migrating from openclaw to picoclaw.\n// Default: ~/.openclaw\nconst OpenclawHomeEnvVar = \"OPENCLAW_HOME\"\n\nvar providerMapping = map[string]string{\n\t\"anthropic\":  \"anthropic\",\n\t\"claude\":     \"anthropic\",\n\t\"openai\":     \"openai\",\n\t\"gpt\":        \"openai\",\n\t\"groq\":       \"groq\",\n\t\"ollama\":     \"ollama\",\n\t\"openrouter\": \"openrouter\",\n\t\"deepseek\":   \"deepseek\",\n\t\"together\":   \"together\",\n\t\"mistral\":    \"mistral\",\n\t\"fireworks\":  \"fireworks\",\n\t\"google\":     \"google\",\n\t\"gemini\":     \"google\",\n\t\"xai\":        \"xai\",\n\t\"grok\":       \"xai\",\n\t\"cerebras\":   \"cerebras\",\n\t\"sambanova\":  \"sambanova\",\n}\n\ntype OpenclawHandler struct {\n\topts             Options\n\tsourceConfigFile string\n\tsourceWorkspace  string\n}\n\ntype (\n\tOptions   = internal.Options\n\tAction    = internal.Action\n\tResult    = internal.Result\n\tOperation = internal.Operation\n)\n\nfunc NewOpenclawHandler(opts Options) (Operation, error) {\n\thome, err := resolveSourceHome(opts.SourceHome)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\topts.SourceHome = home\n\n\tconfigFile, err := findSourceConfig(home)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &OpenclawHandler{\n\t\topts:             opts,\n\t\tsourceWorkspace:  filepath.Join(opts.SourceHome, \"workspace\"),\n\t\tsourceConfigFile: configFile,\n\t}, nil\n}\n\nfunc (o *OpenclawHandler) GetSourceName() string {\n\treturn \"openclaw\"\n}\n\nfunc (o *OpenclawHandler) GetSourceHome() (string, error) {\n\treturn o.opts.SourceHome, nil\n}\n\nfunc (o *OpenclawHandler) GetSourceWorkspace() (string, error) {\n\treturn o.sourceWorkspace, nil\n}\n\nfunc (o *OpenclawHandler) GetSourceConfigFile() (string, error) {\n\treturn o.sourceConfigFile, nil\n}\n\nfunc (o *OpenclawHandler) GetMigrateableFiles() []string {\n\treturn migrateableFiles\n}\n\nfunc (o *OpenclawHandler) GetMigrateableDirs() []string {\n\treturn migrateableDirs\n}\n\nfunc (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error {\n\topenclawCfg, err := LoadOpenClawConfig(srcConfigPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tpicoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, w := range warnings {\n\t\tfmt.Printf(\"  Warning: %s\\n\", w)\n\t}\n\n\tincoming := picoCfg.ToStandardConfig()\n\tif err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil {\n\t\treturn err\n\t}\n\n\treturn config.SaveConfig(dstConfigPath, incoming)\n}\n\nfunc resolveSourceHome(override string) (string, error) {\n\tif override != \"\" {\n\t\treturn internal.ExpandHome(override), nil\n\t}\n\tif envHome := os.Getenv(OpenclawHomeEnvVar); envHome != \"\" {\n\t\treturn internal.ExpandHome(envHome), nil\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolving home directory: %w\", err)\n\t}\n\treturn filepath.Join(home, \".openclaw\"), nil\n}\n\nfunc findSourceConfig(sourceHome string) (string, error) {\n\tcandidates := []string{\n\t\tfilepath.Join(sourceHome, \"openclaw.json\"),\n\t\tfilepath.Join(sourceHome, \"config.json\"),\n\t}\n\tfor _, p := range candidates {\n\t\tif _, err := os.Stat(p); err == nil {\n\t\t\treturn p, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"no config file found in %s (tried openclaw.json, config.json)\", sourceHome)\n}\n\nfunc rewriteWorkspacePath(path string) string {\n\tpath = strings.Replace(path, \".openclaw\", \".picoclaw\", 1)\n\treturn path\n}\n\nfunc mapProvider(provider string) string {\n\tif mapped, ok := providerMapping[strings.ToLower(provider)]; ok {\n\t\treturn mapped\n\t}\n\treturn strings.ToLower(provider)\n}\n"
  },
  {
    "path": "pkg/migrate/sources/openclaw/openclaw_handler_test.go",
    "content": "package openclaw\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewOpenclawHandler(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\trequire.NotNil(t, handler)\n}\n\nfunc TestNewOpenclawHandlerNoConfig(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t_, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.Error(t, err)\n}\n\nfunc TestOpenclawHandlerGetSourceName(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"openclaw\", handler.GetSourceName())\n}\n\nfunc TestOpenclawHandlerGetSourceHome(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\thome, err := handler.GetSourceHome()\n\trequire.NoError(t, err)\n\tassert.Equal(t, tmpDir, home)\n}\n\nfunc TestOpenclawHandlerGetSourceWorkspace(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tworkspace, err := handler.GetSourceWorkspace()\n\trequire.NoError(t, err)\n\tassert.Equal(t, filepath.Join(tmpDir, \"workspace\"), workspace)\n}\n\nfunc TestOpenclawHandlerGetSourceConfigFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tconfigFile, err := handler.GetSourceConfigFile()\n\trequire.NoError(t, err)\n\tassert.Equal(t, configPath, configFile)\n}\n\nfunc TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tconfigFile, err := handler.GetSourceConfigFile()\n\trequire.NoError(t, err)\n\tassert.Equal(t, configPath, configFile)\n}\n\nfunc TestOpenclawHandlerGetMigrateableFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tfiles := handler.GetMigrateableFiles()\n\tassert.NotEmpty(t, files)\n\tassert.Contains(t, files, \"AGENTS.md\")\n\tassert.Contains(t, files, \"SOUL.md\")\n\tassert.Contains(t, files, \"USER.md\")\n}\n\nfunc TestOpenclawHandlerGetMigrateableDirs(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\thandler, err := NewOpenclawHandler(Options{\n\t\tSourceHome: tmpDir,\n\t})\n\trequire.NoError(t, err)\n\n\tdirs := handler.GetMigrateableDirs()\n\tassert.NotEmpty(t, dirs)\n\tassert.Contains(t, dirs, \"memory\")\n\tassert.Contains(t, dirs, \"skills\")\n}\n\nfunc TestResolveSourceHome(t *testing.T) {\n\tresult, err := resolveSourceHome(\"/custom/path\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"/custom/path\", result)\n}\n\nfunc TestResolveSourceHomeWithEnvVar(t *testing.T) {\n\tt.Setenv(\"OPENCLAW_HOME\", \"/env/path\")\n\n\tresult, err := resolveSourceHome(\"\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"/env/path\", result)\n}\n\nfunc TestResolveSourceHomeWithTilde(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\tresult, err := resolveSourceHome(\"~/openclaw\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, filepath.Join(home, \"openclaw\"), result)\n}\n\nfunc TestFindSourceConfig(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"openclaw.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\tresult, err := findSourceConfig(tmpDir)\n\trequire.NoError(t, err)\n\tassert.Equal(t, configPath, result)\n}\n\nfunc TestFindSourceConfigWithConfigJson(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tconfigPath := filepath.Join(tmpDir, \"config.json\")\n\terr := os.WriteFile(configPath, []byte(\"{}\"), 0o644)\n\trequire.NoError(t, err)\n\n\tresult, err := findSourceConfig(tmpDir)\n\trequire.NoError(t, err)\n\tassert.Equal(t, configPath, result)\n}\n\nfunc TestFindSourceConfigNotFound(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t_, err := findSourceConfig(tmpDir)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"no config file found\")\n}\n\nfunc TestMapProvider(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"anthropic\", \"anthropic\"},\n\t\t{\"claude\", \"anthropic\"},\n\t\t{\"openai\", \"openai\"},\n\t\t{\"gpt\", \"openai\"},\n\t\t{\"groq\", \"groq\"},\n\t\t{\"ollama\", \"ollama\"},\n\t\t{\"openrouter\", \"openrouter\"},\n\t\t{\"deepseek\", \"deepseek\"},\n\t\t{\"together\", \"together\"},\n\t\t{\"mistral\", \"mistral\"},\n\t\t{\"fireworks\", \"fireworks\"},\n\t\t{\"google\", \"google\"},\n\t\t{\"gemini\", \"google\"},\n\t\t{\"xai\", \"xai\"},\n\t\t{\"grok\", \"xai\"},\n\t\t{\"cerebras\", \"cerebras\"},\n\t\t{\"sambanova\", \"sambanova\"},\n\t\t{\"unknown\", \"unknown\"},\n\t\t{\"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult := mapProvider(tt.input)\n\t\tassert.Equal(t, tt.expected, result, \"mapProvider(%q)\", tt.input)\n\t}\n}\n\nfunc TestRewriteWorkspacePath(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"~/.openclaw/workspace\", \"~/.picoclaw/workspace\"},\n\t\t{\"/home/user/.openclaw/workspace\", \"/home/user/.picoclaw/workspace\"},\n\t\t{\"/path/without/openclaw/change\", \"/path/without/openclaw/change\"},\n\t\t{\"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult := rewriteWorkspacePath(tt.input)\n\t\tassert.Equal(t, tt.expected, result, \"rewriteWorkspacePath(%q)\", tt.input)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/anthropic/provider.go",
    "content": "package anthropicprovider\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/anthropics/anthropic-sdk-go\"\n\t\"github.com/anthropics/anthropic-sdk-go/option\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\ntype (\n\tToolCall               = protocoltypes.ToolCall\n\tFunctionCall           = protocoltypes.FunctionCall\n\tLLMResponse            = protocoltypes.LLMResponse\n\tUsageInfo              = protocoltypes.UsageInfo\n\tMessage                = protocoltypes.Message\n\tToolDefinition         = protocoltypes.ToolDefinition\n\tToolFunctionDefinition = protocoltypes.ToolFunctionDefinition\n)\n\nconst (\n\tdefaultBaseURL      = \"https://api.anthropic.com\"\n\tanthropicBetaHeader = \"oauth-2025-04-20\"\n)\n\ntype Provider struct {\n\tclient      *anthropic.Client\n\ttokenSource func() (string, error)\n\tbaseURL     string\n}\n\n// SupportsThinking implements providers.ThinkingCapable.\nfunc (p *Provider) SupportsThinking() bool { return true }\n\nfunc NewProvider(token string) *Provider {\n\treturn NewProviderWithBaseURL(token, \"\")\n}\n\nfunc NewProviderWithBaseURL(token, apiBase string) *Provider {\n\tbaseURL := normalizeBaseURL(apiBase)\n\tclient := anthropic.NewClient(\n\t\toption.WithAuthToken(token),\n\t\toption.WithBaseURL(baseURL),\n\t)\n\treturn &Provider{\n\t\tclient:  &client,\n\t\tbaseURL: baseURL,\n\t}\n}\n\nfunc NewProviderWithClient(client *anthropic.Client) *Provider {\n\treturn &Provider{\n\t\tclient:  client,\n\t\tbaseURL: defaultBaseURL,\n\t}\n}\n\nfunc NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {\n\treturn NewProviderWithTokenSourceAndBaseURL(token, tokenSource, \"\")\n}\n\nfunc NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {\n\tp := NewProviderWithBaseURL(token, apiBase)\n\tp.tokenSource = tokenSource\n\treturn p\n}\n\nfunc (p *Provider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\tvar opts []option.RequestOption\n\tif p.tokenSource != nil {\n\t\ttok, err := p.tokenSource()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"refreshing token: %w\", err)\n\t\t}\n\t\topts = append(opts,\n\t\t\toption.WithAuthToken(tok),\n\t\t\toption.WithHeader(\"anthropic-beta\", anthropicBetaHeader),\n\t\t)\n\t}\n\n\tparams, err := buildParams(messages, tools, model, options)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// OAuth/setup-tokens require streaming; API keys use non-streaming.\n\tif p.tokenSource != nil {\n\t\treturn p.chatStreaming(ctx, params, opts)\n\t}\n\n\tresp, err := p.client.Messages.New(ctx, params, opts...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"claude API call: %w\", err)\n\t}\n\n\treturn parseResponse(resp), nil\n}\n\nfunc (p *Provider) chatStreaming(\n\tctx context.Context,\n\tparams anthropic.MessageNewParams,\n\topts []option.RequestOption,\n) (*LLMResponse, error) {\n\tstream := p.client.Messages.NewStreaming(ctx, params, opts...)\n\tdefer stream.Close()\n\n\tvar msg anthropic.Message\n\tfor stream.Next() {\n\t\tevent := stream.Current()\n\t\tif err := msg.Accumulate(event); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"claude streaming accumulate: %w\", err)\n\t\t}\n\t}\n\tif err := stream.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"claude API call: %w\", err)\n\t}\n\n\treturn parseResponse(&msg), nil\n}\n\nfunc (p *Provider) GetDefaultModel() string {\n\treturn \"claude-sonnet-4.6\"\n}\n\nfunc (p *Provider) BaseURL() string {\n\treturn p.baseURL\n}\n\nfunc buildParams(\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (anthropic.MessageNewParams, error) {\n\tvar system []anthropic.TextBlockParam\n\tvar anthropicMessages []anthropic.MessageParam\n\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\t// Prefer structured SystemParts for per-block cache_control.\n\t\t\t// This enables LLM-side KV cache reuse: the static block's prefix\n\t\t\t// hash stays stable across requests while dynamic parts change freely.\n\t\t\tif len(msg.SystemParts) > 0 {\n\t\t\t\tfor _, part := range msg.SystemParts {\n\t\t\t\t\tblock := anthropic.TextBlockParam{Text: part.Text}\n\t\t\t\t\tif part.CacheControl != nil && part.CacheControl.Type == \"ephemeral\" {\n\t\t\t\t\t\tblock.CacheControl = anthropic.NewCacheControlEphemeralParam()\n\t\t\t\t\t}\n\t\t\t\t\tsystem = append(system, block)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsystem = append(system, anthropic.TextBlockParam{Text: msg.Content})\n\t\t\t}\n\t\tcase \"user\":\n\t\t\tif msg.ToolCallID != \"\" {\n\t\t\t\tanthropicMessages = append(anthropicMessages,\n\t\t\t\t\tanthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tanthropicMessages = append(anthropicMessages,\n\t\t\t\t\tanthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),\n\t\t\t\t)\n\t\t\t}\n\t\tcase \"assistant\":\n\t\t\tif len(msg.ToolCalls) > 0 {\n\t\t\t\tvar blocks []anthropic.ContentBlockParamUnion\n\t\t\t\tif msg.Content != \"\" {\n\t\t\t\t\tblocks = append(blocks, anthropic.NewTextBlock(msg.Content))\n\t\t\t\t}\n\t\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\t\t// Skip tool calls with empty names to avoid API errors\n\t\t\t\t\tif tc.Name == \"\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\targs := tc.Arguments\n\t\t\t\t\tif args == nil && tc.Function != nil && tc.Function.Arguments != \"\" {\n\t\t\t\t\t\tif err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {\n\t\t\t\t\t\t\targs = map[string]any{}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif args == nil {\n\t\t\t\t\t\targs = map[string]any{}\n\t\t\t\t\t}\n\t\t\t\t\tblocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))\n\t\t\t\t}\n\t\t\t\tanthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))\n\t\t\t} else {\n\t\t\t\tanthropicMessages = append(anthropicMessages,\n\t\t\t\t\tanthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),\n\t\t\t\t)\n\t\t\t}\n\t\tcase \"tool\":\n\t\t\tanthropicMessages = append(anthropicMessages,\n\t\t\t\tanthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),\n\t\t\t)\n\t\t}\n\t}\n\n\tmaxTokens := int64(4096)\n\tif mt, ok := options[\"max_tokens\"].(int); ok {\n\t\tmaxTokens = int64(mt)\n\t}\n\n\t// Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6),\n\t// but config may use dots (claude-sonnet-4.6).\n\tapiModel := strings.ReplaceAll(model, \".\", \"-\")\n\n\tparams := anthropic.MessageNewParams{\n\t\tModel:     anthropic.Model(apiModel),\n\t\tMessages:  anthropicMessages,\n\t\tMaxTokens: maxTokens,\n\t}\n\n\tif len(system) > 0 {\n\t\tparams.System = system\n\t}\n\n\tif temp, ok := options[\"temperature\"].(float64); ok {\n\t\tparams.Temperature = anthropic.Float(temp)\n\t}\n\n\tif len(tools) > 0 {\n\t\tparams.Tools = translateTools(tools)\n\t}\n\n\t// Extended Thinking / Adaptive Thinking\n\t// The thinking_level value directly determines the API parameter format:\n\t//   \"adaptive\" → {thinking: {type: \"adaptive\"}} + output_config.effort\n\t//   \"low/medium/high/xhigh\" → {thinking: {type: \"enabled\", budget_tokens: N}}\n\tif level, ok := options[\"thinking_level\"].(string); ok && level != \"\" && level != \"off\" {\n\t\tapplyThinkingConfig(&params, level)\n\t}\n\n\treturn params, nil\n}\n\n// applyThinkingConfig sets thinking parameters based on the level value.\n// \"adaptive\" uses the adaptive thinking API (Claude 4.6+).\n// All other levels use budget_tokens which is universally supported.\n//\n// Anthropic API constraint: temperature must not be set when thinking is enabled.\n// budget_tokens must be strictly less than max_tokens.\nfunc applyThinkingConfig(params *anthropic.MessageNewParams, level string) {\n\t// Anthropic API rejects requests with temperature set alongside thinking.\n\t// Reset to zero value (omitted from JSON serialization).\n\tif params.Temperature.Valid() {\n\t\tlog.Printf(\"anthropic: temperature cleared because thinking is enabled (level=%s)\", level)\n\t}\n\tparams.Temperature = anthropic.MessageNewParams{}.Temperature\n\n\tif level == \"adaptive\" {\n\t\tadaptive := anthropic.NewThinkingConfigAdaptiveParam()\n\t\tparams.Thinking = anthropic.ThinkingConfigParamUnion{OfAdaptive: &adaptive}\n\t\tparams.OutputConfig = anthropic.OutputConfigParam{\n\t\t\tEffort: anthropic.OutputConfigEffortHigh,\n\t\t}\n\t\treturn\n\t}\n\n\tbudget := int64(levelToBudget(level))\n\tif budget <= 0 {\n\t\treturn\n\t}\n\n\t// budget_tokens must be < max_tokens; clamp to respect user's max_tokens setting.\n\tif budget >= params.MaxTokens {\n\t\tlog.Printf(\"anthropic: budget_tokens (%d) clamped to %d (max_tokens-1)\", budget, params.MaxTokens-1)\n\t\tbudget = params.MaxTokens - 1\n\t} else if budget > params.MaxTokens*80/100 {\n\t\tlog.Printf(\"anthropic: thinking budget (%d) exceeds 80%% of max_tokens (%d), output may be truncated\",\n\t\t\tbudget, params.MaxTokens)\n\t}\n\tparams.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)\n}\n\n// levelToBudget maps a thinking level to budget_tokens.\n// Values are based on Anthropic's recommendations and community best practices:\n//\n//\tlow    =  4,096  — simple reasoning, quick debugging (Claude Code \"think\")\n//\tmedium = 16,384  — Anthropic recommended sweet spot for most tasks\n//\thigh   = 32,000  — complex architecture, deep analysis (diminishing returns above this)\n//\txhigh  = 64,000  — extreme reasoning, research problems, benchmarks\n//\n// Note: For Claude 4.6+, prefer adaptive thinking over manual budget_tokens.\nfunc levelToBudget(level string) int {\n\tswitch level {\n\tcase \"low\":\n\t\treturn 4096\n\tcase \"medium\":\n\t\treturn 16384\n\tcase \"high\":\n\t\treturn 32000\n\tcase \"xhigh\":\n\t\treturn 64000\n\tdefault:\n\t\treturn 0\n\t}\n}\n\nfunc translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {\n\tresult := make([]anthropic.ToolUnionParam, 0, len(tools))\n\tfor _, t := range tools {\n\t\ttool := anthropic.ToolParam{\n\t\t\tName: t.Function.Name,\n\t\t\tInputSchema: anthropic.ToolInputSchemaParam{\n\t\t\t\tProperties: t.Function.Parameters[\"properties\"],\n\t\t\t},\n\t\t}\n\t\tif desc := t.Function.Description; desc != \"\" {\n\t\t\ttool.Description = anthropic.String(desc)\n\t\t}\n\t\tif req, ok := t.Function.Parameters[\"required\"].([]any); ok {\n\t\t\trequired := make([]string, 0, len(req))\n\t\t\tfor _, r := range req {\n\t\t\t\tif s, ok := r.(string); ok {\n\t\t\t\t\trequired = append(required, s)\n\t\t\t\t}\n\t\t\t}\n\t\t\ttool.InputSchema.Required = required\n\t\t}\n\t\tresult = append(result, anthropic.ToolUnionParam{OfTool: &tool})\n\t}\n\treturn result\n}\n\nfunc parseResponse(resp *anthropic.Message) *LLMResponse {\n\tvar content strings.Builder\n\tvar reasoning strings.Builder\n\tvar toolCalls []ToolCall\n\n\tfor _, block := range resp.Content {\n\t\tswitch block.Type {\n\t\tcase \"thinking\":\n\t\t\ttb := block.AsThinking()\n\t\t\treasoning.WriteString(tb.Thinking)\n\t\tcase \"text\":\n\t\t\ttb := block.AsText()\n\t\t\tcontent.WriteString(tb.Text)\n\t\tcase \"tool_use\":\n\t\t\ttu := block.AsToolUse()\n\t\t\tvar args map[string]any\n\t\t\tif err := json.Unmarshal(tu.Input, &args); err != nil {\n\t\t\t\tlog.Printf(\"anthropic: failed to decode tool call input for %q: %v\", tu.Name, err)\n\t\t\t\targs = map[string]any{\"raw\": string(tu.Input)}\n\t\t\t}\n\t\t\ttoolCalls = append(toolCalls, ToolCall{\n\t\t\t\tID:        tu.ID,\n\t\t\t\tName:      tu.Name,\n\t\t\t\tArguments: args,\n\t\t\t})\n\t\t}\n\t}\n\n\tfinishReason := \"stop\"\n\tswitch resp.StopReason {\n\tcase anthropic.StopReasonToolUse:\n\t\tfinishReason = \"tool_calls\"\n\tcase anthropic.StopReasonMaxTokens:\n\t\tfinishReason = \"length\"\n\tcase anthropic.StopReasonEndTurn:\n\t\tfinishReason = \"stop\"\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      content.String(),\n\t\tReasoning:    reasoning.String(),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: finishReason,\n\t\tUsage: &UsageInfo{\n\t\t\tPromptTokens:     int(resp.Usage.InputTokens),\n\t\t\tCompletionTokens: int(resp.Usage.OutputTokens),\n\t\t\tTotalTokens:      int(resp.Usage.InputTokens + resp.Usage.OutputTokens),\n\t\t},\n\t}\n}\n\nfunc normalizeBaseURL(apiBase string) string {\n\tbase := strings.TrimSpace(apiBase)\n\tif base == \"\" {\n\t\treturn defaultBaseURL\n\t}\n\n\tbase = strings.TrimRight(base, \"/\")\n\tif before, ok := strings.CutSuffix(base, \"/v1\"); ok {\n\t\tbase = before\n\t}\n\tif base == \"\" {\n\t\treturn defaultBaseURL\n\t}\n\n\treturn base\n}\n"
  },
  {
    "path": "pkg/providers/anthropic/provider_test.go",
    "content": "package anthropicprovider\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"github.com/anthropics/anthropic-sdk-go\"\n\tanthropicoption \"github.com/anthropics/anthropic-sdk-go/option\"\n)\n\nfunc TestBuildParams_BasicMessage(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}\n\tparams, err := buildParams(messages, nil, \"claude-sonnet-4.6\", map[string]any{\n\t\t\"max_tokens\": 1024,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"buildParams() error: %v\", err)\n\t}\n\tif string(params.Model) != \"claude-sonnet-4-6\" {\n\t\tt.Errorf(\"Model = %q, want %q\", params.Model, \"claude-sonnet-4-6\")\n\t}\n\tif params.MaxTokens != 1024 {\n\t\tt.Errorf(\"MaxTokens = %d, want 1024\", params.MaxTokens)\n\t}\n\tif len(params.Messages) != 1 {\n\t\tt.Fatalf(\"len(Messages) = %d, want 1\", len(params.Messages))\n\t}\n}\n\nfunc TestBuildParams_SystemMessage(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful\"},\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}\n\tparams, err := buildParams(messages, nil, \"claude-sonnet-4.6\", map[string]any{})\n\tif err != nil {\n\t\tt.Fatalf(\"buildParams() error: %v\", err)\n\t}\n\tif len(params.System) != 1 {\n\t\tt.Fatalf(\"len(System) = %d, want 1\", len(params.System))\n\t}\n\tif params.System[0].Text != \"You are helpful\" {\n\t\tt.Errorf(\"System[0].Text = %q, want %q\", params.System[0].Text, \"You are helpful\")\n\t}\n\tif len(params.Messages) != 1 {\n\t\tt.Fatalf(\"len(Messages) = %d, want 1\", len(params.Messages))\n\t}\n}\n\nfunc TestBuildParams_ToolCallMessage(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"What's the weather?\"},\n\t\t{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: \"\",\n\t\t\tToolCalls: []ToolCall{\n\t\t\t\t{\n\t\t\t\t\tID:        \"call_1\",\n\t\t\t\t\tName:      \"get_weather\",\n\t\t\t\t\tArguments: map[string]any{\"city\": \"SF\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{Role: \"tool\", Content: `{\"temp\": 72}`, ToolCallID: \"call_1\"},\n\t}\n\tparams, err := buildParams(messages, nil, \"claude-sonnet-4.6\", map[string]any{})\n\tif err != nil {\n\t\tt.Fatalf(\"buildParams() error: %v\", err)\n\t}\n\tif len(params.Messages) != 3 {\n\t\tt.Fatalf(\"len(Messages) = %d, want 3\", len(params.Messages))\n\t}\n}\n\nfunc TestBuildParams_WithTools(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"get_weather\",\n\t\t\t\tDescription: \"Get weather for a city\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\"city\": map[string]any{\"type\": \"string\"},\n\t\t\t\t\t},\n\t\t\t\t\t\"required\": []any{\"city\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tparams, err := buildParams([]Message{{Role: \"user\", Content: \"Hi\"}}, tools, \"claude-sonnet-4.6\", map[string]any{})\n\tif err != nil {\n\t\tt.Fatalf(\"buildParams() error: %v\", err)\n\t}\n\tif len(params.Tools) != 1 {\n\t\tt.Fatalf(\"len(Tools) = %d, want 1\", len(params.Tools))\n\t}\n}\n\nfunc TestParseResponse_TextOnly(t *testing.T) {\n\tresp := &anthropic.Message{\n\t\tContent: []anthropic.ContentBlockUnion{},\n\t\tUsage: anthropic.Usage{\n\t\t\tInputTokens:  10,\n\t\t\tOutputTokens: 20,\n\t\t},\n\t}\n\tresult := parseResponse(resp)\n\tif result.Usage.PromptTokens != 10 {\n\t\tt.Errorf(\"PromptTokens = %d, want 10\", result.Usage.PromptTokens)\n\t}\n\tif result.Usage.CompletionTokens != 20 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 20\", result.Usage.CompletionTokens)\n\t}\n\tif result.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", result.FinishReason, \"stop\")\n\t}\n}\n\nfunc TestParseResponse_StopReasons(t *testing.T) {\n\ttests := []struct {\n\t\tstopReason anthropic.StopReason\n\t\twant       string\n\t}{\n\t\t{anthropic.StopReasonEndTurn, \"stop\"},\n\t\t{anthropic.StopReasonMaxTokens, \"length\"},\n\t\t{anthropic.StopReasonToolUse, \"tool_calls\"},\n\t}\n\tfor _, tt := range tests {\n\t\tresp := &anthropic.Message{\n\t\t\tStopReason: tt.stopReason,\n\t\t}\n\t\tresult := parseResponse(resp)\n\t\tif result.FinishReason != tt.want {\n\t\t\tt.Errorf(\"StopReason %q: FinishReason = %q, want %q\", tt.stopReason, result.FinishReason, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestProvider_ChatRoundTrip(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/v1/messages\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer test-token\" {\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tjson.NewDecoder(r.Body).Decode(&reqBody)\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":          \"msg_test\",\n\t\t\t\"type\":        \"message\",\n\t\t\t\"role\":        \"assistant\",\n\t\t\t\"model\":       reqBody[\"model\"],\n\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\"content\": []map[string]any{\n\t\t\t\t{\"type\": \"text\", \"text\": \"Hello! How can I help you?\"},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":  15,\n\t\t\t\t\"output_tokens\": 8,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tprovider := NewProviderWithClient(createAnthropicTestClient(server.URL, \"test-token\"))\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"claude-sonnet-4.6\", map[string]any{\"max_tokens\": 1024})\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hello! How can I help you?\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello! How can I help you?\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage.PromptTokens != 15 {\n\t\tt.Errorf(\"PromptTokens = %d, want 15\", resp.Usage.PromptTokens)\n\t}\n}\n\nfunc TestProvider_GetDefaultModel(t *testing.T) {\n\tp := NewProvider(\"test-token\")\n\tif got := p.GetDefaultModel(); got != \"claude-sonnet-4.6\" {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, \"claude-sonnet-4.6\")\n\t}\n}\n\nfunc TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) {\n\tp := NewProviderWithBaseURL(\"token\", \"https://api.anthropic.com/v1/\")\n\tif got := p.BaseURL(); got != \"https://api.anthropic.com\" {\n\t\tt.Fatalf(\"BaseURL() = %q, want %q\", got, \"https://api.anthropic.com\")\n\t}\n}\n\nfunc TestProvider_ChatUsesTokenSource(t *testing.T) {\n\tvar requests int32\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/v1/messages\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tatomic.AddInt32(&requests, 1)\n\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer refreshed-token\" {\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tjson.NewDecoder(r.Body).Decode(&reqBody)\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":          \"msg_test\",\n\t\t\t\"type\":        \"message\",\n\t\t\t\"role\":        \"assistant\",\n\t\t\t\"model\":       reqBody[\"model\"],\n\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\"content\": []map[string]any{\n\t\t\t\t{\"type\": \"text\", \"text\": \"ok\"},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":  1,\n\t\t\t\t\"output_tokens\": 1,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProviderWithTokenSourceAndBaseURL(\"stale-token\", func() (string, error) {\n\t\treturn \"refreshed-token\", nil\n\t}, server.URL)\n\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hello\"}},\n\t\tnil,\n\t\t\"claude-sonnet-4.6\",\n\t\tmap[string]any{},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif got := atomic.LoadInt32(&requests); got != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", got)\n\t}\n}\n\nfunc TestProvider_ChatStreamingRoundTrip(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/v1/messages\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer refreshed-token\" {\n\t\t\tt.Errorf(\"Authorization = %q, want %q\", got, \"Bearer refreshed-token\")\n\t\t}\n\t\tif got := r.Header.Get(\"Anthropic-Beta\"); got != anthropicBetaHeader {\n\t\t\tt.Errorf(\"Anthropic-Beta = %q, want %q\", got, anthropicBetaHeader)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tflusher, _ := w.(http.Flusher)\n\n\t\tevents := []string{\n\t\t\t\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"id\\\":\\\"msg_stream\\\",\\\"type\\\":\\\"message\\\",\\\"role\\\":\\\"assistant\\\",\\\"content\\\":[],\\\"model\\\":\\\"claude-sonnet-4-6\\\",\\\"stop_reason\\\":null,\\\"usage\\\":{\\\"input_tokens\\\":12,\\\"output_tokens\\\":0}}}\\n\\n\",\n\t\t\t\"event: content_block_start\\ndata: {\\\"type\\\":\\\"content_block_start\\\",\\\"index\\\":0,\\\"content_block\\\":{\\\"type\\\":\\\"text\\\",\\\"text\\\":\\\"\\\"}}\\n\\n\",\n\t\t\t\"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\"Hello\\\"}}\\n\\n\",\n\t\t\t\"event: content_block_delta\\ndata: {\\\"type\\\":\\\"content_block_delta\\\",\\\"index\\\":0,\\\"delta\\\":{\\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\" world\\\"}}\\n\\n\",\n\t\t\t\"event: content_block_stop\\ndata: {\\\"type\\\":\\\"content_block_stop\\\",\\\"index\\\":0}\\n\\n\",\n\t\t\t\"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"delta\\\":{\\\"stop_reason\\\":\\\"end_turn\\\"},\\\"usage\\\":{\\\"output_tokens\\\":5}}\\n\\n\",\n\t\t\t\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\",\n\t\t}\n\t\tfor _, e := range events {\n\t\t\tw.Write([]byte(e))\n\t\t\tif flusher != nil {\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tp := NewProviderWithTokenSourceAndBaseURL(\"stale-token\", func() (string, error) {\n\t\treturn \"refreshed-token\", nil\n\t}, server.URL)\n\n\tresp, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"Hello\"}},\n\t\tnil,\n\t\t\"claude-sonnet-4.6\",\n\t\tmap[string]any{},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hello world\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello world\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage.CompletionTokens != 5 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 5\", resp.Usage.CompletionTokens)\n\t}\n}\n\nfunc createAnthropicTestClient(baseURL, token string) *anthropic.Client {\n\tc := anthropic.NewClient(\n\t\tanthropicoption.WithAuthToken(token),\n\t\tanthropicoption.WithBaseURL(baseURL),\n\t)\n\treturn &c\n}\n"
  },
  {
    "path": "pkg/providers/anthropic/thinking_test.go",
    "content": "package anthropicprovider\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/anthropics/anthropic-sdk-go\"\n)\n\nfunc TestApplyThinkingConfig_Adaptive(t *testing.T) {\n\tparams := anthropic.MessageNewParams{\n\t\tMaxTokens:   16000,\n\t\tTemperature: anthropic.Float(0.7),\n\t}\n\tapplyThinkingConfig(&params, \"adaptive\")\n\n\tif params.Thinking.OfAdaptive == nil {\n\t\tt.Fatal(\"expected adaptive thinking\")\n\t}\n\tif params.Thinking.OfEnabled != nil {\n\t\tt.Error(\"should not set enabled thinking in adaptive mode\")\n\t}\n\tif params.OutputConfig.Effort != anthropic.OutputConfigEffortHigh {\n\t\tt.Errorf(\"effort = %q, want %q\", params.OutputConfig.Effort, anthropic.OutputConfigEffortHigh)\n\t}\n\tif params.Temperature.Valid() {\n\t\tt.Error(\"temperature should be cleared when thinking is enabled\")\n\t}\n}\n\nfunc TestApplyThinkingConfig_BudgetLevels(t *testing.T) {\n\ttests := []struct {\n\t\tlevel      string\n\t\twantBudget int64\n\t}{\n\t\t{\"low\", 4096},\n\t\t{\"medium\", 16384},\n\t\t{\"high\", 32000},\n\t\t{\"xhigh\", 64000},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.level, func(t *testing.T) {\n\t\t\tparams := anthropic.MessageNewParams{\n\t\t\t\tMaxTokens:   200000,\n\t\t\t\tTemperature: anthropic.Float(0.5),\n\t\t\t}\n\t\t\tapplyThinkingConfig(&params, tt.level)\n\n\t\t\tif params.Thinking.OfEnabled == nil {\n\t\t\t\tt.Fatal(\"expected enabled thinking\")\n\t\t\t}\n\t\t\tif params.Thinking.OfAdaptive != nil {\n\t\t\t\tt.Error(\"should not set adaptive thinking\")\n\t\t\t}\n\t\t\tif params.Thinking.OfEnabled.BudgetTokens != tt.wantBudget {\n\t\t\t\tt.Errorf(\"budget_tokens = %d, want %d\", params.Thinking.OfEnabled.BudgetTokens, tt.wantBudget)\n\t\t\t}\n\t\t\tif params.OutputConfig.Effort != \"\" {\n\t\t\t\tt.Errorf(\"effort = %q, want empty\", params.OutputConfig.Effort)\n\t\t\t}\n\t\t\tif params.Temperature.Valid() {\n\t\t\t\tt.Error(\"temperature should be cleared when thinking is enabled\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyThinkingConfig_BudgetClamp(t *testing.T) {\n\t// budget_tokens must be < max_tokens; clamp budget down to respect user's max_tokens.\n\tparams := anthropic.MessageNewParams{MaxTokens: 4096}\n\tapplyThinkingConfig(&params, \"high\") // budget=32000 > maxTokens=4096\n\n\tif params.Thinking.OfEnabled == nil {\n\t\tt.Fatal(\"expected enabled thinking\")\n\t}\n\tif params.Thinking.OfEnabled.BudgetTokens != 4095 {\n\t\tt.Errorf(\"budget_tokens = %d, want 4095 (maxTokens-1)\", params.Thinking.OfEnabled.BudgetTokens)\n\t}\n\tif params.MaxTokens != 4096 {\n\t\tt.Errorf(\"max_tokens should not be modified, got %d\", params.MaxTokens)\n\t}\n}\n\nfunc TestApplyThinkingConfig_UnknownLevel(t *testing.T) {\n\tparams := anthropic.MessageNewParams{MaxTokens: 16000}\n\tapplyThinkingConfig(&params, \"unknown\")\n\n\tif params.Thinking.OfEnabled != nil {\n\t\tt.Error(\"should not set enabled thinking for unknown level\")\n\t}\n\tif params.Thinking.OfAdaptive != nil {\n\t\tt.Error(\"should not set adaptive thinking for unknown level\")\n\t}\n}\n\nfunc TestLevelToBudget(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tlevel string\n\t\twant  int\n\t}{\n\t\t{\"low\", \"low\", 4096},\n\t\t{\"medium\", \"medium\", 16384},\n\t\t{\"high\", \"high\", 32000},\n\t\t{\"xhigh\", \"xhigh\", 64000},\n\t\t{\"off\", \"off\", 0},\n\t\t{\"empty\", \"\", 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := levelToBudget(tt.level); got != tt.want {\n\t\t\t\tt.Errorf(\"levelToBudget(%q) = %d, want %d\", tt.level, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildParams_ThinkingClearsTemperature(t *testing.T) {\n\tmsgs := []Message{{Role: \"user\", Content: \"hello\"}}\n\topts := map[string]any{\n\t\t\"max_tokens\":     200000,\n\t\t\"temperature\":    0.8,\n\t\t\"thinking_level\": \"medium\",\n\t}\n\n\tparams, err := buildParams(msgs, nil, \"claude-sonnet-4-6\", opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif params.Temperature.Valid() {\n\t\tt.Error(\"temperature should be cleared when thinking_level is set\")\n\t}\n\tif params.Thinking.OfEnabled == nil {\n\t\tt.Fatal(\"expected enabled thinking\")\n\t}\n\tif params.Thinking.OfEnabled.BudgetTokens != 16384 {\n\t\tt.Errorf(\"budget_tokens = %d, want 16384\", params.Thinking.OfEnabled.BudgetTokens)\n\t}\n}\n\n// unmarshalBlocks constructs []ContentBlockUnion via JSON round-trip so that\n// the internal JSON.raw field is populated (required by AsText/AsThinking).\nfunc unmarshalBlocks(t *testing.T, jsonStr string) []anthropic.ContentBlockUnion {\n\tt.Helper()\n\tvar blocks []anthropic.ContentBlockUnion\n\tif err := json.Unmarshal([]byte(jsonStr), &blocks); err != nil {\n\t\tt.Fatalf(\"unmarshalBlocks: %v\", err)\n\t}\n\treturn blocks\n}\n\nfunc TestParseResponse_ThinkingBlock(t *testing.T) {\n\tresp := &anthropic.Message{\n\t\tContent: unmarshalBlocks(t, `[\n\t\t\t{\"type\":\"thinking\",\"thinking\":\"Let me reason step by step...\",\"signature\":\"sig\"},\n\t\t\t{\"type\":\"text\",\"text\":\"The answer is 42.\"}\n\t\t]`),\n\t\tStopReason: anthropic.StopReasonEndTurn,\n\t}\n\n\tresult := parseResponse(resp)\n\n\tif result.Reasoning != \"Let me reason step by step...\" {\n\t\tt.Errorf(\"Reasoning = %q, want thinking content\", result.Reasoning)\n\t}\n\tif result.Content != \"The answer is 42.\" {\n\t\tt.Errorf(\"Content = %q, want text content\", result.Content)\n\t}\n\tif result.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want stop\", result.FinishReason)\n\t}\n}\n\nfunc TestParseResponse_NoThinkingBlock(t *testing.T) {\n\tresp := &anthropic.Message{\n\t\tContent: unmarshalBlocks(t, `[\n\t\t\t{\"type\":\"text\",\"text\":\"Just a normal response.\"}\n\t\t]`),\n\t\tStopReason: anthropic.StopReasonEndTurn,\n\t}\n\n\tresult := parseResponse(resp)\n\n\tif result.Reasoning != \"\" {\n\t\tt.Errorf(\"Reasoning = %q, want empty\", result.Reasoning)\n\t}\n\tif result.Content != \"Just a normal response.\" {\n\t\tt.Errorf(\"Content = %q, want text content\", result.Content)\n\t}\n}\n\nfunc TestBuildParams_NoThinkingKeepsTemperature(t *testing.T) {\n\tmsgs := []Message{{Role: \"user\", Content: \"hello\"}}\n\topts := map[string]any{\n\t\t\"temperature\": 0.8,\n\t}\n\n\tparams, err := buildParams(msgs, nil, \"claude-sonnet-4-6\", opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !params.Temperature.Valid() {\n\t\tt.Error(\"temperature should be preserved when thinking is not set\")\n\t}\n\tif params.Temperature.Value != 0.8 {\n\t\tt.Errorf(\"temperature = %f, want 0.8\", params.Temperature.Value)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/anthropic_messages/provider.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage anthropicmessages\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\ntype (\n\tToolCall               = protocoltypes.ToolCall\n\tFunctionCall           = protocoltypes.FunctionCall\n\tLLMResponse            = protocoltypes.LLMResponse\n\tUsageInfo              = protocoltypes.UsageInfo\n\tMessage                = protocoltypes.Message\n\tToolDefinition         = protocoltypes.ToolDefinition\n\tToolFunctionDefinition = protocoltypes.ToolFunctionDefinition\n)\n\nconst (\n\tdefaultAPIVersion     = \"2023-06-01\"\n\tdefaultBaseURL        = \"https://api.anthropic.com/v1\"\n\tdefaultRequestTimeout = 120 * time.Second\n)\n\n// Provider implements Anthropic Messages API via HTTP (without SDK).\n// It supports custom endpoints that use Anthropic's native message format.\ntype Provider struct {\n\tapiKey     string\n\tapiBase    string\n\thttpClient *http.Client\n}\n\n// NewProvider creates a new Anthropic Messages API provider.\nfunc NewProvider(apiKey, apiBase string) *Provider {\n\treturn NewProviderWithTimeout(apiKey, apiBase, 0)\n}\n\n// NewProviderWithTimeout creates a provider with custom request timeout.\nfunc NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {\n\tbaseURL := normalizeBaseURL(apiBase)\n\ttimeout := defaultRequestTimeout\n\tif timeoutSeconds > 0 {\n\t\ttimeout = time.Duration(timeoutSeconds) * time.Second\n\t}\n\n\treturn &Provider{\n\t\tapiKey:  apiKey,\n\t\tapiBase: baseURL,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: timeout,\n\t\t},\n\t}\n}\n\n// Chat sends messages to the Anthropic Messages API and returns the response.\nfunc (p *Provider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\tif p.apiKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"API key not configured\")\n\t}\n\n\t// Build request body\n\trequestBody, err := buildRequestBody(messages, tools, model, options)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building request body: %w\", err)\n\t}\n\n\t// Serialize to JSON\n\tjsonBody, err := json.Marshal(requestBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"serializing request body: %w\", err)\n\t}\n\n\t// Build request URL\n\tendpointURL, err := url.JoinPath(p.apiBase, \"messages\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building endpoint URL: %w\", err)\n\t}\n\n\t// Create HTTP request\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", endpointURL, bytes.NewReader(jsonBody))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating HTTP request: %w\", err)\n\t}\n\n\t// Set headers\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-API-Key\", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name\n\treq.Header.Set(\"Anthropic-Version\", defaultAPIVersion)\n\n\t// Execute request\n\tresp, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"executing HTTP request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Read response body\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading response body: %w\", err)\n\t}\n\n\t// Check for HTTP errors with detailed messages\n\tswitch resp.StatusCode {\n\tcase http.StatusUnauthorized:\n\t\treturn nil, fmt.Errorf(\"authentication failed (401): check your API key\")\n\tcase http.StatusTooManyRequests:\n\t\treturn nil, fmt.Errorf(\"rate limited (429): %s\", string(body))\n\tcase http.StatusBadRequest:\n\t\treturn nil, fmt.Errorf(\"bad request (400): %s\", string(body))\n\tcase http.StatusNotFound:\n\t\treturn nil, fmt.Errorf(\"endpoint not found (404): %s\", string(body))\n\tcase http.StatusInternalServerError:\n\t\treturn nil, fmt.Errorf(\"internal server error (500): %s\", string(body))\n\tcase http.StatusServiceUnavailable:\n\t\treturn nil, fmt.Errorf(\"service unavailable (503): %s\", string(body))\n\tdefault:\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\treturn nil, fmt.Errorf(\"API request failed with status %d: %s\", resp.StatusCode, string(body))\n\t\t}\n\t}\n\n\t// Parse response\n\treturn parseResponseBody(body)\n}\n\n// GetDefaultModel returns the default model for this provider.\nfunc (p *Provider) GetDefaultModel() string {\n\treturn \"claude-sonnet-4.6\"\n}\n\n// buildRequestBody converts internal message format to Anthropic Messages API format.\nfunc buildRequestBody(\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (map[string]any, error) {\n\t// max_tokens is required and guaranteed by agent loop\n\tmaxTokens, ok := asInt(options[\"max_tokens\"])\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"max_tokens is required in options\")\n\t}\n\n\tresult := map[string]any{\n\t\t\"model\":      model,\n\t\t\"max_tokens\": int64(maxTokens),\n\t\t\"messages\":   []any{},\n\t}\n\n\t// Set temperature from options\n\tif temp, ok := asFloat(options[\"temperature\"]); ok {\n\t\tresult[\"temperature\"] = temp\n\t}\n\n\t// Process messages\n\tvar systemPrompt string\n\tvar apiMessages []any\n\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\t// Accumulate system messages\n\t\t\tif systemPrompt != \"\" {\n\t\t\t\tsystemPrompt += \"\\n\\n\" + msg.Content\n\t\t\t} else {\n\t\t\t\tsystemPrompt = msg.Content\n\t\t\t}\n\n\t\tcase \"user\":\n\t\t\tif msg.ToolCallID != \"\" {\n\t\t\t\t// Tool result message\n\t\t\t\tcontent := []map[string]any{\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\":        \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": msg.ToolCallID,\n\t\t\t\t\t\t\"content\":     msg.Content,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tapiMessages = append(apiMessages, map[string]any{\n\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\"content\": content,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\t// Regular user message\n\t\t\t\tapiMessages = append(apiMessages, map[string]any{\n\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\"content\": msg.Content,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase \"assistant\":\n\t\t\tcontent := []any{}\n\n\t\t\t// Add text content if present\n\t\t\tif msg.Content != \"\" {\n\t\t\t\tcontent = append(content, map[string]any{\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"text\": msg.Content,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Add tool_use blocks\n\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\tif strings.TrimSpace(tc.Name) == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Handle nil Arguments (GLM-4 may return null input)\n\t\t\t\tinput := tc.Arguments\n\t\t\t\tif input == nil {\n\t\t\t\t\tinput = map[string]any{}\n\t\t\t\t}\n\n\t\t\t\ttoolUse := map[string]any{\n\t\t\t\t\t\"type\":  \"tool_use\",\n\t\t\t\t\t\"id\":    tc.ID,\n\t\t\t\t\t\"name\":  tc.Name,\n\t\t\t\t\t\"input\": input,\n\t\t\t\t}\n\t\t\t\tcontent = append(content, toolUse)\n\t\t\t}\n\n\t\t\tapiMessages = append(apiMessages, map[string]any{\n\t\t\t\t\"role\":    \"assistant\",\n\t\t\t\t\"content\": content,\n\t\t\t})\n\n\t\tcase \"tool\":\n\t\t\t// Tool result (alternative format)\n\t\t\tcontent := []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"type\":        \"tool_result\",\n\t\t\t\t\t\"tool_use_id\": msg.ToolCallID,\n\t\t\t\t\t\"content\":     msg.Content,\n\t\t\t\t},\n\t\t\t}\n\t\t\tapiMessages = append(apiMessages, map[string]any{\n\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\"content\": content,\n\t\t\t})\n\t\t}\n\t}\n\n\tresult[\"messages\"] = apiMessages\n\n\t// Set system prompt if present\n\tif systemPrompt != \"\" {\n\t\tresult[\"system\"] = systemPrompt\n\t}\n\n\t// Add tools if present\n\tif len(tools) > 0 {\n\t\tresult[\"tools\"] = buildTools(tools)\n\t}\n\n\treturn result, nil\n}\n\n// buildTools converts tool definitions to Anthropic format.\nfunc buildTools(tools []ToolDefinition) []any {\n\tresult := make([]any, len(tools))\n\tfor i, tool := range tools {\n\t\ttoolDef := map[string]any{\n\t\t\t\"name\":         tool.Function.Name,\n\t\t\t\"description\":  tool.Function.Description,\n\t\t\t\"input_schema\": tool.Function.Parameters,\n\t\t}\n\t\tresult[i] = toolDef\n\t}\n\treturn result\n}\n\n// parseResponseBody parses Anthropic Messages API response.\nfunc parseResponseBody(body []byte) (*LLMResponse, error) {\n\tvar resp anthropicMessageResponse\n\tif err := json.Unmarshal(body, &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing JSON response: %w\", err)\n\t}\n\n\t// Extract content and tool calls\n\tvar content strings.Builder\n\ttoolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization\n\n\tfor _, block := range resp.Content {\n\t\tswitch block.Type {\n\t\tcase \"text\":\n\t\t\tcontent.WriteString(block.Text)\n\t\tcase \"tool_use\":\n\t\t\targsJSON, _ := json.Marshal(block.Input)\n\t\t\ttoolCalls = append(toolCalls, ToolCall{\n\t\t\t\tID:        block.ID,\n\t\t\t\tName:      block.Name,\n\t\t\t\tArguments: block.Input,\n\t\t\t\tFunction: &FunctionCall{\n\t\t\t\t\tName:      block.Name,\n\t\t\t\t\tArguments: string(argsJSON),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\t// Map stop_reason\n\tfinishReason := \"stop\"\n\tswitch resp.StopReason {\n\tcase \"tool_use\":\n\t\tfinishReason = \"tool_calls\"\n\tcase \"max_tokens\":\n\t\tfinishReason = \"length\"\n\tcase \"end_turn\":\n\t\tfinishReason = \"stop\"\n\tcase \"stop_sequence\":\n\t\tfinishReason = \"stop\"\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      content.String(),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: finishReason,\n\t\tUsage: &UsageInfo{\n\t\t\tPromptTokens:     int(resp.Usage.InputTokens),\n\t\t\tCompletionTokens: int(resp.Usage.OutputTokens),\n\t\t\tTotalTokens:      int(resp.Usage.InputTokens + resp.Usage.OutputTokens),\n\t\t},\n\t}, nil\n}\n\n// normalizeBaseURL ensures the base URL is properly formatted.\n// It removes /v1 suffix if present (to avoid duplication) and always appends /v1.\n// This handles edge cases like \"https://api.example.com/v1/proxy\" correctly.\nfunc normalizeBaseURL(apiBase string) string {\n\tbase := strings.TrimSpace(apiBase)\n\tif base == \"\" {\n\t\treturn defaultBaseURL\n\t}\n\n\t// Remove trailing slashes\n\tbase = strings.TrimRight(base, \"/\")\n\n\t// Remove /v1 suffix if present (will be re-added)\n\t// This prevents duplication for URLs like \"https://api.example.com/v1/proxy\"\n\tif before, ok := strings.CutSuffix(base, \"/v1\"); ok {\n\t\tbase = before\n\t}\n\n\t// Ensure we don't have an empty string after cutting\n\tif base == \"\" {\n\t\treturn defaultBaseURL\n\t}\n\n\t// Add /v1 suffix (required by Anthropic Messages API)\n\treturn base + \"/v1\"\n}\n\n// Helper functions for type conversion\n\nfunc asInt(v any) (int, bool) {\n\tswitch val := v.(type) {\n\tcase int:\n\t\treturn val, true\n\tcase float64:\n\t\treturn int(val), true\n\tcase int64:\n\t\treturn int(val), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc asFloat(v any) (float64, bool) {\n\tswitch val := v.(type) {\n\tcase float64:\n\t\treturn val, true\n\tcase int:\n\t\treturn float64(val), true\n\tcase int64:\n\t\treturn float64(val), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// Anthropic API response structures\n\ntype anthropicMessageResponse struct {\n\tID         string         `json:\"id\"`\n\tType       string         `json:\"type\"`\n\tRole       string         `json:\"role\"`\n\tContent    []contentBlock `json:\"content\"`\n\tStopReason string         `json:\"stop_reason\"`\n\tModel      string         `json:\"model\"`\n\tUsage      usageInfo      `json:\"usage\"`\n}\n\ntype contentBlock struct {\n\tType  string         `json:\"type\"`\n\tText  string         `json:\"text,omitempty\"`\n\tID    string         `json:\"id,omitempty\"`\n\tName  string         `json:\"name,omitempty\"`\n\tInput map[string]any `json:\"input,omitempty\"`\n}\n\ntype usageInfo struct {\n\tInputTokens  int64 `json:\"input_tokens\"`\n\tOutputTokens int64 `json:\"output_tokens\"`\n}\n"
  },
  {
    "path": "pkg/providers/anthropic_messages/provider_test.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage anthropicmessages\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestBuildRequestBody(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tmessages []Message\n\t\ttools    []ToolDefinition\n\t\tmodel    string\n\t\toptions  map[string]any\n\t\twant     map[string]any\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"basic user message\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"Hello, world!\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"model\":      \"test-model\",\n\t\t\t\t\"max_tokens\": int64(8192),\n\t\t\t\t\"messages\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"Hello, world!\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"user and assistant messages\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"What is 2+2?\"},\n\t\t\t\t{Role: \"assistant\", Content: \"4\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"model\":      \"test-model\",\n\t\t\t\t\"max_tokens\": int64(8192),\n\t\t\t\t\"messages\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"What is 2+2?\",\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\t\t\"content\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"text\": \"4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with system message\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"system\", Content: \"You are a helpful assistant.\"},\n\t\t\t\t{Role: \"user\", Content: \"Hello\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"model\":      \"test-model\",\n\t\t\t\t\"max_tokens\": int64(8192),\n\t\t\t\t\"system\":     \"You are a helpful assistant.\",\n\t\t\t\t\"messages\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"Hello\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with custom max_tokens and temperature\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"Test\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\":  2048,\n\t\t\t\t\"temperature\": 0.5,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"model\":       \"test-model\",\n\t\t\t\t\"max_tokens\":  int64(2048),\n\t\t\t\t\"temperature\": 0.5,\n\t\t\t\t\"messages\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"Test\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"missing max_tokens returns error\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"Test\"},\n\t\t\t},\n\t\t\tmodel:   \"test-model\",\n\t\t\toptions: map[string]any{},\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"with tools\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"What's the weather?\"},\n\t\t\t},\n\t\t\ttools: []ToolDefinition{\n\t\t\t\t{\n\t\t\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\t\t\tName:        \"get_weather\",\n\t\t\t\t\t\tDescription: \"Get current weather\",\n\t\t\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\t\t\"location\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"City name\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twant: map[string]any{\n\t\t\t\t\"model\":      \"test-model\",\n\t\t\t\t\"max_tokens\": int64(8192),\n\t\t\t\t\"messages\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"What's the weather?\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"tools\": []any{\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"name\":        \"get_weather\",\n\t\t\t\t\t\t\"description\": \"Get current weather\",\n\t\t\t\t\t\t\"input_schema\": map[string]any{\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\t\t\"location\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"City name\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"buildRequestBody() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tgotJSON, _ := json.MarshalIndent(got, \"\", \"  \")\n\t\t\t\twantJSON, _ := json.MarshalIndent(tt.want, \"\", \"  \")\n\t\t\t\tt.Errorf(\"buildRequestBody() mismatch:\\ngot:\\n%s\\nwant:\\n%s\", gotJSON, wantJSON)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseResponseBody(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tbody    []byte\n\t\twant    *LLMResponse\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"basic text response\",\n\t\t\tbody: []byte(`{\n\t\t\t\t\"id\": \"msg-123\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Hello, how can I help?\"}\n\t\t\t\t],\n\t\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\t\"model\": \"test-model\",\n\t\t\t\t\"usage\": {\n\t\t\t\t\t\"input_tokens\": 10,\n\t\t\t\t\t\"output_tokens\": 5\n\t\t\t\t}\n\t\t\t}`),\n\t\t\twant: &LLMResponse{\n\t\t\t\tContent:      \"Hello, how can I help?\",\n\t\t\t\tToolCalls:    []ToolCall{},\n\t\t\t\tFinishReason: \"stop\",\n\t\t\t\tUsage: &UsageInfo{\n\t\t\t\t\tPromptTokens:     10,\n\t\t\t\t\tCompletionTokens: 5,\n\t\t\t\t\tTotalTokens:      15,\n\t\t\t\t},\n\t\t\t\tReasoning:        \"\",\n\t\t\t\tReasoningDetails: nil,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"response with tool use\",\n\t\t\tbody: []byte(`{\n\t\t\t\t\"id\": \"msg-456\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"I'll check the weather for you.\"},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\": \"toolu-123\",\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"input\": {\"location\": \"Tokyo\"}\n\t\t\t\t\t}\n\t\t\t\t],\n\t\t\t\t\"stop_reason\": \"tool_use\",\n\t\t\t\t\"model\": \"test-model\",\n\t\t\t\t\"usage\": {\n\t\t\t\t\t\"input_tokens\": 20,\n\t\t\t\t\t\"output_tokens\": 15\n\t\t\t\t}\n\t\t\t}`),\n\t\t\twant: &LLMResponse{\n\t\t\t\tContent: \"I'll check the weather for you.\",\n\t\t\t\tToolCalls: []ToolCall{\n\t\t\t\t\t{\n\t\t\t\t\t\tID:   \"toolu-123\",\n\t\t\t\t\t\tName: \"get_weather\",\n\t\t\t\t\t\tArguments: map[string]any{\n\t\t\t\t\t\t\t\"location\": \"Tokyo\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tFunction: &FunctionCall{\n\t\t\t\t\t\t\tName:      \"get_weather\",\n\t\t\t\t\t\t\tArguments: `{\"location\":\"Tokyo\"}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tFinishReason: \"tool_calls\",\n\t\t\t\tUsage: &UsageInfo{\n\t\t\t\t\tPromptTokens:     20,\n\t\t\t\t\tCompletionTokens: 15,\n\t\t\t\t\tTotalTokens:      35,\n\t\t\t\t},\n\t\t\t\tReasoning:        \"\",\n\t\t\t\tReasoningDetails: nil,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid JSON\",\n\t\t\tbody:    []byte(`invalid json`),\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"max_tokens stop reason\",\n\t\t\tbody: []byte(`{\n\t\t\t\t\"id\": \"msg-789\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"text\", \"text\": \"Partial response\"}\n\t\t\t\t],\n\t\t\t\t\"stop_reason\": \"max_tokens\",\n\t\t\t\t\"model\": \"test-model\",\n\t\t\t\t\"usage\": {\n\t\t\t\t\t\"input_tokens\": 100,\n\t\t\t\t\t\"output_tokens\": 4096\n\t\t\t\t}\n\t\t\t}`),\n\t\t\twant: &LLMResponse{\n\t\t\t\tContent:      \"Partial response\",\n\t\t\t\tToolCalls:    []ToolCall{},\n\t\t\t\tFinishReason: \"length\",\n\t\t\t\tUsage: &UsageInfo{\n\t\t\t\t\tPromptTokens:     100,\n\t\t\t\t\tCompletionTokens: 4096,\n\t\t\t\t\tTotalTokens:      4196,\n\t\t\t\t},\n\t\t\t\tReasoning:        \"\",\n\t\t\t\tReasoningDetails: nil,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseResponseBody(tt.body)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseResponseBody() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Compare individual fields\n\t\t\tif got.Content != tt.want.Content {\n\t\t\t\tt.Errorf(\"Content = %q, want %q\", got.Content, tt.want.Content)\n\t\t\t}\n\t\t\tif got.FinishReason != tt.want.FinishReason {\n\t\t\t\tt.Errorf(\"FinishReason = %q, want %q\", got.FinishReason, tt.want.FinishReason)\n\t\t\t}\n\t\t\tif got.Usage == nil && tt.want.Usage != nil {\n\t\t\t\tt.Errorf(\"Usage = nil, want non-nil\")\n\t\t\t} else if got.Usage != nil && tt.want.Usage == nil {\n\t\t\t\tt.Errorf(\"Usage = non-nil, want nil\")\n\t\t\t} else if got.Usage != nil && tt.want.Usage != nil {\n\t\t\t\tif got.Usage.PromptTokens != tt.want.Usage.PromptTokens {\n\t\t\t\t\tt.Errorf(\"Usage.PromptTokens = %d, want %d\", got.Usage.PromptTokens, tt.want.Usage.PromptTokens)\n\t\t\t\t}\n\t\t\t\tif got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens {\n\t\t\t\t\tt.Errorf(\"Usage.CompletionTokens = %d, want %d\",\n\t\t\t\t\t\tgot.Usage.CompletionTokens, tt.want.Usage.CompletionTokens)\n\t\t\t\t}\n\t\t\t\tif got.Usage.TotalTokens != tt.want.Usage.TotalTokens {\n\t\t\t\t\tt.Errorf(\"Usage.TotalTokens = %d, want %d\", got.Usage.TotalTokens, tt.want.Usage.TotalTokens)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(got.ToolCalls) != len(tt.want.ToolCalls) {\n\t\t\t\tt.Errorf(\"ToolCalls length = %d, want %d\", len(got.ToolCalls), len(tt.want.ToolCalls))\n\t\t\t} else {\n\t\t\t\tfor i := range got.ToolCalls {\n\t\t\t\t\tif got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID {\n\t\t\t\t\t\tt.Errorf(\"ToolCalls[%d].ID = %q, want %q\",\n\t\t\t\t\t\t\ti, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID)\n\t\t\t\t\t}\n\t\t\t\t\tif got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name {\n\t\t\t\t\t\tt.Errorf(\"ToolCalls[%d].Name = %q, want %q\",\n\t\t\t\t\t\t\ti, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNormalizeBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tapiBase  string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"empty string defaults to official API\",\n\t\t\tapiBase:  \"\",\n\t\t\texpected: \"https://api.anthropic.com/v1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL without /v1 gets it appended\",\n\t\t\tapiBase:  \"https://api.example.com/anthropic\",\n\t\t\texpected: \"https://api.example.com/anthropic/v1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with /v1 remains unchanged\",\n\t\t\tapiBase:  \"https://api.example.com/v1\",\n\t\t\texpected: \"https://api.example.com/v1\",\n\t\t},\n\t\t{\n\t\t\tname:     \"URL with trailing slash gets cleaned\",\n\t\t\tapiBase:  \"https://api.example.com/anthropic/\",\n\t\t\texpected: \"https://api.example.com/anthropic/v1\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := normalizeBaseURL(tt.apiBase)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"normalizeBaseURL(%q) = %q, want %q\", tt.apiBase, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewProvider(t *testing.T) {\n\tprovider := NewProvider(\"test-key\", \"https://api.example.com\")\n\tif provider == nil {\n\t\tt.Fatal(\"NewProvider() returned nil\")\n\t}\n\tif provider.apiKey != \"test-key\" {\n\t\tt.Errorf(\"provider.apiKey = %q, want %q\", provider.apiKey, \"test-key\")\n\t}\n\tif provider.apiBase != \"https://api.example.com/v1\" {\n\t\tt.Errorf(\"provider.apiBase = %q, want %q\", provider.apiBase, \"https://api.example.com/v1\")\n\t}\n}\n\nfunc TestGetDefaultModel(t *testing.T) {\n\tprovider := NewProvider(\"test-key\", \"\")\n\tgot := provider.GetDefaultModel()\n\texpected := \"claude-sonnet-4.6\"\n\tif got != expected {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, expected)\n\t}\n}\n\n// TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody.\nfunc TestBuildRequestBodyEdgeCases(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tmessages []Message\n\t\ttools    []ToolDefinition\n\t\tmodel    string\n\t\toptions  map[string]any\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"empty message list\",\n\t\t\tmessages: []Message{},\n\t\t\tmodel:    \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"very long system message\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"system\", Content: strings.Repeat(\"This is a very long system prompt. \", 1000)},\n\t\t\t\t{Role: \"user\", Content: \"Hello\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple consecutive system messages\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"system\", Content: \"First system message\"},\n\t\t\t\t{Role: \"system\", Content: \"Second system message\"},\n\t\t\t\t{Role: \"system\", Content: \"Third system message\"},\n\t\t\t\t{Role: \"user\", Content: \"Hello\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"tool result without tool call\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"user\", Content: \"Use a tool\"},\n\t\t\t\t{Role: \"assistant\", Content: \"\", ToolCalls: []ToolCall{\n\t\t\t\t\t{ID: \"tool-1\", Name: \"test_tool\", Arguments: map[string]any{\"arg\": \"value\"}},\n\t\t\t\t}},\n\t\t\t\t{Role: \"user\", ToolCallID: \"tool-1\", Content: \"Tool result\"},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"skip tool calls with empty names\",\n\t\t\tmessages: []Message{\n\t\t\t\t{Role: \"assistant\", Content: \"Calling tool\", ToolCalls: []ToolCall{\n\t\t\t\t\t{ID: \"tool-empty\", Name: \"\", Arguments: map[string]any{\"ignored\": true}},\n\t\t\t\t\t{ID: \"tool-valid\", Name: \"test_tool\", Arguments: map[string]any{\"arg\": \"value\"}},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tmodel: \"test-model\",\n\t\t\toptions: map[string]any{\n\t\t\t\t\"max_tokens\": 8192,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"buildRequestBody() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Verify basic structure\n\t\t\tif got == nil {\n\t\t\t\tt.Error(\"buildRequestBody() returned nil\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got[\"model\"] != tt.model {\n\t\t\t\tt.Errorf(\"model = %v, want %v\", got[\"model\"], tt.model)\n\t\t\t}\n\n\t\t\tif tt.name == \"skip tool calls with empty names\" {\n\t\t\t\tmessages, ok := got[\"messages\"].([]any)\n\t\t\t\tif !ok || len(messages) != 1 {\n\t\t\t\t\tt.Fatalf(\"messages = %#v, want single assistant message\", got[\"messages\"])\n\t\t\t\t}\n\n\t\t\t\tassistantMsg, ok := messages[0].(map[string]any)\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"assistant message = %#v, want map\", messages[0])\n\t\t\t\t}\n\n\t\t\t\tcontent, ok := assistantMsg[\"content\"].([]any)\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"assistant content = %#v, want []any\", assistantMsg[\"content\"])\n\t\t\t\t}\n\t\t\t\tif len(content) != 2 {\n\t\t\t\t\tt.Fatalf(\"assistant content length = %d, want 2\", len(content))\n\t\t\t\t}\n\n\t\t\t\ttoolUse, ok := content[1].(map[string]any)\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Fatalf(\"tool_use block = %#v, want map\", content[1])\n\t\t\t\t}\n\t\t\t\tif gotName := toolUse[\"name\"]; gotName != \"test_tool\" {\n\t\t\t\t\tt.Fatalf(\"tool_use name = %v, want %q\", gotName, \"test_tool\")\n\t\t\t\t}\n\t\t\t\tif gotID := toolUse[\"id\"]; gotID != \"tool-valid\" {\n\t\t\t\t\tt.Fatalf(\"tool_use id = %v, want %q\", gotID, \"tool-valid\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.\nfunc TestParseResponseBodyEdgeCases(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tbody    []byte\n\t\twantErr bool\n\t\tcheck   func(*testing.T, *LLMResponse)\n\t}{\n\t\t{\n\t\t\tname: \"empty content blocks\",\n\t\t\tbody: []byte(`{\n\t\t\t\t\"id\": \"msg-empty\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [],\n\t\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\t\"model\": \"test-model\",\n\t\t\t\t\"usage\": {\"input_tokens\": 5, \"output_tokens\": 0}\n\t\t\t}`),\n\t\t\twantErr: false,\n\t\t\tcheck: func(t *testing.T, resp *LLMResponse) {\n\t\t\t\tif resp.Content != \"\" {\n\t\t\t\t\tt.Errorf(\"Content = %q, want empty string\", resp.Content)\n\t\t\t\t}\n\t\t\t\tif len(resp.ToolCalls) != 0 {\n\t\t\t\t\tt.Errorf(\"ToolCalls length = %d, want 0\", len(resp.ToolCalls))\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple tool use blocks\",\n\t\t\tbody: []byte(`{\n\t\t\t\t\"id\": \"msg-multi\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"tool-1\", \"name\": \"func1\", \"input\": {\"arg\": \"val1\"}},\n\t\t\t\t\t{\"type\": \"tool_use\", \"id\": \"tool-2\", \"name\": \"func2\", \"input\": {\"arg\": \"val2\"}}\n\t\t\t\t],\n\t\t\t\t\"stop_reason\": \"tool_use\",\n\t\t\t\t\"model\": \"test-model\",\n\t\t\t\t\"usage\": {\"input_tokens\": 10, \"output_tokens\": 20}\n\t\t\t}`),\n\t\t\twantErr: false,\n\t\t\tcheck: func(t *testing.T, resp *LLMResponse) {\n\t\t\t\tif len(resp.ToolCalls) != 2 {\n\t\t\t\t\tt.Errorf(\"ToolCalls length = %d, want 2\", len(resp.ToolCalls))\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"malformed JSON response\",\n\t\t\tbody:    []byte(`{invalid json`),\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseResponseBody(tt.body)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseResponseBody() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.check != nil && err == nil {\n\t\t\t\ttt.check(t, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestProviderChatErrors tests error handling in Chat.\n// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default.\nfunc TestProviderChatErrors(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tapiKey     string\n\t\tmessages   []Message\n\t\twantErrMsg string\n\t}{\n\t\t{\n\t\t\tname:       \"missing API key\",\n\t\t\tapiKey:     \"\",\n\t\t\tmessages:   []Message{{Role: \"user\", Content: \"Test\"}},\n\t\t\twantErrMsg: \"API key not configured\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create provider using constructor to ensure proper initialization\n\t\t\tprovider := NewProvider(tt.apiKey, \"https://api.example.com\")\n\n\t\t\t_, err := provider.Chat(context.Background(), tt.messages, nil, \"test-model\", nil)\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"Chat() expected error, got nil\")\n\t\t\t}\n\t\t\tif err.Error() != tt.wantErrMsg {\n\t\t\t\tt.Errorf(\"Chat() error = %q, want %q\", err.Error(), tt.wantErrMsg)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/antigravity_provider.go",
    "content": "package providers\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nconst (\n\tantigravityBaseURL      = \"https://cloudcode-pa.googleapis.com\"\n\tantigravityDefaultModel = \"gemini-3-flash\"\n\tantigravityUserAgent    = \"antigravity\"\n\tantigravityXGoogClient  = \"google-cloud-sdk vscode_cloudshelleditor/0.1\"\n\tantigravityVersion      = \"1.15.8\"\n)\n\n// AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API.\n// This provider authenticates via Google OAuth and provides access to models like Claude and Gemini\n// through Google's infrastructure.\ntype AntigravityProvider struct {\n\ttokenSource func() (string, string, error) // Returns (accessToken, projectID, error)\n\thttpClient  *http.Client\n}\n\n// NewAntigravityProvider creates a new Antigravity provider using stored auth credentials.\nfunc NewAntigravityProvider() *AntigravityProvider {\n\treturn &AntigravityProvider{\n\t\ttokenSource: createAntigravityTokenSource(),\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 120 * time.Second,\n\t\t},\n\t}\n}\n\n// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.\n// The v1internal endpoint wraps the standard Gemini request in an envelope with\n// project, model, request, requestType, userAgent, and requestId fields.\nfunc (p *AntigravityProvider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\taccessToken, projectID, err := p.tokenSource()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity auth: %w\", err)\n\t}\n\n\tif model == \"\" || model == \"antigravity\" || model == \"google-antigravity\" {\n\t\tmodel = antigravityDefaultModel\n\t}\n\t// Strip provider prefixes if present\n\tmodel = strings.TrimPrefix(model, \"google-antigravity/\")\n\tmodel = strings.TrimPrefix(model, \"antigravity/\")\n\n\tlogger.DebugCF(\"provider.antigravity\", \"Starting chat\", map[string]any{\n\t\t\"model\":     model,\n\t\t\"project\":   projectID,\n\t\t\"requestId\": fmt.Sprintf(\"agent-%d-%s\", time.Now().UnixMilli(), randomString(9)),\n\t})\n\n\t// Build the inner Gemini-format request\n\tinnerRequest := p.buildRequest(messages, tools, model, options)\n\n\t// Wrap in v1internal envelope (matches pi-ai SDK format)\n\tenvelope := map[string]any{\n\t\t\"project\":     projectID,\n\t\t\"model\":       model,\n\t\t\"request\":     innerRequest,\n\t\t\"requestType\": \"agent\",\n\t\t\"userAgent\":   antigravityUserAgent,\n\t\t\"requestId\":   fmt.Sprintf(\"agent-%d-%s\", time.Now().UnixMilli(), randomString(9)),\n\t}\n\n\tbodyBytes, err := json.Marshal(envelope)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshaling request: %w\", err)\n\t}\n\n\t// Build API URL — uses Cloud Code Assist v1internal streaming endpoint\n\tapiURL := fmt.Sprintf(\"%s/v1internal:streamGenerateContent?alt=sse\", antigravityBaseURL)\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", apiURL, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating request: %w\", err)\n\t}\n\n\t// Headers matching the pi-ai SDK antigravity format\n\tclientMetadata, _ := json.Marshal(map[string]string{\n\t\t\"ideType\":    \"IDE_UNSPECIFIED\",\n\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\"pluginType\": \"GEMINI\",\n\t})\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\treq.Header.Set(\"User-Agent\", fmt.Sprintf(\"antigravity/%s linux/amd64\", antigravityVersion))\n\treq.Header.Set(\"X-Goog-Api-Client\", antigravityXGoogClient)\n\treq.Header.Set(\"Client-Metadata\", string(clientMetadata))\n\n\tresp, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"antigravity API call: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlogger.ErrorCF(\"provider.antigravity\", \"API call failed\", map[string]any{\n\t\t\t\"status_code\": resp.StatusCode,\n\t\t\t\"response\":    string(respBody),\n\t\t\t\"model\":       model,\n\t\t})\n\n\t\treturn nil, p.parseAntigravityError(resp.StatusCode, respBody)\n\t}\n\n\t// Response is always SSE from streamGenerateContent — each line is \"data: {...}\"\n\t// with a \"response\" wrapper containing the standard Gemini response\n\tllmResp, err := p.parseSSEResponse(string(respBody))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Check for empty response (some models might return valid success but empty text)\n\tif llmResp.Content == \"\" && len(llmResp.ToolCalls) == 0 {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"antigravity: model returned an empty response (this model might be invalid or restricted)\",\n\t\t)\n\t}\n\n\treturn llmResp, nil\n}\n\n// GetDefaultModel returns the default model identifier.\nfunc (p *AntigravityProvider) GetDefaultModel() string {\n\treturn antigravityDefaultModel\n}\n\n// --- Request building ---\n\ntype antigravityRequest struct {\n\tContents     []antigravityContent     `json:\"contents\"`\n\tTools        []antigravityTool        `json:\"tools,omitempty\"`\n\tSystemPrompt *antigravitySystemPrompt `json:\"systemInstruction,omitempty\"`\n\tConfig       *antigravityGenConfig    `json:\"generationConfig,omitempty\"`\n}\n\ntype antigravityContent struct {\n\tRole  string            `json:\"role\"`\n\tParts []antigravityPart `json:\"parts\"`\n}\n\ntype antigravityPart struct {\n\tText                  string                       `json:\"text,omitempty\"`\n\tThoughtSignature      string                       `json:\"thoughtSignature,omitempty\"`\n\tThoughtSignatureSnake string                       `json:\"thought_signature,omitempty\"`\n\tFunctionCall          *antigravityFunctionCall     `json:\"functionCall,omitempty\"`\n\tFunctionResponse      *antigravityFunctionResponse `json:\"functionResponse,omitempty\"`\n}\n\ntype antigravityFunctionCall struct {\n\tName string         `json:\"name\"`\n\tArgs map[string]any `json:\"args\"`\n}\n\ntype antigravityFunctionResponse struct {\n\tName     string         `json:\"name\"`\n\tResponse map[string]any `json:\"response\"`\n}\n\ntype antigravityTool struct {\n\tFunctionDeclarations []antigravityFuncDecl `json:\"functionDeclarations\"`\n}\n\ntype antigravityFuncDecl struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tParameters  any    `json:\"parameters,omitempty\"`\n}\n\ntype antigravitySystemPrompt struct {\n\tParts []antigravityPart `json:\"parts\"`\n}\n\ntype antigravityGenConfig struct {\n\tMaxOutputTokens int     `json:\"maxOutputTokens,omitempty\"`\n\tTemperature     float64 `json:\"temperature,omitempty\"`\n}\n\nfunc (p *AntigravityProvider) buildRequest(\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) antigravityRequest {\n\treq := antigravityRequest{}\n\ttoolCallNames := make(map[string]string)\n\n\t// Build contents from messages\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\treq.SystemPrompt = &antigravitySystemPrompt{\n\t\t\t\tParts: []antigravityPart{{Text: msg.Content}},\n\t\t\t}\n\t\tcase \"user\":\n\t\t\tif msg.ToolCallID != \"\" {\n\t\t\t\ttoolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)\n\t\t\t\t// Tool result\n\t\t\t\treq.Contents = append(req.Contents, antigravityContent{\n\t\t\t\t\tRole: \"user\",\n\t\t\t\t\tParts: []antigravityPart{{\n\t\t\t\t\t\tFunctionResponse: &antigravityFunctionResponse{\n\t\t\t\t\t\t\tName: toolName,\n\t\t\t\t\t\t\tResponse: map[string]any{\n\t\t\t\t\t\t\t\t\"result\": msg.Content,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\treq.Contents = append(req.Contents, antigravityContent{\n\t\t\t\t\tRole:  \"user\",\n\t\t\t\t\tParts: []antigravityPart{{Text: msg.Content}},\n\t\t\t\t})\n\t\t\t}\n\t\tcase \"assistant\":\n\t\t\tcontent := antigravityContent{\n\t\t\t\tRole: \"model\",\n\t\t\t}\n\t\t\tif msg.Content != \"\" {\n\t\t\t\tcontent.Parts = append(content.Parts, antigravityPart{Text: msg.Content})\n\t\t\t}\n\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\ttoolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)\n\t\t\t\tif toolName == \"\" {\n\t\t\t\t\tlogger.WarnCF(\n\t\t\t\t\t\t\"provider.antigravity\",\n\t\t\t\t\t\t\"Skipping tool call with empty name in history\",\n\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\"tool_call_id\": tc.ID,\n\t\t\t\t\t\t},\n\t\t\t\t\t)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif tc.ID != \"\" {\n\t\t\t\t\ttoolCallNames[tc.ID] = toolName\n\t\t\t\t}\n\t\t\t\tcontent.Parts = append(content.Parts, antigravityPart{\n\t\t\t\t\tThoughtSignature:      thoughtSignature,\n\t\t\t\t\tThoughtSignatureSnake: thoughtSignature,\n\t\t\t\t\tFunctionCall: &antigravityFunctionCall{\n\t\t\t\t\t\tName: toolName,\n\t\t\t\t\t\tArgs: toolArgs,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t\tif len(content.Parts) > 0 {\n\t\t\t\treq.Contents = append(req.Contents, content)\n\t\t\t}\n\t\tcase \"tool\":\n\t\t\ttoolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)\n\t\t\treq.Contents = append(req.Contents, antigravityContent{\n\t\t\t\tRole: \"user\",\n\t\t\t\tParts: []antigravityPart{{\n\t\t\t\t\tFunctionResponse: &antigravityFunctionResponse{\n\t\t\t\t\t\tName: toolName,\n\t\t\t\t\t\tResponse: map[string]any{\n\t\t\t\t\t\t\t\"result\": msg.Content,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t})\n\t\t}\n\t}\n\n\t// Build tools (sanitize schemas for Gemini compatibility)\n\tif len(tools) > 0 {\n\t\tvar funcDecls []antigravityFuncDecl\n\t\tfor _, t := range tools {\n\t\t\tif t.Type != \"function\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams := sanitizeSchemaForGemini(t.Function.Parameters)\n\t\t\tfuncDecls = append(funcDecls, antigravityFuncDecl{\n\t\t\t\tName:        t.Function.Name,\n\t\t\t\tDescription: t.Function.Description,\n\t\t\t\tParameters:  params,\n\t\t\t})\n\t\t}\n\t\tif len(funcDecls) > 0 {\n\t\t\treq.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}}\n\t\t}\n\t}\n\n\t// Generation config\n\tconfig := &antigravityGenConfig{}\n\tif val, ok := options[\"max_tokens\"]; ok {\n\t\tif maxTokens, ok := val.(int); ok && maxTokens > 0 {\n\t\t\tconfig.MaxOutputTokens = maxTokens\n\t\t} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {\n\t\t\tconfig.MaxOutputTokens = int(maxTokens)\n\t\t}\n\t}\n\tif temp, ok := options[\"temperature\"].(float64); ok {\n\t\tconfig.Temperature = temp\n\t}\n\tif config.MaxOutputTokens > 0 || config.Temperature > 0 {\n\t\treq.Config = config\n\t}\n\n\treturn req\n}\n\nfunc normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {\n\tname := tc.Name\n\targs := tc.Arguments\n\tthoughtSignature := \"\"\n\n\tif name == \"\" && tc.Function != nil {\n\t\tname = tc.Function.Name\n\t\tthoughtSignature = tc.Function.ThoughtSignature\n\t} else if tc.Function != nil {\n\t\tthoughtSignature = tc.Function.ThoughtSignature\n\t}\n\n\tif args == nil {\n\t\targs = map[string]any{}\n\t}\n\n\tif len(args) == 0 && tc.Function != nil && tc.Function.Arguments != \"\" {\n\t\tvar parsed map[string]any\n\t\tif err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {\n\t\t\targs = parsed\n\t\t}\n\t}\n\n\treturn name, args, thoughtSignature\n}\n\nfunc resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string {\n\tif toolCallID == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif name, ok := toolCallNames[toolCallID]; ok && name != \"\" {\n\t\treturn name\n\t}\n\n\treturn inferToolNameFromCallID(toolCallID)\n}\n\nfunc inferToolNameFromCallID(toolCallID string) string {\n\tif !strings.HasPrefix(toolCallID, \"call_\") {\n\t\treturn toolCallID\n\t}\n\n\trest := strings.TrimPrefix(toolCallID, \"call_\")\n\tif idx := strings.LastIndex(rest, \"_\"); idx > 0 {\n\t\tcandidate := rest[:idx]\n\t\tif candidate != \"\" {\n\t\t\treturn candidate\n\t\t}\n\t}\n\n\treturn toolCallID\n}\n\n// --- Response parsing ---\n\ntype antigravityJSONResponse struct {\n\tCandidates []struct {\n\t\tContent struct {\n\t\t\tParts []struct {\n\t\t\t\tText                  string                   `json:\"text,omitempty\"`\n\t\t\t\tThoughtSignature      string                   `json:\"thoughtSignature,omitempty\"`\n\t\t\t\tThoughtSignatureSnake string                   `json:\"thought_signature,omitempty\"`\n\t\t\t\tFunctionCall          *antigravityFunctionCall `json:\"functionCall,omitempty\"`\n\t\t\t} `json:\"parts\"`\n\t\t\tRole string `json:\"role\"`\n\t\t} `json:\"content\"`\n\t\tFinishReason string `json:\"finishReason\"`\n\t} `json:\"candidates\"`\n\tUsageMetadata struct {\n\t\tPromptTokenCount     int `json:\"promptTokenCount\"`\n\t\tCandidatesTokenCount int `json:\"candidatesTokenCount\"`\n\t\tTotalTokenCount      int `json:\"totalTokenCount\"`\n\t} `json:\"usageMetadata\"`\n}\n\nfunc (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {\n\tvar contentParts []string\n\tvar toolCalls []ToolCall\n\tvar usage *UsageInfo\n\tvar finishReason string\n\n\tscanner := bufio.NewScanner(strings.NewReader(body))\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimPrefix(line, \"data: \")\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\t// v1internal SSE wraps the Gemini response in a \"response\" field\n\t\tvar sseChunk struct {\n\t\t\tResponse antigravityJSONResponse `json:\"response\"`\n\t\t}\n\t\tif err := json.Unmarshal([]byte(data), &sseChunk); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tresp := sseChunk.Response\n\n\t\tfor _, candidate := range resp.Candidates {\n\t\t\tfor _, part := range candidate.Content.Parts {\n\t\t\t\tif part.Text != \"\" {\n\t\t\t\t\tcontentParts = append(contentParts, part.Text)\n\t\t\t\t}\n\t\t\t\tif part.FunctionCall != nil {\n\t\t\t\t\targumentsJSON, _ := json.Marshal(part.FunctionCall.Args)\n\t\t\t\t\ttoolCalls = append(toolCalls, ToolCall{\n\t\t\t\t\t\tID:        fmt.Sprintf(\"call_%s_%d\", part.FunctionCall.Name, time.Now().UnixNano()),\n\t\t\t\t\t\tName:      part.FunctionCall.Name,\n\t\t\t\t\t\tArguments: part.FunctionCall.Args,\n\t\t\t\t\t\tFunction: &FunctionCall{\n\t\t\t\t\t\t\tName:      part.FunctionCall.Name,\n\t\t\t\t\t\t\tArguments: string(argumentsJSON),\n\t\t\t\t\t\t\tThoughtSignature: extractPartThoughtSignature(\n\t\t\t\t\t\t\t\tpart.ThoughtSignature,\n\t\t\t\t\t\t\t\tpart.ThoughtSignatureSnake,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tif candidate.FinishReason != \"\" {\n\t\t\t\tfinishReason = candidate.FinishReason\n\t\t\t}\n\t\t}\n\n\t\tif resp.UsageMetadata.TotalTokenCount > 0 {\n\t\t\tusage = &UsageInfo{\n\t\t\t\tPromptTokens:     resp.UsageMetadata.PromptTokenCount,\n\t\t\t\tCompletionTokens: resp.UsageMetadata.CandidatesTokenCount,\n\t\t\t\tTotalTokens:      resp.UsageMetadata.TotalTokenCount,\n\t\t\t}\n\t\t}\n\t}\n\n\tmappedFinish := \"stop\"\n\tif len(toolCalls) > 0 {\n\t\tmappedFinish = \"tool_calls\"\n\t}\n\tif finishReason == \"MAX_TOKENS\" {\n\t\tmappedFinish = \"length\"\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      strings.Join(contentParts, \"\"),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: mappedFinish,\n\t\tUsage:        usage,\n\t}, nil\n}\n\nfunc extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string {\n\tif thoughtSignature != \"\" {\n\t\treturn thoughtSignature\n\t}\n\tif thoughtSignatureSnake != \"\" {\n\t\treturn thoughtSignatureSnake\n\t}\n\treturn \"\"\n}\n\n// --- Schema sanitization ---\n\n// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.\nvar geminiUnsupportedKeywords = map[string]bool{\n\t\"patternProperties\":    true,\n\t\"additionalProperties\": true,\n\t\"$schema\":              true,\n\t\"$id\":                  true,\n\t\"$ref\":                 true,\n\t\"$defs\":                true,\n\t\"definitions\":          true,\n\t\"examples\":             true,\n\t\"minLength\":            true,\n\t\"maxLength\":            true,\n\t\"minimum\":              true,\n\t\"maximum\":              true,\n\t\"multipleOf\":           true,\n\t\"pattern\":              true,\n\t\"format\":               true,\n\t\"minItems\":             true,\n\t\"maxItems\":             true,\n\t\"uniqueItems\":          true,\n\t\"minProperties\":        true,\n\t\"maxProperties\":        true,\n}\n\nfunc sanitizeSchemaForGemini(schema map[string]any) map[string]any {\n\tif schema == nil {\n\t\treturn nil\n\t}\n\n\tresult := make(map[string]any)\n\tfor k, v := range schema {\n\t\tif geminiUnsupportedKeywords[k] {\n\t\t\tcontinue\n\t\t}\n\t\t// Recursively sanitize nested objects\n\t\tswitch val := v.(type) {\n\t\tcase map[string]any:\n\t\t\tresult[k] = sanitizeSchemaForGemini(val)\n\t\tcase []any:\n\t\t\tsanitized := make([]any, len(val))\n\t\t\tfor i, item := range val {\n\t\t\t\tif m, ok := item.(map[string]any); ok {\n\t\t\t\t\tsanitized[i] = sanitizeSchemaForGemini(m)\n\t\t\t\t} else {\n\t\t\t\t\tsanitized[i] = item\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult[k] = sanitized\n\t\tdefault:\n\t\t\tresult[k] = v\n\t\t}\n\t}\n\n\t// Ensure top-level has type: \"object\" if properties are present\n\tif _, hasProps := result[\"properties\"]; hasProps {\n\t\tif _, hasType := result[\"type\"]; !hasType {\n\t\t\tresult[\"type\"] = \"object\"\n\t\t}\n\t}\n\n\treturn result\n}\n\n// --- Token source ---\n\nfunc createAntigravityTokenSource() func() (string, string, error) {\n\treturn func() (string, string, error) {\n\t\tcred, err := auth.GetCredential(\"google-antigravity\")\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"loading auth credentials: %w\", err)\n\t\t}\n\t\tif cred == nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\n\t\t\t\t\"no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity\",\n\t\t\t)\n\t\t}\n\n\t\t// Refresh if needed\n\t\tif cred.NeedsRefresh() && cred.RefreshToken != \"\" {\n\t\t\toauthCfg := auth.GoogleAntigravityOAuthConfig()\n\t\t\trefreshed, err := auth.RefreshAccessToken(cred, oauthCfg)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"refreshing token: %w\", err)\n\t\t\t}\n\t\t\trefreshed.Email = cred.Email\n\t\t\tif refreshed.ProjectID == \"\" {\n\t\t\t\trefreshed.ProjectID = cred.ProjectID\n\t\t\t}\n\t\t\tif err := auth.SetCredential(\"google-antigravity\", refreshed); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"saving refreshed token: %w\", err)\n\t\t\t}\n\t\t\tcred = refreshed\n\t\t}\n\n\t\tif cred.IsExpired() {\n\t\t\treturn \"\", \"\", fmt.Errorf(\n\t\t\t\t\"antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity\",\n\t\t\t)\n\t\t}\n\n\t\tprojectID := cred.ProjectID\n\t\tif projectID == \"\" {\n\t\t\t// Try to fetch project ID from API\n\t\t\tfetchedID, err := FetchAntigravityProjectID(cred.AccessToken)\n\t\t\tif err != nil {\n\t\t\t\tlogger.WarnCF(\"provider.antigravity\", \"Could not fetch project ID, using fallback\", map[string]any{\n\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t})\n\t\t\t\tprojectID = \"rising-fact-p41fc\" // Default fallback (same as OpenCode)\n\t\t\t} else {\n\t\t\t\tprojectID = fetchedID\n\t\t\t\tcred.ProjectID = projectID\n\t\t\t\t_ = auth.SetCredential(\"google-antigravity\", cred)\n\t\t\t}\n\t\t}\n\n\t\treturn cred.AccessToken, projectID, nil\n\t}\n}\n\n// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.\nfunc FetchAntigravityProjectID(accessToken string) (string, error) {\n\treqBody, _ := json.Marshal(map[string]any{\n\t\t\"metadata\": map[string]any{\n\t\t\t\"ideType\":    \"IDE_UNSPECIFIED\",\n\t\t\t\"platform\":   \"PLATFORM_UNSPECIFIED\",\n\t\t\t\"pluginType\": \"GEMINI\",\n\t\t},\n\t})\n\n\treq, err := http.NewRequest(\"POST\", antigravityBaseURL+\"/v1internal:loadCodeAssist\", bytes.NewReader(reqBody))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", antigravityUserAgent)\n\treq.Header.Set(\"X-Goog-Api-Client\", antigravityXGoogClient)\n\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"reading loadCodeAssist response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"loadCodeAssist failed: %s\", string(body))\n\t}\n\n\tvar result struct {\n\t\tCloudAICompanionProject string `json:\"cloudaicompanionProject\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif result.CloudAICompanionProject == \"\" {\n\t\treturn \"\", fmt.Errorf(\"no project ID in loadCodeAssist response\")\n\t}\n\n\treturn result.CloudAICompanionProject, nil\n}\n\n// FetchAntigravityModels fetches available models from the Cloud Code Assist API.\nfunc FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {\n\treqBody, _ := json.Marshal(map[string]any{\n\t\t\"project\": projectID,\n\t})\n\n\treq, err := http.NewRequest(\"POST\", antigravityBaseURL+\"/v1internal:fetchAvailableModels\", bytes.NewReader(reqBody))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", antigravityUserAgent)\n\treq.Header.Set(\"X-Goog-Api-Client\", antigravityXGoogClient)\n\n\tclient := &http.Client{Timeout: 15 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading fetchAvailableModels response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\n\t\t\t\"fetchAvailableModels failed (HTTP %d): %s\",\n\t\t\tresp.StatusCode,\n\t\t\ttruncateString(string(body), 200),\n\t\t)\n\t}\n\n\tvar result struct {\n\t\tModels map[string]struct {\n\t\t\tDisplayName string `json:\"displayName\"`\n\t\t\tQuotaInfo   struct {\n\t\t\t\tRemainingFraction any    `json:\"remainingFraction\"`\n\t\t\t\tResetTime         string `json:\"resetTime\"`\n\t\t\t\tIsExhausted       bool   `json:\"isExhausted\"`\n\t\t\t} `json:\"quotaInfo\"`\n\t\t} `json:\"models\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing models response: %w\", err)\n\t}\n\n\tvar models []AntigravityModelInfo\n\tfor id, info := range result.Models {\n\t\tmodels = append(models, AntigravityModelInfo{\n\t\t\tID:          id,\n\t\t\tDisplayName: info.DisplayName,\n\t\t\tIsExhausted: info.QuotaInfo.IsExhausted,\n\t\t})\n\t}\n\n\t// Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already\n\thasFlashPreview := false\n\thasFlash := false\n\tfor _, m := range models {\n\t\tif m.ID == \"gemini-3-flash-preview\" {\n\t\t\thasFlashPreview = true\n\t\t}\n\t\tif m.ID == \"gemini-3-flash\" {\n\t\t\thasFlash = true\n\t\t}\n\t}\n\tif !hasFlashPreview {\n\t\tmodels = append(models, AntigravityModelInfo{\n\t\t\tID:          \"gemini-3-flash-preview\",\n\t\t\tDisplayName: \"Gemini 3 Flash (Preview)\",\n\t\t})\n\t}\n\tif !hasFlash {\n\t\tmodels = append(models, AntigravityModelInfo{\n\t\t\tID:          \"gemini-3-flash\",\n\t\t\tDisplayName: \"Gemini 3 Flash\",\n\t\t})\n\t}\n\n\treturn models, nil\n}\n\ntype AntigravityModelInfo struct {\n\tID          string `json:\"id\"`\n\tDisplayName string `json:\"display_name\"`\n\tIsExhausted bool   `json:\"is_exhausted\"`\n}\n\n// --- Helpers ---\n\nfunc truncateString(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\treturn s[:maxLen] + \"...\"\n}\n\nfunc randomString(n int) string {\n\tconst letters = \"abcdefghijklmnopqrstuvwxyz0123456789\"\n\tb := make([]byte, n)\n\tfor i := range b {\n\t\tb[i] = letters[rand.Intn(len(letters))]\n\t}\n\treturn string(b)\n}\n\nfunc (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {\n\tvar errResp struct {\n\t\tError struct {\n\t\t\tCode    int              `json:\"code\"`\n\t\t\tMessage string           `json:\"message\"`\n\t\t\tStatus  string           `json:\"status\"`\n\t\t\tDetails []map[string]any `json:\"details\"`\n\t\t} `json:\"error\"`\n\t}\n\n\tif err := json.Unmarshal(body, &errResp); err != nil {\n\t\treturn fmt.Errorf(\"antigravity API error (HTTP %d): %s\", statusCode, truncateString(string(body), 500))\n\t}\n\n\tmsg := errResp.Error.Message\n\tif statusCode == 429 {\n\t\t// Try to extract quota reset info\n\t\tfor _, detail := range errResp.Error.Details {\n\t\t\tif typeVal, ok := detail[\"@type\"].(string); ok && strings.HasSuffix(typeVal, \"ErrorInfo\") {\n\t\t\t\tif metadata, ok := detail[\"metadata\"].(map[string]any); ok {\n\t\t\t\t\tif delay, ok := metadata[\"quotaResetDelay\"].(string); ok {\n\t\t\t\t\t\treturn fmt.Errorf(\"antigravity rate limit exceeded: %s (reset in %s)\", msg, delay)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn fmt.Errorf(\"antigravity rate limit exceeded: %s\", msg)\n\t}\n\n\treturn fmt.Errorf(\"antigravity API error (%s): %s\", errResp.Error.Status, msg)\n}\n"
  },
  {
    "path": "pkg/providers/antigravity_provider_test.go",
    "content": "package providers\n\nimport \"testing\"\n\nfunc TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {\n\tp := &AntigravityProvider{}\n\n\tmessages := []Message{\n\t\t{\n\t\t\tRole: \"assistant\",\n\t\t\tToolCalls: []ToolCall{{\n\t\t\t\tID: \"call_read_file_123\",\n\t\t\t\tFunction: &FunctionCall{\n\t\t\t\t\tName:      \"read_file\",\n\t\t\t\t\tArguments: `{\"path\":\"README.md\"}`,\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\tRole:       \"tool\",\n\t\t\tToolCallID: \"call_read_file_123\",\n\t\t\tContent:    \"ok\",\n\t\t},\n\t}\n\n\treq := p.buildRequest(messages, nil, \"\", nil)\n\tif len(req.Contents) != 2 {\n\t\tt.Fatalf(\"expected 2 contents, got %d\", len(req.Contents))\n\t}\n\n\tmodelPart := req.Contents[0].Parts[0]\n\tif modelPart.FunctionCall == nil {\n\t\tt.Fatal(\"expected functionCall in assistant message\")\n\t}\n\tif modelPart.FunctionCall.Name != \"read_file\" {\n\t\tt.Fatalf(\"expected functionCall name read_file, got %q\", modelPart.FunctionCall.Name)\n\t}\n\tif got := modelPart.FunctionCall.Args[\"path\"]; got != \"README.md\" {\n\t\tt.Fatalf(\"expected functionCall args[path] to be README.md, got %v\", got)\n\t}\n\n\ttoolPart := req.Contents[1].Parts[0]\n\tif toolPart.FunctionResponse == nil {\n\t\tt.Fatal(\"expected functionResponse in tool message\")\n\t}\n\tif toolPart.FunctionResponse.Name != \"read_file\" {\n\t\tt.Fatalf(\"expected functionResponse name read_file, got %q\", toolPart.FunctionResponse.Name)\n\t}\n}\n\nfunc TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {\n\tgot := resolveToolResponseName(\"call_search_docs_999\", map[string]string{})\n\tif got != \"search_docs\" {\n\t\tt.Fatalf(\"expected inferred tool name search_docs, got %q\", got)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/azure/provider.go",
    "content": "package azure\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/common\"\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\ntype (\n\tLLMResponse    = protocoltypes.LLMResponse\n\tMessage        = protocoltypes.Message\n\tToolDefinition = protocoltypes.ToolDefinition\n)\n\nconst (\n\t// azureAPIVersion is the Azure OpenAI API version used for all requests.\n\tazureAPIVersion       = \"2024-10-21\"\n\tdefaultRequestTimeout = common.DefaultRequestTimeout\n)\n\n// Provider implements the LLM provider interface for Azure OpenAI endpoints.\n// It handles Azure-specific authentication (api-key header), URL construction\n// (deployment-based), and request body formatting (max_completion_tokens, no model field).\ntype Provider struct {\n\tapiKey     string\n\tapiBase    string\n\thttpClient *http.Client\n}\n\n// Option configures the Azure Provider.\ntype Option func(*Provider)\n\n// WithRequestTimeout sets the HTTP request timeout.\nfunc WithRequestTimeout(timeout time.Duration) Option {\n\treturn func(p *Provider) {\n\t\tif timeout > 0 {\n\t\t\tp.httpClient.Timeout = timeout\n\t\t}\n\t}\n}\n\n// NewProvider creates a new Azure OpenAI provider.\nfunc NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {\n\tp := &Provider{\n\t\tapiKey:     apiKey,\n\t\tapiBase:    strings.TrimRight(apiBase, \"/\"),\n\t\thttpClient: common.NewHTTPClient(proxy),\n\t}\n\n\tfor _, opt := range opts {\n\t\tif opt != nil {\n\t\t\topt(p)\n\t\t}\n\t}\n\n\treturn p\n}\n\n// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.\nfunc NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {\n\treturn NewProvider(\n\t\tapiKey, apiBase, proxy,\n\t\tWithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),\n\t)\n}\n\n// Chat sends a chat completion request to the Azure OpenAI endpoint.\n// The model parameter is used as the Azure deployment name in the URL.\nfunc (p *Provider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\tif p.apiBase == \"\" {\n\t\treturn nil, fmt.Errorf(\"Azure API base not configured\")\n\t}\n\n\t// model is the deployment name for Azure OpenAI\n\tdeployment := model\n\n\t// Build Azure-specific URL safely using url.JoinPath and query encoding\n\t// to prevent path traversal or query injection via deployment names.\n\tbase, err := url.JoinPath(p.apiBase, \"openai/deployments\", deployment, \"chat/completions\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to build Azure request URL: %w\", err)\n\t}\n\trequestURL := base + \"?api-version=\" + azureAPIVersion\n\n\t// Build request body — no \"model\" field (Azure infers from deployment URL)\n\trequestBody := map[string]any{\n\t\t\"messages\": common.SerializeMessages(messages),\n\t}\n\n\tif len(tools) > 0 {\n\t\trequestBody[\"tools\"] = tools\n\t\trequestBody[\"tool_choice\"] = \"auto\"\n\t}\n\n\t// Azure OpenAI always uses max_completion_tokens\n\tif maxTokens, ok := common.AsInt(options[\"max_tokens\"]); ok {\n\t\trequestBody[\"max_completion_tokens\"] = maxTokens\n\t}\n\n\tif temperature, ok := common.AsFloat(options[\"temperature\"]); ok {\n\t\trequestBody[\"temperature\"] = temperature\n\t}\n\n\tjsonData, err := json.Marshal(requestBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", requestURL, bytes.NewReader(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\t// Azure uses api-key header instead of Authorization: Bearer\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tif p.apiKey != \"\" {\n\t\treq.Header.Set(\"Api-Key\", p.apiKey)\n\t}\n\n\tresp, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, common.HandleErrorResponse(resp, p.apiBase)\n\t}\n\n\treturn common.ReadAndParseResponse(resp, p.apiBase)\n}\n\n// GetDefaultModel returns an empty string as Azure deployments are user-configured.\nfunc (p *Provider) GetDefaultModel() string {\n\treturn \"\"\n}\n"
  },
  {
    "path": "pkg/providers/azure/provider_test.go",
    "content": "package azure\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n)\n\n// writeValidResponse writes a minimal valid Azure OpenAI chat completion response.\nfunc writeValidResponse(w http.ResponseWriter) {\n\tresp := map[string]any{\n\t\t\"choices\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t},\n\t\t},\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(resp)\n}\n\nfunc TestProviderChat_AzureURLConstruction(t *testing.T) {\n\tvar capturedPath string\n\tvar capturedAPIVersion string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcapturedPath = r.URL.Path\n\t\tcapturedAPIVersion = r.URL.Query().Get(\"api-version\")\n\t\twriteValidResponse(w)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"my-gpt5-deployment\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\twantPath := \"/openai/deployments/my-gpt5-deployment/chat/completions\"\n\tif capturedPath != wantPath {\n\t\tt.Errorf(\"URL path = %q, want %q\", capturedPath, wantPath)\n\t}\n\tif capturedAPIVersion != azureAPIVersion {\n\t\tt.Errorf(\"api-version = %q, want %q\", capturedAPIVersion, azureAPIVersion)\n\t}\n}\n\nfunc TestProviderChat_AzureAuthHeader(t *testing.T) {\n\tvar capturedAPIKey string\n\tvar capturedAuth string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcapturedAPIKey = r.Header.Get(\"Api-Key\")\n\t\tcapturedAuth = r.Header.Get(\"Authorization\")\n\t\twriteValidResponse(w)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-azure-key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"deployment\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif capturedAPIKey != \"test-azure-key\" {\n\t\tt.Errorf(\"api-key header = %q, want %q\", capturedAPIKey, \"test-azure-key\")\n\t}\n\tif capturedAuth != \"\" {\n\t\tt.Errorf(\"Authorization header should be empty, got %q\", capturedAuth)\n\t}\n}\n\nfunc TestProviderChat_AzureOmitsModelFromBody(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tjson.NewDecoder(r.Body).Decode(&requestBody)\n\t\twriteValidResponse(w)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"deployment\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif _, exists := requestBody[\"model\"]; exists {\n\t\tt.Error(\"request body should not contain 'model' field for Azure OpenAI\")\n\t}\n}\n\nfunc TestProviderChat_AzureUsesMaxCompletionTokens(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tjson.NewDecoder(r.Body).Decode(&requestBody)\n\t\twriteValidResponse(w)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-key\", server.URL, \"\")\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"deployment\",\n\t\tmap[string]any{\"max_tokens\": 2048},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif _, exists := requestBody[\"max_completion_tokens\"]; !exists {\n\t\tt.Error(\"request body should contain 'max_completion_tokens'\")\n\t}\n\tif _, exists := requestBody[\"max_tokens\"]; exists {\n\t\tt.Error(\"request body should not contain 'max_tokens'\")\n\t}\n}\n\nfunc TestProviderChat_AzureHTTPError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Error(w, `{\"error\":\"unauthorized\"}`, http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"bad-key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"deployment\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n}\n\nfunc TestProviderChat_AzureParseToolCalls(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\": map[string]any{\n\t\t\t\t\t\t\"content\": \"\",\n\t\t\t\t\t\t\"tool_calls\": []map[string]any{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"id\":   \"call_1\",\n\t\t\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\t\t\"function\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"name\":      \"get_weather\",\n\t\t\t\t\t\t\t\t\t\"arguments\": `{\"city\":\"Seattle\"}`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"finish_reason\": \"tool_calls\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-key\", server.URL, \"\")\n\tout, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"weather?\"}}, nil, \"deployment\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n\tif out.ToolCalls[0].Name != \"get_weather\" {\n\t\tt.Errorf(\"ToolCalls[0].Name = %q, want %q\", out.ToolCalls[0].Name, \"get_weather\")\n\t}\n}\n\nfunc TestProvider_AzureEmptyAPIBase(t *testing.T) {\n\tp := NewProvider(\"test-key\", \"\", \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"deployment\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty API base\")\n\t}\n}\n\nfunc TestProvider_AzureRequestTimeoutDefault(t *testing.T) {\n\tp := NewProvider(\"test-key\", \"https://example.com\", \"\")\n\tif p.httpClient.Timeout != defaultRequestTimeout {\n\t\tt.Errorf(\"timeout = %v, want %v\", p.httpClient.Timeout, defaultRequestTimeout)\n\t}\n}\n\nfunc TestProvider_AzureRequestTimeoutOverride(t *testing.T) {\n\tp := NewProvider(\"test-key\", \"https://example.com\", \"\", WithRequestTimeout(300*time.Second))\n\tif p.httpClient.Timeout != 300*time.Second {\n\t\tt.Errorf(\"timeout = %v, want %v\", p.httpClient.Timeout, 300*time.Second)\n\t}\n}\n\nfunc TestProvider_AzureNewProviderWithTimeout(t *testing.T) {\n\tp := NewProviderWithTimeout(\"test-key\", \"https://example.com\", \"\", 180)\n\tif p.httpClient.Timeout != 180*time.Second {\n\t\tt.Errorf(\"timeout = %v, want %v\", p.httpClient.Timeout, 180*time.Second)\n\t}\n}\n\nfunc TestProviderChat_AzureDeploymentNameEscaped(t *testing.T) {\n\tvar capturedPath string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcapturedPath = r.URL.RawPath // use RawPath to see percent-encoding\n\t\tif capturedPath == \"\" {\n\t\t\tcapturedPath = r.URL.Path\n\t\t}\n\t\twriteValidResponse(w)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"test-key\", server.URL, \"\")\n\n\t// Deployment name with characters that could cause path injection\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"my deploy/../../admin\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\t// The slash and special chars in the deployment name must be escaped, not treated as path separators\n\tif capturedPath == \"/openai/deployments/my deploy/../../admin/chat/completions\" {\n\t\tt.Fatal(\"deployment name was interpolated without escaping — path injection possible\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/claude_cli_provider.go",
    "content": "package providers\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.\ntype ClaudeCliProvider struct {\n\tcommand   string\n\tworkspace string\n}\n\n// NewClaudeCliProvider creates a new Claude CLI provider.\nfunc NewClaudeCliProvider(workspace string) *ClaudeCliProvider {\n\treturn &ClaudeCliProvider{\n\t\tcommand:   \"claude\",\n\t\tworkspace: workspace,\n\t}\n}\n\n// Chat implements LLMProvider.Chat by executing the claude CLI.\nfunc (p *ClaudeCliProvider) Chat(\n\tctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,\n) (*LLMResponse, error) {\n\tsystemPrompt := p.buildSystemPrompt(messages, tools)\n\tprompt := p.messagesToPrompt(messages)\n\n\targs := []string{\"-p\", \"--output-format\", \"json\", \"--dangerously-skip-permissions\", \"--no-chrome\"}\n\tif systemPrompt != \"\" {\n\t\targs = append(args, \"--system-prompt\", systemPrompt)\n\t}\n\tif model != \"\" && model != \"claude-code\" {\n\t\targs = append(args, \"--model\", model)\n\t}\n\targs = append(args, \"-\") // read from stdin\n\n\tcmd := exec.CommandContext(ctx, p.command, args...)\n\tif p.workspace != \"\" {\n\t\tcmd.Dir = p.workspace\n\t}\n\tcmd.Stdin = bytes.NewReader([]byte(prompt))\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Run(); err != nil {\n\t\tstderrStr := strings.TrimSpace(stderr.String())\n\t\tstdoutStr := strings.TrimSpace(stdout.String())\n\t\tswitch {\n\t\tcase stderrStr != \"\" && stdoutStr != \"\":\n\t\t\treturn nil, fmt.Errorf(\"claude cli error: %w\\nstderr: %s\\nstdout: %s\", err, stderrStr, stdoutStr)\n\t\tcase stderrStr != \"\":\n\t\t\treturn nil, fmt.Errorf(\"claude cli error: %s\", stderrStr)\n\t\tcase stdoutStr != \"\":\n\t\t\treturn nil, fmt.Errorf(\"claude cli error: %w\\noutput: %s\", err, stdoutStr)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"claude cli error: %w\", err)\n\t\t}\n\t}\n\n\treturn p.parseClaudeCliResponse(stdout.String())\n}\n\n// GetDefaultModel returns the default model identifier.\nfunc (p *ClaudeCliProvider) GetDefaultModel() string {\n\treturn \"claude-code\"\n}\n\n// messagesToPrompt converts messages to a CLI-compatible prompt string.\nfunc (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {\n\tvar parts []string\n\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\t// handled via --system-prompt flag\n\t\tcase \"user\":\n\t\t\tparts = append(parts, \"User: \"+msg.Content)\n\t\tcase \"assistant\":\n\t\t\tparts = append(parts, \"Assistant: \"+msg.Content)\n\t\tcase \"tool\":\n\t\t\tparts = append(parts, fmt.Sprintf(\"[Tool Result for %s]: %s\", msg.ToolCallID, msg.Content))\n\t\t}\n\t}\n\n\t// Simplify single user message\n\tif len(parts) == 1 && strings.HasPrefix(parts[0], \"User: \") {\n\t\treturn strings.TrimPrefix(parts[0], \"User: \")\n\t}\n\n\treturn strings.Join(parts, \"\\n\")\n}\n\n// buildSystemPrompt combines system messages and tool definitions.\nfunc (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string {\n\tvar parts []string\n\n\tfor _, msg := range messages {\n\t\tif msg.Role == \"system\" {\n\t\t\tparts = append(parts, msg.Content)\n\t\t}\n\t}\n\n\tif len(tools) > 0 {\n\t\tparts = append(parts, buildCLIToolsPrompt(tools))\n\t}\n\n\treturn strings.Join(parts, \"\\n\\n\")\n}\n\n// parseClaudeCliResponse parses the JSON output from the claude CLI.\nfunc (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {\n\tvar resp claudeCliJSONResponse\n\tif err := json.Unmarshal([]byte(output), &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse claude cli response: %w\", err)\n\t}\n\n\tif resp.IsError {\n\t\treturn nil, fmt.Errorf(\"claude cli returned error: %s\", resp.Result)\n\t}\n\n\ttoolCalls := p.extractToolCalls(resp.Result)\n\n\tfinishReason := \"stop\"\n\tcontent := resp.Result\n\tif len(toolCalls) > 0 {\n\t\tfinishReason = \"tool_calls\"\n\t\tcontent = p.stripToolCallsJSON(resp.Result)\n\t}\n\n\tvar usage *UsageInfo\n\tif resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {\n\t\tusage = &UsageInfo{\n\t\t\tPromptTokens:     resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens,\n\t\t\tCompletionTokens: resp.Usage.OutputTokens,\n\t\t\tTotalTokens:      resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens,\n\t\t}\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      strings.TrimSpace(content),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: finishReason,\n\t\tUsage:        usage,\n\t}, nil\n}\n\n// extractToolCalls delegates to the shared extractToolCallsFromText function.\nfunc (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {\n\treturn extractToolCallsFromText(text)\n}\n\n// stripToolCallsJSON delegates to the shared stripToolCallsFromText function.\nfunc (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {\n\treturn stripToolCallsFromText(text)\n}\n\n// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.\nfunc findMatchingBrace(text string, pos int) int {\n\tdepth := 0\n\tfor i := pos; i < len(text); i++ {\n\t\tif text[i] == '{' {\n\t\t\tdepth++\n\t\t} else if text[i] == '}' {\n\t\t\tdepth--\n\t\t\tif depth == 0 {\n\t\t\t\treturn i + 1\n\t\t\t}\n\t\t}\n\t}\n\treturn pos\n}\n\n// claudeCliJSONResponse represents the JSON output from the claude CLI.\n// Matches the real claude CLI v2.x output format.\ntype claudeCliJSONResponse struct {\n\tType         string             `json:\"type\"`\n\tSubtype      string             `json:\"subtype\"`\n\tIsError      bool               `json:\"is_error\"`\n\tResult       string             `json:\"result\"`\n\tSessionID    string             `json:\"session_id\"`\n\tTotalCostUSD float64            `json:\"total_cost_usd\"`\n\tDurationMS   int                `json:\"duration_ms\"`\n\tDurationAPI  int                `json:\"duration_api_ms\"`\n\tNumTurns     int                `json:\"num_turns\"`\n\tUsage        claudeCliUsageInfo `json:\"usage\"`\n}\n\n// claudeCliUsageInfo represents token usage from the claude CLI response.\ntype claudeCliUsageInfo struct {\n\tInputTokens              int `json:\"input_tokens\"`\n\tOutputTokens             int `json:\"output_tokens\"`\n\tCacheCreationInputTokens int `json:\"cache_creation_input_tokens\"`\n\tCacheReadInputTokens     int `json:\"cache_read_input_tokens\"`\n}\n"
  },
  {
    "path": "pkg/providers/claude_cli_provider_integration_test.go",
    "content": "//go:build integration\n\npackage providers\n\nimport (\n\t\"context\"\n\texec \"os/exec\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI.\n// Run with: go test -tags=integration ./pkg/providers/...\nfunc TestIntegration_RealClaudeCLI(t *testing.T) {\n\t// Check if claude CLI is available\n\tpath, err := exec.LookPath(\"claude\")\n\tif err != nil {\n\t\tt.Skip(\"claude CLI not found in PATH, skipping integration test\")\n\t}\n\tt.Logf(\"Using claude CLI at: %s\", path)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tresp, err := p.Chat(ctx, []Message{\n\t\t{Role: \"user\", Content: \"Respond with only the word 'pong'. Nothing else.\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() with real CLI error = %v\", err)\n\t}\n\n\t// Verify response structure\n\tif resp.Content == \"\" {\n\t\tt.Error(\"Content is empty\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage == nil {\n\t\tt.Error(\"Usage should not be nil from real CLI\")\n\t} else {\n\t\tif resp.Usage.PromptTokens == 0 {\n\t\t\tt.Error(\"PromptTokens should be > 0\")\n\t\t}\n\t\tif resp.Usage.CompletionTokens == 0 {\n\t\t\tt.Error(\"CompletionTokens should be > 0\")\n\t\t}\n\t\tt.Logf(\"Usage: prompt=%d, completion=%d, total=%d\",\n\t\t\tresp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)\n\t}\n\n\tt.Logf(\"Response content: %q\", resp.Content)\n\n\t// Loose check - should contain \"pong\" somewhere (model might capitalize or add punctuation)\n\tif !strings.Contains(strings.ToLower(resp.Content), \"pong\") {\n\t\tt.Errorf(\"Content = %q, expected to contain 'pong'\", resp.Content)\n\t}\n}\n\nfunc TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {\n\tif _, err := exec.LookPath(\"claude\"); err != nil {\n\t\tt.Skip(\"claude CLI not found in PATH\")\n\t}\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tresp, err := p.Chat(ctx, []Message{\n\t\t{Role: \"system\", Content: \"You are a calculator. Only respond with numbers. No text.\"},\n\t\t{Role: \"user\", Content: \"What is 2+2?\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tt.Logf(\"Response: %q\", resp.Content)\n\n\tif !strings.Contains(resp.Content, \"4\") {\n\t\tt.Errorf(\"Content = %q, expected to contain '4'\", resp.Content)\n\t}\n}\n\nfunc TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) {\n\tif _, err := exec.LookPath(\"claude\"); err != nil {\n\t\tt.Skip(\"claude CLI not found in PATH\")\n\t}\n\n\t// Run claude directly and verify our parser handles real output\n\tcmd := exec.Command(\"claude\", \"-p\", \"--output-format\", \"json\",\n\t\t\"--dangerously-skip-permissions\", \"--no-chrome\", \"--no-session-persistence\", \"-\")\n\tcmd.Stdin = strings.NewReader(\"Say hi\")\n\tcmd.Dir = t.TempDir()\n\n\toutput, err := cmd.Output()\n\tif err != nil {\n\t\tt.Fatalf(\"claude CLI failed: %v\", err)\n\t}\n\n\tt.Logf(\"Raw CLI output: %s\", string(output))\n\n\t// Verify our parser can handle real output\n\tp := NewClaudeCliProvider(\"\")\n\tresp, err := p.parseClaudeCliResponse(string(output))\n\tif err != nil {\n\t\tt.Fatalf(\"parseClaudeCliResponse() failed on real CLI output: %v\", err)\n\t}\n\n\tif resp.Content == \"\" {\n\t\tt.Error(\"parsed Content is empty\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want stop\", resp.FinishReason)\n\t}\n\tif resp.Usage == nil {\n\t\tt.Error(\"Usage should not be nil\")\n\t}\n\n\tt.Logf(\"Parsed: content=%q, finish=%s, usage=%+v\", resp.Content, resp.FinishReason, resp.Usage)\n}\n"
  },
  {
    "path": "pkg/providers/claude_cli_provider_test.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// --- Compile-time interface check ---\n\nvar _ LLMProvider = (*ClaudeCliProvider)(nil)\n\n// --- Helper: create mock CLI scripts ---\n\n// createMockCLI creates a temporary script that simulates the claude CLI.\n// Uses files for stdout/stderr to avoid shell quoting issues with JSON.\nfunc createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {\n\tt.Helper()\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"mock CLI scripts not supported on Windows\")\n\t}\n\n\tdir := t.TempDir()\n\n\tif stdout != \"\" {\n\t\tif err := os.WriteFile(filepath.Join(dir, \"stdout.txt\"), []byte(stdout), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif stderr != \"\" {\n\t\tif err := os.WriteFile(filepath.Join(dir, \"stderr.txt\"), []byte(stderr), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tvar sb strings.Builder\n\tsb.WriteString(\"#!/bin/sh\\n\")\n\tif stderr != \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"cat '%s/stderr.txt' >&2\\n\", dir))\n\t}\n\tif stdout != \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"cat '%s/stdout.txt'\\n\", dir))\n\t}\n\tsb.WriteString(fmt.Sprintf(\"exit %d\\n\", exitCode))\n\n\tscript := filepath.Join(dir, \"claude\")\n\tif err := os.WriteFile(script, []byte(sb.String()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn script\n}\n\n// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests).\nfunc createSlowMockCLI(t *testing.T, sleepSeconds int) string {\n\tt.Helper()\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"mock CLI scripts not supported on Windows\")\n\t}\n\n\tdir := t.TempDir()\n\tscript := filepath.Join(dir, \"claude\")\n\tcontent := fmt.Sprintf(\"#!/bin/sh\\nsleep %d\\necho '{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"late\\\"}'\\n\", sleepSeconds)\n\tif err := os.WriteFile(script, []byte(content), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn script\n}\n\n// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON.\nfunc createArgCaptureCLI(t *testing.T, argsFile string) string {\n\tt.Helper()\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"mock CLI scripts not supported on Windows\")\n\t}\n\n\tdir := t.TempDir()\n\tscript := filepath.Join(dir, \"claude\")\n\tcontent := fmt.Sprintf(`#!/bin/sh\necho \"$@\" > '%s'\ncat <<'EOFMOCK'\n{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"test\"}\nEOFMOCK\n`, argsFile)\n\tif err := os.WriteFile(script, []byte(content), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn script\n}\n\n// --- Constructor tests ---\n\nfunc TestNewClaudeCliProvider(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/test/workspace\")\n\tif p == nil {\n\t\tt.Fatal(\"NewClaudeCliProvider returned nil\")\n\t}\n\tif p.workspace != \"/test/workspace\" {\n\t\tt.Errorf(\"workspace = %q, want %q\", p.workspace, \"/test/workspace\")\n\t}\n\tif p.command != \"claude\" {\n\t\tt.Errorf(\"command = %q, want %q\", p.command, \"claude\")\n\t}\n}\n\nfunc TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) {\n\tp := NewClaudeCliProvider(\"\")\n\tif p.workspace != \"\" {\n\t\tt.Errorf(\"workspace = %q, want empty\", p.workspace)\n\t}\n}\n\n// --- GetDefaultModel tests ---\n\nfunc TestClaudeCliProvider_GetDefaultModel(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tif got := p.GetDefaultModel(); got != \"claude-code\" {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, \"claude-code\")\n\t}\n}\n\n// --- Chat() tests ---\n\nfunc TestChat_Success(t *testing.T) {\n\tmockJSON := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"Hello from mock!\",\"session_id\":\"sess_123\",\"total_cost_usd\":0.005,\"duration_ms\":200,\"duration_api_ms\":150,\"num_turns\":1,\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":0}}`\n\tscript := createMockCLI(t, mockJSON, \"\", 0)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\tresp, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif resp.Content != \"Hello from mock!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello from mock!\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif len(resp.ToolCalls) != 0 {\n\t\tt.Errorf(\"ToolCalls len = %d, want 0\", len(resp.ToolCalls))\n\t}\n\tif resp.Usage == nil {\n\t\tt.Fatal(\"Usage should not be nil\")\n\t}\n\tif resp.Usage.PromptTokens != 110 { // 10 + 100 + 0\n\t\tt.Errorf(\"PromptTokens = %d, want 110\", resp.Usage.PromptTokens)\n\t}\n\tif resp.Usage.CompletionTokens != 5 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 5\", resp.Usage.CompletionTokens)\n\t}\n\tif resp.Usage.TotalTokens != 115 { // 110 + 5\n\t\tt.Errorf(\"TotalTokens = %d, want 115\", resp.Usage.TotalTokens)\n\t}\n}\n\nfunc TestChat_IsErrorResponse(t *testing.T) {\n\tmockJSON := `{\"type\":\"result\",\"subtype\":\"error\",\"is_error\":true,\"result\":\"Rate limit exceeded\",\"session_id\":\"s1\",\"total_cost_usd\":0}`\n\tscript := createMockCLI(t, mockJSON, \"\", 0)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error when is_error=true\")\n\t}\n\tif !strings.Contains(err.Error(), \"Rate limit exceeded\") {\n\t\tt.Errorf(\"error = %q, want to contain 'Rate limit exceeded'\", err.Error())\n\t}\n}\n\nfunc TestChat_WithToolCallsInResponse(t *testing.T) {\n\tmockJSON := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"Checking weather.\\n{\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"get_weather\\\",\\\"arguments\\\":\\\"{\\\\\\\"location\\\\\\\":\\\\\\\"NYC\\\\\\\"}\\\"}}]}\",\"session_id\":\"s1\",\"total_cost_usd\":0.01,\"usage\":{\"input_tokens\":5,\"output_tokens\":20,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}`\n\tscript := createMockCLI(t, mockJSON, \"\", 0)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\tresp, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"What's the weather?\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif resp.FinishReason != \"tool_calls\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"tool_calls\")\n\t}\n\tif len(resp.ToolCalls) != 1 {\n\t\tt.Fatalf(\"ToolCalls len = %d, want 1\", len(resp.ToolCalls))\n\t}\n\tif resp.ToolCalls[0].Name != \"get_weather\" {\n\t\tt.Errorf(\"ToolCalls[0].Name = %q, want %q\", resp.ToolCalls[0].Name, \"get_weather\")\n\t}\n\tif resp.ToolCalls[0].Arguments[\"location\"] != \"NYC\" {\n\t\tt.Errorf(\"ToolCalls[0].Arguments[location] = %v, want NYC\", resp.ToolCalls[0].Arguments[\"location\"])\n\t}\n}\n\nfunc TestChat_StderrError(t *testing.T) {\n\tscript := createMockCLI(t, \"\", \"Error: rate limited\", 1)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"rate limited\") {\n\t\tt.Errorf(\"error = %q, want to contain 'rate limited'\", err.Error())\n\t}\n}\n\nfunc TestChat_NonZeroExitNoStderr(t *testing.T) {\n\tscript := createMockCLI(t, \"\", \"\", 1)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error for non-zero exit\")\n\t}\n\tif !strings.Contains(err.Error(), \"claude cli error\") {\n\t\tt.Errorf(\"error = %q, want to contain 'claude cli error'\", err.Error())\n\t}\n}\n\nfunc TestChat_CommandNotFound(t *testing.T) {\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = \"/nonexistent/claude-binary-that-does-not-exist\"\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error for missing command\")\n\t}\n}\n\nfunc TestChat_InvalidResponseJSON(t *testing.T) {\n\tscript := createMockCLI(t, \"not valid json at all\", \"\", 0)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error for invalid JSON\")\n\t}\n\tif !strings.Contains(err.Error(), \"failed to parse claude cli response\") {\n\t\tt.Errorf(\"error = %q, want to contain 'failed to parse claude cli response'\", err.Error())\n\t}\n}\n\nfunc TestChat_ContextCancellation(t *testing.T) {\n\tscript := createSlowMockCLI(t, 2) // sleep 2s\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\tctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)\n\tdefer cancel()\n\n\tstart := time.Now()\n\t_, err := p.Chat(ctx, []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\telapsed := time.Since(start)\n\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected error on context cancellation\")\n\t}\n\t// Should fail well before the full 2s sleep completes\n\tif elapsed > 3*time.Second {\n\t\tt.Errorf(\"Chat() took %v, expected to fail faster via context cancellation\", elapsed)\n\t}\n}\n\nfunc TestChat_PassesSystemPromptFlag(t *testing.T) {\n\targsFile := filepath.Join(t.TempDir(), \"args.txt\")\n\tscript := createArgCaptureCLI(t, argsFile)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"system\", Content: \"Be helpful.\"},\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\targsBytes, err := os.ReadFile(argsFile)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to read args file: %v\", err)\n\t}\n\targs := string(argsBytes)\n\tif !strings.Contains(args, \"--system-prompt\") {\n\t\tt.Errorf(\"CLI args missing --system-prompt, got: %s\", args)\n\t}\n}\n\nfunc TestChat_PassesModelFlag(t *testing.T) {\n\targsFile := filepath.Join(t.TempDir(), \"args.txt\")\n\tscript := createArgCaptureCLI(t, argsFile)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}, nil, \"claude-sonnet-4.6\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\targsBytes, _ := os.ReadFile(argsFile)\n\targs := string(argsBytes)\n\tif !strings.Contains(args, \"--model\") {\n\t\tt.Errorf(\"CLI args missing --model, got: %s\", args)\n\t}\n\tif !strings.Contains(args, \"claude-sonnet-4.6\") {\n\t\tt.Errorf(\"CLI args missing model name, got: %s\", args)\n\t}\n}\n\nfunc TestChat_SkipsModelFlagForClaudeCode(t *testing.T) {\n\targsFile := filepath.Join(t.TempDir(), \"args.txt\")\n\tscript := createArgCaptureCLI(t, argsFile)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}, nil, \"claude-code\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\targsBytes, _ := os.ReadFile(argsFile)\n\targs := string(argsBytes)\n\tif strings.Contains(args, \"--model\") {\n\t\tt.Errorf(\"CLI args should NOT contain --model for claude-code, got: %s\", args)\n\t}\n}\n\nfunc TestChat_SkipsModelFlagForEmptyModel(t *testing.T) {\n\targsFile := filepath.Join(t.TempDir(), \"args.txt\")\n\tscript := createArgCaptureCLI(t, argsFile)\n\n\tp := NewClaudeCliProvider(t.TempDir())\n\tp.command = script\n\n\t_, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\targsBytes, _ := os.ReadFile(argsFile)\n\targs := string(argsBytes)\n\tif strings.Contains(args, \"--model\") {\n\t\tt.Errorf(\"CLI args should NOT contain --model for empty model, got: %s\", args)\n\t}\n}\n\nfunc TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {\n\tmockJSON := `{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s\"}`\n\tscript := createMockCLI(t, mockJSON, \"\", 0)\n\n\tp := NewClaudeCliProvider(\"\")\n\tp.command = script\n\n\tresp, err := p.Chat(context.Background(), []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() with empty workspace error = %v\", err)\n\t}\n\tif resp.Content != \"ok\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"ok\")\n\t}\n}\n\n// --- CreateProvider factory tests ---\n\nfunc TestCreateProvider_ClaudeCli(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{ModelName: \"claude-sonnet-4.6\", Model: \"claude-cli/claude-sonnet-4.6\", Workspace: \"/test/ws\"},\n\t}\n\tcfg.Agents.Defaults.Model = \"claude-sonnet-4.6\"\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider(claude-cli) error = %v\", err)\n\t}\n\n\tcliProvider, ok := provider.(*ClaudeCliProvider)\n\tif !ok {\n\t\tt.Fatalf(\"CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider\", provider)\n\t}\n\tif cliProvider.workspace != \"/test/ws\" {\n\t\tt.Errorf(\"workspace = %q, want %q\", cliProvider.workspace, \"/test/ws\")\n\t}\n}\n\nfunc TestCreateProvider_ClaudeCode(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{ModelName: \"claude-code\", Model: \"claude-cli/claude-code\"},\n\t}\n\tcfg.Agents.Defaults.Model = \"claude-code\"\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider(claude-code) error = %v\", err)\n\t}\n\tif _, ok := provider.(*ClaudeCliProvider); !ok {\n\t\tt.Fatalf(\"CreateProvider(claude-code) returned %T, want *ClaudeCliProvider\", provider)\n\t}\n}\n\nfunc TestCreateProvider_ClaudeCodec(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{ModelName: \"claudecode\", Model: \"claude-cli/claudecode\"},\n\t}\n\tcfg.Agents.Defaults.Model = \"claudecode\"\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider(claudecode) error = %v\", err)\n\t}\n\tif _, ok := provider.(*ClaudeCliProvider); !ok {\n\t\tt.Fatalf(\"CreateProvider(claudecode) returned %T, want *ClaudeCliProvider\", provider)\n\t}\n}\n\nfunc TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{ModelName: \"claude-cli\", Model: \"claude-cli/claude-sonnet\"},\n\t}\n\tcfg.Agents.Defaults.Model = \"claude-cli\"\n\tcfg.Agents.Defaults.Workspace = \"\"\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider error = %v\", err)\n\t}\n\n\tcliProvider, ok := provider.(*ClaudeCliProvider)\n\tif !ok {\n\t\tt.Fatalf(\"returned %T, want *ClaudeCliProvider\", provider)\n\t}\n\tif cliProvider.workspace != \".\" {\n\t\tt.Errorf(\"workspace = %q, want %q (default)\", cliProvider.workspace, \".\")\n\t}\n}\n\n// --- messagesToPrompt tests ---\n\nfunc TestMessagesToPrompt_SingleUser(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}\n\tgot := p.messagesToPrompt(messages)\n\twant := \"Hello\"\n\tif got != want {\n\t\tt.Errorf(\"messagesToPrompt() = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestMessagesToPrompt_Conversation(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t\t{Role: \"assistant\", Content: \"Hello!\"},\n\t\t{Role: \"user\", Content: \"How are you?\"},\n\t}\n\tgot := p.messagesToPrompt(messages)\n\twant := \"User: Hi\\nAssistant: Hello!\\nUser: How are you?\"\n\tif got != want {\n\t\tt.Errorf(\"messagesToPrompt() = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestMessagesToPrompt_WithSystemMessage(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful.\"},\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}\n\tgot := p.messagesToPrompt(messages)\n\twant := \"Hello\"\n\tif got != want {\n\t\tt.Errorf(\"messagesToPrompt() = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestMessagesToPrompt_WithToolResults(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"What's the weather?\"},\n\t\t{Role: \"tool\", Content: `{\"temp\": 72}`, ToolCallID: \"call_123\"},\n\t}\n\tgot := p.messagesToPrompt(messages)\n\tif !strings.Contains(got, \"[Tool Result for call_123]\") {\n\t\tt.Errorf(\"messagesToPrompt() missing tool result marker, got %q\", got)\n\t}\n\tif !strings.Contains(got, `{\"temp\": 72}`) {\n\t\tt.Errorf(\"messagesToPrompt() missing tool result content, got %q\", got)\n\t}\n}\n\nfunc TestMessagesToPrompt_EmptyMessages(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tgot := p.messagesToPrompt(nil)\n\tif got != \"\" {\n\t\tt.Errorf(\"messagesToPrompt(nil) = %q, want empty\", got)\n\t}\n}\n\nfunc TestMessagesToPrompt_OnlySystemMessages(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"System 1\"},\n\t\t{Role: \"system\", Content: \"System 2\"},\n\t}\n\tgot := p.messagesToPrompt(messages)\n\tif got != \"\" {\n\t\tt.Errorf(\"messagesToPrompt() with only system msgs = %q, want empty\", got)\n\t}\n}\n\n// --- buildSystemPrompt tests ---\n\nfunc TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}\n\tgot := p.buildSystemPrompt(messages, nil)\n\tif got != \"\" {\n\t\tt.Errorf(\"buildSystemPrompt() = %q, want empty\", got)\n\t}\n}\n\nfunc TestBuildSystemPrompt_SystemOnly(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful.\"},\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}\n\tgot := p.buildSystemPrompt(messages, nil)\n\tif got != \"You are helpful.\" {\n\t\tt.Errorf(\"buildSystemPrompt() = %q, want %q\", got, \"You are helpful.\")\n\t}\n}\n\nfunc TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful.\"},\n\t\t{Role: \"system\", Content: \"Be concise.\"},\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}\n\tgot := p.buildSystemPrompt(messages, nil)\n\tif !strings.Contains(got, \"You are helpful.\") {\n\t\tt.Error(\"missing first system message\")\n\t}\n\tif !strings.Contains(got, \"Be concise.\") {\n\t\tt.Error(\"missing second system message\")\n\t}\n\t// Should be joined with double newline\n\twant := \"You are helpful.\\n\\nBe concise.\"\n\tif got != want {\n\t\tt.Errorf(\"buildSystemPrompt() = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildSystemPrompt_WithTools(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful.\"},\n\t}\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"get_weather\",\n\t\t\t\tDescription: \"Get weather for a location\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\"location\": map[string]any{\"type\": \"string\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tgot := p.buildSystemPrompt(messages, tools)\n\tif !strings.Contains(got, \"You are helpful.\") {\n\t\tt.Error(\"buildSystemPrompt() missing system message\")\n\t}\n\tif !strings.Contains(got, \"get_weather\") {\n\t\tt.Error(\"buildSystemPrompt() missing tool definition\")\n\t}\n\tif !strings.Contains(got, \"Available Tools\") {\n\t\tt.Error(\"buildSystemPrompt() missing tools header\")\n\t}\n}\n\nfunc TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"test_tool\",\n\t\t\t\tDescription: \"A test tool\",\n\t\t\t},\n\t\t},\n\t}\n\tgot := p.buildSystemPrompt(nil, tools)\n\tif !strings.Contains(got, \"test_tool\") {\n\t\tt.Error(\"should include tool definitions even without system messages\")\n\t}\n}\n\n// --- buildToolsPrompt tests ---\n\nfunc TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"other\", Function: ToolFunctionDefinition{Name: \"skip_me\"}},\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"include_me\", Description: \"Included\"}},\n\t}\n\tgot := buildCLIToolsPrompt(tools)\n\tif strings.Contains(got, \"skip_me\") {\n\t\tt.Error(\"buildToolsPrompt() should skip non-function tools\")\n\t}\n\tif !strings.Contains(got, \"include_me\") {\n\t\tt.Error(\"buildToolsPrompt() should include function tools\")\n\t}\n}\n\nfunc TestBuildToolsPrompt_NoDescription(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"bare_tool\"}},\n\t}\n\tgot := buildCLIToolsPrompt(tools)\n\tif !strings.Contains(got, \"bare_tool\") {\n\t\tt.Error(\"should include tool name\")\n\t}\n\tif strings.Contains(got, \"Description:\") {\n\t\tt.Error(\"should not include Description: line when empty\")\n\t}\n}\n\nfunc TestBuildToolsPrompt_NoParameters(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{\n\t\t\tName:        \"no_params_tool\",\n\t\t\tDescription: \"A tool with no parameters\",\n\t\t}},\n\t}\n\tgot := buildCLIToolsPrompt(tools)\n\tif strings.Contains(got, \"Parameters:\") {\n\t\tt.Error(\"should not include Parameters: section when nil\")\n\t}\n}\n\n// --- parseClaudeCliResponse tests ---\n\nfunc TestParseClaudeCliResponse_TextOnly(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"Hello, world!\",\"session_id\":\"abc123\",\"total_cost_usd\":0.01,\"duration_ms\":500,\"usage\":{\"input_tokens\":10,\"output_tokens\":20,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}`\n\n\tresp, err := p.parseClaudeCliResponse(output)\n\tif err != nil {\n\t\tt.Fatalf(\"parseClaudeCliResponse() error = %v\", err)\n\t}\n\tif resp.Content != \"Hello, world!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello, world!\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif len(resp.ToolCalls) != 0 {\n\t\tt.Errorf(\"ToolCalls = %d, want 0\", len(resp.ToolCalls))\n\t}\n\tif resp.Usage == nil {\n\t\tt.Fatal(\"Usage should not be nil\")\n\t}\n\tif resp.Usage.PromptTokens != 10 {\n\t\tt.Errorf(\"PromptTokens = %d, want 10\", resp.Usage.PromptTokens)\n\t}\n\tif resp.Usage.CompletionTokens != 20 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 20\", resp.Usage.CompletionTokens)\n\t}\n}\n\nfunc TestParseClaudeCliResponse_EmptyResult(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"\",\"session_id\":\"abc\"}`\n\n\tresp, err := p.parseClaudeCliResponse(output)\n\tif err != nil {\n\t\tt.Fatalf(\"error = %v\", err)\n\t}\n\tif resp.Content != \"\" {\n\t\tt.Errorf(\"Content = %q, want empty\", resp.Content)\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n}\n\nfunc TestParseClaudeCliResponse_IsError(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"error\",\"is_error\":true,\"result\":\"Something went wrong\",\"session_id\":\"abc\"}`\n\n\t_, err := p.parseClaudeCliResponse(output)\n\tif err == nil {\n\t\tt.Fatal(\"expected error when is_error=true\")\n\t}\n\tif !strings.Contains(err.Error(), \"Something went wrong\") {\n\t\tt.Errorf(\"error = %q, want to contain 'Something went wrong'\", err.Error())\n\t}\n}\n\nfunc TestParseClaudeCliResponse_NoUsage(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hi\",\"session_id\":\"s\"}`\n\n\tresp, err := p.parseClaudeCliResponse(output)\n\tif err != nil {\n\t\tt.Fatalf(\"error = %v\", err)\n\t}\n\tif resp.Usage != nil {\n\t\tt.Errorf(\"Usage should be nil when no tokens, got %+v\", resp.Usage)\n\t}\n}\n\nfunc TestParseClaudeCliResponse_InvalidJSON(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\t_, err := p.parseClaudeCliResponse(\"not json\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid JSON\")\n\t}\n\tif !strings.Contains(err.Error(), \"failed to parse claude cli response\") {\n\t\tt.Errorf(\"error = %q, want to contain 'failed to parse claude cli response'\", err.Error())\n\t}\n}\n\nfunc TestParseClaudeCliResponse_WithToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"Let me check.\\n{\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"get_weather\\\",\\\"arguments\\\":\\\"{\\\\\\\"location\\\\\\\":\\\\\\\"Tokyo\\\\\\\"}\\\"}}]}\",\"session_id\":\"abc123\",\"total_cost_usd\":0.01}`\n\n\tresp, err := p.parseClaudeCliResponse(output)\n\tif err != nil {\n\t\tt.Fatalf(\"error = %v\", err)\n\t}\n\tif resp.FinishReason != \"tool_calls\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"tool_calls\")\n\t}\n\tif len(resp.ToolCalls) != 1 {\n\t\tt.Fatalf(\"ToolCalls = %d, want 1\", len(resp.ToolCalls))\n\t}\n\ttc := resp.ToolCalls[0]\n\tif tc.Name != \"get_weather\" {\n\t\tt.Errorf(\"Name = %q, want %q\", tc.Name, \"get_weather\")\n\t}\n\tif tc.Function == nil {\n\t\tt.Fatal(\"Function is nil\")\n\t}\n\tif tc.Function.Name != \"get_weather\" {\n\t\tt.Errorf(\"Function.Name = %q, want %q\", tc.Function.Name, \"get_weather\")\n\t}\n\tif tc.Arguments[\"location\"] != \"Tokyo\" {\n\t\tt.Errorf(\"Arguments[location] = %v, want Tokyo\", tc.Arguments[\"location\"])\n\t}\n\tif strings.Contains(resp.Content, \"tool_calls\") {\n\t\tt.Errorf(\"Content should not contain tool_calls JSON, got %q\", resp.Content)\n\t}\n\tif resp.Content != \"Let me check.\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Let me check.\")\n\t}\n}\n\nfunc TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\toutput := `{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"  hello  \\n  \",\"session_id\":\"s\"}`\n\n\tresp, err := p.parseClaudeCliResponse(output)\n\tif err != nil {\n\t\tt.Fatalf(\"error = %v\", err)\n\t}\n\tif resp.Content != \"hello\" {\n\t\tt.Errorf(\"Content = %q, want %q (should be trimmed)\", resp.Content, \"hello\")\n\t}\n}\n\n// --- extractToolCalls tests ---\n\nfunc TestExtractToolCalls_NoToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tgot := p.extractToolCalls(\"Just a regular response.\")\n\tif len(got) != 0 {\n\t\tt.Errorf(\"extractToolCalls() = %d, want 0\", len(got))\n\t}\n}\n\nfunc TestExtractToolCalls_WithToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := `Here's the result:\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"test\",\"arguments\":\"{}\"}}]}`\n\n\tgot := p.extractToolCalls(text)\n\tif len(got) != 1 {\n\t\tt.Fatalf(\"extractToolCalls() = %d, want 1\", len(got))\n\t}\n\tif got[0].ID != \"call_1\" {\n\t\tt.Errorf(\"ID = %q, want %q\", got[0].ID, \"call_1\")\n\t}\n\tif got[0].Name != \"test\" {\n\t\tt.Errorf(\"Name = %q, want %q\", got[0].Name, \"test\")\n\t}\n\tif got[0].Type != \"function\" {\n\t\tt.Errorf(\"Type = %q, want %q\", got[0].Type, \"function\")\n\t}\n}\n\nfunc TestExtractToolCalls_InvalidJSON(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tgot := p.extractToolCalls(`{\"tool_calls\":invalid}`)\n\tif len(got) != 0 {\n\t\tt.Errorf(\"extractToolCalls() with invalid JSON = %d, want 0\", len(got))\n\t}\n}\n\nfunc TestExtractToolCalls_MultipleToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := `{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"/tmp/test\\\"}\"}},{\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"/tmp/out\\\",\\\"content\\\":\\\"hello\\\"}\"}}]}`\n\n\tgot := p.extractToolCalls(text)\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"extractToolCalls() = %d, want 2\", len(got))\n\t}\n\tif got[0].Name != \"read_file\" {\n\t\tt.Errorf(\"[0].Name = %q, want %q\", got[0].Name, \"read_file\")\n\t}\n\tif got[1].Name != \"write_file\" {\n\t\tt.Errorf(\"[1].Name = %q, want %q\", got[1].Name, \"write_file\")\n\t}\n\t// Verify arguments were parsed\n\tif got[0].Arguments[\"path\"] != \"/tmp/test\" {\n\t\tt.Errorf(\"[0].Arguments[path] = %v, want /tmp/test\", got[0].Arguments[\"path\"])\n\t}\n\tif got[1].Arguments[\"content\"] != \"hello\" {\n\t\tt.Errorf(\"[1].Arguments[content] = %v, want hello\", got[1].Arguments[\"content\"])\n\t}\n}\n\nfunc TestExtractToolCalls_UnmatchedBrace(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\tgot := p.extractToolCalls(`{\"tool_calls\":[{\"id\":\"call_1\"`)\n\tif len(got) != 0 {\n\t\tt.Errorf(\"extractToolCalls() with unmatched brace = %d, want 0\", len(got))\n\t}\n}\n\nfunc TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := `{\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"fn\",\"arguments\":\"{\\\"num\\\":42,\\\"flag\\\":true,\\\"name\\\":\\\"test\\\"}\"}}]}`\n\n\tgot := p.extractToolCalls(text)\n\tif len(got) != 1 {\n\t\tt.Fatalf(\"len = %d, want 1\", len(got))\n\t}\n\t// Verify different argument types\n\tif got[0].Arguments[\"num\"] != float64(42) {\n\t\tt.Errorf(\"Arguments[num] = %v (%T), want 42\", got[0].Arguments[\"num\"], got[0].Arguments[\"num\"])\n\t}\n\tif got[0].Arguments[\"flag\"] != true {\n\t\tt.Errorf(\"Arguments[flag] = %v, want true\", got[0].Arguments[\"flag\"])\n\t}\n\tif got[0].Arguments[\"name\"] != \"test\" {\n\t\tt.Errorf(\"Arguments[name] = %v, want test\", got[0].Arguments[\"name\"])\n\t}\n\t// Verify raw arguments string is preserved in FunctionCall\n\tif got[0].Function.Arguments == \"\" {\n\t\tt.Error(\"Function.Arguments should contain raw JSON string\")\n\t}\n}\n\n// --- stripToolCallsJSON tests ---\n\nfunc TestStripToolCallsJSON(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := `Let me check the weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"test\",\"arguments\":\"{}\"}}]}\nDone.`\n\n\tgot := p.stripToolCallsJSON(text)\n\tif strings.Contains(got, \"tool_calls\") {\n\t\tt.Errorf(\"should remove tool_calls JSON, got %q\", got)\n\t}\n\tif !strings.Contains(got, \"Let me check the weather.\") {\n\t\tt.Errorf(\"should keep text before, got %q\", got)\n\t}\n\tif !strings.Contains(got, \"Done.\") {\n\t\tt.Errorf(\"should keep text after, got %q\", got)\n\t}\n}\n\nfunc TestStripToolCallsJSON_NoToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := \"Just regular text.\"\n\tgot := p.stripToolCallsJSON(text)\n\tif got != text {\n\t\tt.Errorf(\"stripToolCallsJSON() = %q, want %q\", got, text)\n\t}\n}\n\nfunc TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {\n\tp := NewClaudeCliProvider(\"/workspace\")\n\ttext := `{\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"fn\",\"arguments\":\"{}\"}}]}`\n\tgot := p.stripToolCallsJSON(text)\n\tif got != \"\" {\n\t\tt.Errorf(\"stripToolCallsJSON() = %q, want empty\", got)\n\t}\n}\n\n// --- findMatchingBrace tests ---\n\nfunc TestFindMatchingBrace(t *testing.T) {\n\ttests := []struct {\n\t\ttext string\n\t\tpos  int\n\t\twant int\n\t}{\n\t\t{`{\"a\":1}`, 0, 7},\n\t\t{`{\"a\":{\"b\":2}}`, 0, 13},\n\t\t{`text {\"a\":1} more`, 5, 12},\n\t\t{`{unclosed`, 0, 0},      // no match returns pos\n\t\t{`{}`, 0, 2},             // empty object\n\t\t{`{{{}}}`, 0, 6},         // deeply nested\n\t\t{`{\"a\":\"b{c}d\"}`, 0, 13}, // braces in strings (simplified matcher)\n\t}\n\tfor _, tt := range tests {\n\t\tgot := findMatchingBrace(tt.text, tt.pos)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"findMatchingBrace(%q, %d) = %d, want %d\", tt.text, tt.pos, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/claude_provider.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tanthropicprovider \"github.com/sipeed/picoclaw/pkg/providers/anthropic\"\n)\n\ntype ClaudeProvider struct {\n\tdelegate *anthropicprovider.Provider\n}\n\nfunc NewClaudeProvider(token string) *ClaudeProvider {\n\treturn &ClaudeProvider{\n\t\tdelegate: anthropicprovider.NewProvider(token),\n\t}\n}\n\nfunc NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider {\n\treturn &ClaudeProvider{\n\t\tdelegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase),\n\t}\n}\n\nfunc NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {\n\treturn &ClaudeProvider{\n\t\tdelegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource),\n\t}\n}\n\nfunc NewClaudeProviderWithTokenSourceAndBaseURL(\n\ttoken string, tokenSource func() (string, error), apiBase string,\n) *ClaudeProvider {\n\treturn &ClaudeProvider{\n\t\tdelegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),\n\t}\n}\n\nfunc newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {\n\treturn &ClaudeProvider{delegate: delegate}\n}\n\nfunc (p *ClaudeProvider) Chat(\n\tctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,\n) (*LLMResponse, error) {\n\tresp, err := p.delegate.Chat(ctx, messages, tools, model, options)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resp, nil\n}\n\nfunc (p *ClaudeProvider) GetDefaultModel() string {\n\treturn p.delegate.GetDefaultModel()\n}\n\nfunc createClaudeTokenSource() func() (string, error) {\n\treturn func() (string, error) {\n\t\tcred, err := getCredential(\"anthropic\")\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"loading auth credentials: %w\", err)\n\t\t}\n\t\tif cred == nil {\n\t\t\treturn \"\", fmt.Errorf(\"no credentials for anthropic. Run: picoclaw auth login --provider anthropic\")\n\t\t}\n\t\treturn cred.AccessToken, nil\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/claude_provider_test.go",
    "content": "package providers\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/anthropics/anthropic-sdk-go\"\n\tanthropicoption \"github.com/anthropics/anthropic-sdk-go/option\"\n\n\tanthropicprovider \"github.com/sipeed/picoclaw/pkg/providers/anthropic\"\n)\n\nfunc TestClaudeProvider_ChatRoundTrip(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/v1/messages\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer test-token\" {\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tjson.NewDecoder(r.Body).Decode(&reqBody)\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":          \"msg_test\",\n\t\t\t\"type\":        \"message\",\n\t\t\t\"role\":        \"assistant\",\n\t\t\t\"model\":       reqBody[\"model\"],\n\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\"content\": []map[string]any{\n\t\t\t\t{\"type\": \"text\", \"text\": \"Hello! How can I help you?\"},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":  15,\n\t\t\t\t\"output_tokens\": 8,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tdelegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, \"test-token\"))\n\tprovider := newClaudeProviderWithDelegate(delegate)\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"claude-sonnet-4.6\", map[string]any{\"max_tokens\": 1024})\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hello! How can I help you?\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello! How can I help you?\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage.PromptTokens != 15 {\n\t\tt.Errorf(\"PromptTokens = %d, want 15\", resp.Usage.PromptTokens)\n\t}\n}\n\nfunc TestClaudeProvider_GetDefaultModel(t *testing.T) {\n\tp := NewClaudeProvider(\"test-token\")\n\tif got := p.GetDefaultModel(); got != \"claude-sonnet-4.6\" {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, \"claude-sonnet-4.6\")\n\t}\n}\n\nfunc createAnthropicTestClient(baseURL, token string) *anthropic.Client {\n\tc := anthropic.NewClient(\n\t\tanthropicoption.WithAuthToken(token),\n\t\tanthropicoption.WithBaseURL(baseURL),\n\t)\n\treturn &c\n}\n"
  },
  {
    "path": "pkg/providers/codex_cli_credentials.go",
    "content": "package providers\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n)\n\n// CodexHomeEnvVar is the environment variable that overrides the Codex CLI\n// home directory when resolving the codex auth.json credentials file.\n// Default: ~/.codex\nconst CodexHomeEnvVar = \"CODEX_HOME\"\n\n// CodexCliAuth represents the ~/.codex/auth.json file structure.\ntype CodexCliAuth struct {\n\tTokens struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tAccountID    string `json:\"account_id\"`\n\t} `json:\"tokens\"`\n}\n\n// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file.\n// Expiry is estimated as file modification time + 1 hour (same approach as moltbot).\nfunc ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) {\n\tauthPath, err := resolveCodexAuthPath()\n\tif err != nil {\n\t\treturn \"\", \"\", time.Time{}, err\n\t}\n\n\tdata, err := os.ReadFile(authPath)\n\tif err != nil {\n\t\treturn \"\", \"\", time.Time{}, fmt.Errorf(\"reading %s: %w\", authPath, err)\n\t}\n\n\tvar auth CodexCliAuth\n\tif err = json.Unmarshal(data, &auth); err != nil {\n\t\treturn \"\", \"\", time.Time{}, fmt.Errorf(\"parsing %s: %w\", authPath, err)\n\t}\n\n\tif auth.Tokens.AccessToken == \"\" {\n\t\treturn \"\", \"\", time.Time{}, fmt.Errorf(\"no access_token in %s\", authPath)\n\t}\n\n\tstat, err := os.Stat(authPath)\n\tif err != nil {\n\t\texpiresAt = time.Now().Add(time.Hour)\n\t} else {\n\t\texpiresAt = stat.ModTime().Add(time.Hour)\n\t}\n\n\treturn auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil\n}\n\n// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json.\n// This allows the existing CodexProvider to reuse Codex CLI credentials.\nfunc CreateCodexCliTokenSource() func() (string, string, error) {\n\treturn func() (string, string, error) {\n\t\ttoken, accountID, expiresAt, err := ReadCodexCliCredentials()\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"reading codex cli credentials: %w\", err)\n\t\t}\n\n\t\tif time.Now().After(expiresAt) {\n\t\t\treturn \"\", \"\", fmt.Errorf(\n\t\t\t\t\"codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login\",\n\t\t\t)\n\t\t}\n\n\t\treturn token, accountID, nil\n\t}\n}\n\nfunc resolveCodexAuthPath() (string, error) {\n\tcodexHome := os.Getenv(CodexHomeEnvVar)\n\tif codexHome == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"getting home dir: %w\", err)\n\t\t}\n\t\tcodexHome = filepath.Join(home, \".codex\")\n\t}\n\treturn filepath.Join(codexHome, \"auth.json\"), nil\n}\n"
  },
  {
    "path": "pkg/providers/codex_cli_credentials_test.go",
    "content": "package providers\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestReadCodexCliCredentials_Valid(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tauthJSON := `{\n\t\t\"tokens\": {\n\t\t\t\"access_token\": \"test-access-token\",\n\t\t\t\"refresh_token\": \"test-refresh-token\",\n\t\t\t\"account_id\": \"org-test123\"\n\t\t}\n\t}`\n\tif err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\ttoken, accountID, expiresAt, err := ReadCodexCliCredentials()\n\tif err != nil {\n\t\tt.Fatalf(\"ReadCodexCliCredentials() error: %v\", err)\n\t}\n\tif token != \"test-access-token\" {\n\t\tt.Errorf(\"token = %q, want %q\", token, \"test-access-token\")\n\t}\n\tif accountID != \"org-test123\" {\n\t\tt.Errorf(\"accountID = %q, want %q\", accountID, \"org-test123\")\n\t}\n\t// Expiry should be within ~1 hour from now (file was just written)\n\tif expiresAt.Before(time.Now()) {\n\t\tt.Errorf(\"expiresAt = %v, should be in the future\", expiresAt)\n\t}\n\tif expiresAt.After(time.Now().Add(2 * time.Hour)) {\n\t\tt.Errorf(\"expiresAt = %v, should be within ~1 hour\", expiresAt)\n\t}\n}\n\n// readCodexCliCredentialsErr calls ReadCodexCliCredentials and returns only the\n// error, for tests that only need to assert on failure.\nfunc readCodexCliCredentialsErr() error {\n\t_, _, _, err := ReadCodexCliCredentials() //nolint:dogsled\n\treturn err\n}\n\nfunc TestReadCodexCliCredentials_MissingFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\tif err := readCodexCliCredentialsErr(); err == nil {\n\t\tt.Fatal(\"expected error for missing auth.json\")\n\t}\n}\n\nfunc TestReadCodexCliCredentials_EmptyToken(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tauthJSON := `{\"tokens\": {\"access_token\": \"\", \"refresh_token\": \"r\", \"account_id\": \"a\"}}`\n\tif err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\tif err := readCodexCliCredentialsErr(); err == nil {\n\t\tt.Fatal(\"expected error for empty access_token\")\n\t}\n}\n\nfunc TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tif err := os.WriteFile(authPath, []byte(\"not json\"), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\tif err := readCodexCliCredentialsErr(); err == nil {\n\t\tt.Fatal(\"expected error for invalid JSON\")\n\t}\n}\n\nfunc TestReadCodexCliCredentials_NoAccountID(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tauthJSON := `{\"tokens\": {\"access_token\": \"tok123\", \"refresh_token\": \"ref456\"}}`\n\tif err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\ttoken, accountID, _, err := ReadCodexCliCredentials()\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif token != \"tok123\" {\n\t\tt.Errorf(\"token = %q, want %q\", token, \"tok123\")\n\t}\n\tif accountID != \"\" {\n\t\tt.Errorf(\"accountID = %q, want empty\", accountID)\n\t}\n}\n\nfunc TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tcustomDir := filepath.Join(tmpDir, \"custom-codex\")\n\tif err := os.MkdirAll(customDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tauthJSON := `{\"tokens\": {\"access_token\": \"custom-token\", \"refresh_token\": \"r\"}}`\n\tif err := os.WriteFile(filepath.Join(customDir, \"auth.json\"), []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", customDir)\n\n\ttoken, _, _, err := ReadCodexCliCredentials()\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif token != \"custom-token\" {\n\t\tt.Errorf(\"token = %q, want %q\", token, \"custom-token\")\n\t}\n}\n\nfunc TestCreateCodexCliTokenSource_Valid(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tauthJSON := `{\"tokens\": {\"access_token\": \"fresh-token\", \"refresh_token\": \"r\", \"account_id\": \"acc\"}}`\n\tif err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\tsource := CreateCodexCliTokenSource()\n\ttoken, accountID, err := source()\n\tif err != nil {\n\t\tt.Fatalf(\"token source error: %v\", err)\n\t}\n\tif token != \"fresh-token\" {\n\t\tt.Errorf(\"token = %q, want %q\", token, \"fresh-token\")\n\t}\n\tif accountID != \"acc\" {\n\t\tt.Errorf(\"accountID = %q, want %q\", accountID, \"acc\")\n\t}\n}\n\nfunc TestCreateCodexCliTokenSource_Expired(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tauthPath := filepath.Join(tmpDir, \"auth.json\")\n\n\tauthJSON := `{\"tokens\": {\"access_token\": \"old-token\", \"refresh_token\": \"r\"}}`\n\tif err := os.WriteFile(authPath, []byte(authJSON), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Set file modification time to 2 hours ago\n\toldTime := time.Now().Add(-2 * time.Hour)\n\tif err := os.Chtimes(authPath, oldTime, oldTime); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Setenv(\"CODEX_HOME\", tmpDir)\n\n\tsource := CreateCodexCliTokenSource()\n\t_, _, err := source()\n\tif err == nil {\n\t\tt.Fatal(\"expected error for expired credentials\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/codex_cli_provider.go",
    "content": "package providers\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\n// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.\ntype CodexCliProvider struct {\n\tcommand   string\n\tworkspace string\n}\n\n// NewCodexCliProvider creates a new Codex CLI provider.\nfunc NewCodexCliProvider(workspace string) *CodexCliProvider {\n\treturn &CodexCliProvider{\n\t\tcommand:   \"codex\",\n\t\tworkspace: workspace,\n\t}\n}\n\n// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.\nfunc (p *CodexCliProvider) Chat(\n\tctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,\n) (*LLMResponse, error) {\n\tif p.command == \"\" {\n\t\treturn nil, fmt.Errorf(\"codex command not configured\")\n\t}\n\n\tprompt := p.buildPrompt(messages, tools)\n\n\targs := []string{\n\t\t\"exec\",\n\t\t\"--json\",\n\t\t\"--dangerously-bypass-approvals-and-sandbox\",\n\t\t\"--skip-git-repo-check\",\n\t\t\"--color\", \"never\",\n\t}\n\tif model != \"\" && model != \"codex-cli\" {\n\t\targs = append(args, \"-m\", model)\n\t}\n\tif p.workspace != \"\" {\n\t\targs = append(args, \"-C\", p.workspace)\n\t}\n\targs = append(args, \"-\") // read prompt from stdin\n\n\tcmd := exec.CommandContext(ctx, p.command, args...)\n\tcmd.Stdin = bytes.NewReader([]byte(prompt))\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\n\terr := cmd.Run()\n\n\t// Parse JSONL from stdout even if exit code is non-zero,\n\t// because codex writes diagnostic noise to stderr (e.g. rollout errors)\n\t// but still produces valid JSONL output.\n\tif stdoutStr := stdout.String(); stdoutStr != \"\" {\n\t\tresp, parseErr := p.parseJSONLEvents(stdoutStr)\n\t\tif parseErr == nil && resp != nil && (resp.Content != \"\" || len(resp.ToolCalls) > 0) {\n\t\t\treturn resp, nil\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tif ctx.Err() == context.Canceled {\n\t\t\treturn nil, ctx.Err()\n\t\t}\n\t\tif stderrStr := stderr.String(); stderrStr != \"\" {\n\t\t\treturn nil, fmt.Errorf(\"codex cli error: %s\", stderrStr)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"codex cli error: %w\", err)\n\t}\n\n\treturn p.parseJSONLEvents(stdout.String())\n}\n\n// GetDefaultModel returns the default model identifier.\nfunc (p *CodexCliProvider) GetDefaultModel() string {\n\treturn \"codex-cli\"\n}\n\n// buildPrompt converts messages to a prompt string for the Codex CLI.\n// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.\nfunc (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {\n\tvar systemParts []string\n\tvar conversationParts []string\n\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\tsystemParts = append(systemParts, msg.Content)\n\t\tcase \"user\":\n\t\t\tconversationParts = append(conversationParts, msg.Content)\n\t\tcase \"assistant\":\n\t\t\tconversationParts = append(conversationParts, \"Assistant: \"+msg.Content)\n\t\tcase \"tool\":\n\t\t\tconversationParts = append(conversationParts,\n\t\t\t\tfmt.Sprintf(\"[Tool Result for %s]: %s\", msg.ToolCallID, msg.Content))\n\t\t}\n\t}\n\n\tvar sb strings.Builder\n\n\tif len(systemParts) > 0 {\n\t\tsb.WriteString(\"## System Instructions\\n\\n\")\n\t\tsb.WriteString(strings.Join(systemParts, \"\\n\\n\"))\n\t\tsb.WriteString(\"\\n\\n## Task\\n\\n\")\n\t}\n\n\tif len(tools) > 0 {\n\t\tsb.WriteString(buildCLIToolsPrompt(tools))\n\t\tsb.WriteString(\"\\n\\n\")\n\t}\n\n\t// Simplify single user message (no prefix)\n\tif len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {\n\t\treturn conversationParts[0]\n\t}\n\n\tsb.WriteString(strings.Join(conversationParts, \"\\n\"))\n\treturn sb.String()\n}\n\n// codexEvent represents a single JSONL event from `codex exec --json`.\ntype codexEvent struct {\n\tType     string          `json:\"type\"`\n\tThreadID string          `json:\"thread_id,omitempty\"`\n\tMessage  string          `json:\"message,omitempty\"`\n\tItem     *codexEventItem `json:\"item,omitempty\"`\n\tUsage    *codexUsage     `json:\"usage,omitempty\"`\n\tError    *codexEventErr  `json:\"error,omitempty\"`\n}\n\ntype codexEventItem struct {\n\tID       string `json:\"id\"`\n\tType     string `json:\"type\"`\n\tText     string `json:\"text,omitempty\"`\n\tCommand  string `json:\"command,omitempty\"`\n\tStatus   string `json:\"status,omitempty\"`\n\tExitCode *int   `json:\"exit_code,omitempty\"`\n\tOutput   string `json:\"output,omitempty\"`\n}\n\ntype codexUsage struct {\n\tInputTokens       int `json:\"input_tokens\"`\n\tCachedInputTokens int `json:\"cached_input_tokens\"`\n\tOutputTokens      int `json:\"output_tokens\"`\n}\n\ntype codexEventErr struct {\n\tMessage string `json:\"message\"`\n}\n\n// parseJSONLEvents processes the JSONL output from codex exec --json.\nfunc (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {\n\tvar contentParts []string\n\tvar usage *UsageInfo\n\tvar lastError string\n\n\tscanner := bufio.NewScanner(strings.NewReader(output))\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar event codexEvent\n\t\tif err := json.Unmarshal([]byte(line), &event); err != nil {\n\t\t\tcontinue // skip malformed lines\n\t\t}\n\n\t\tswitch event.Type {\n\t\tcase \"item.completed\":\n\t\t\tif event.Item != nil && event.Item.Type == \"agent_message\" && event.Item.Text != \"\" {\n\t\t\t\tcontentParts = append(contentParts, event.Item.Text)\n\t\t\t}\n\t\tcase \"turn.completed\":\n\t\t\tif event.Usage != nil {\n\t\t\t\tpromptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens\n\t\t\t\tusage = &UsageInfo{\n\t\t\t\t\tPromptTokens:     promptTokens,\n\t\t\t\t\tCompletionTokens: event.Usage.OutputTokens,\n\t\t\t\t\tTotalTokens:      promptTokens + event.Usage.OutputTokens,\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"error\":\n\t\t\tlastError = event.Message\n\t\tcase \"turn.failed\":\n\t\t\tif event.Error != nil {\n\t\t\t\tlastError = event.Error.Message\n\t\t\t}\n\t\t}\n\t}\n\n\tif lastError != \"\" && len(contentParts) == 0 {\n\t\treturn nil, fmt.Errorf(\"codex cli: %s\", lastError)\n\t}\n\n\tcontent := strings.Join(contentParts, \"\\n\")\n\n\t// Extract tool calls from response text (same pattern as ClaudeCliProvider)\n\ttoolCalls := extractToolCallsFromText(content)\n\n\tfinishReason := \"stop\"\n\tif len(toolCalls) > 0 {\n\t\tfinishReason = \"tool_calls\"\n\t\tcontent = stripToolCallsFromText(content)\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      strings.TrimSpace(content),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: finishReason,\n\t\tUsage:        usage,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/providers/codex_cli_provider_integration_test.go",
    "content": "//go:build integration\n\npackage providers\n\nimport (\n\t\"context\"\n\texec \"os/exec\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI.\n// Run with: go test -tags=integration ./pkg/providers/...\nfunc TestIntegration_RealCodexCLI(t *testing.T) {\n\tpath, err := exec.LookPath(\"codex\")\n\tif err != nil {\n\t\tt.Skip(\"codex CLI not found in PATH, skipping integration test\")\n\t}\n\tt.Logf(\"Using codex CLI at: %s\", path)\n\n\tp := NewCodexCliProvider(t.TempDir())\n\n\tctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)\n\tdefer cancel()\n\n\tresp, err := p.Chat(ctx, []Message{\n\t\t{Role: \"user\", Content: \"Respond with only the word 'pong'. Nothing else.\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() with real CLI error = %v\", err)\n\t}\n\n\tif resp.Content == \"\" {\n\t\tt.Error(\"Content is empty\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage != nil {\n\t\tt.Logf(\"Usage: prompt=%d, completion=%d, total=%d\",\n\t\t\tresp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)\n\t}\n\n\tt.Logf(\"Response content: %q\", resp.Content)\n\n\tif !strings.Contains(strings.ToLower(resp.Content), \"pong\") {\n\t\tt.Errorf(\"Content = %q, expected to contain 'pong'\", resp.Content)\n\t}\n}\n\nfunc TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {\n\tif _, err := exec.LookPath(\"codex\"); err != nil {\n\t\tt.Skip(\"codex CLI not found in PATH\")\n\t}\n\n\tp := NewCodexCliProvider(t.TempDir())\n\n\tctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)\n\tdefer cancel()\n\n\tresp, err := p.Chat(ctx, []Message{\n\t\t{Role: \"system\", Content: \"You are a calculator. Only respond with numbers. No text.\"},\n\t\t{Role: \"user\", Content: \"What is 2+2?\"},\n\t}, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tt.Logf(\"Response: %q\", resp.Content)\n\n\tif !strings.Contains(resp.Content, \"4\") {\n\t\tt.Errorf(\"Content = %q, expected to contain '4'\", resp.Content)\n\t}\n}\n\nfunc TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) {\n\tif _, err := exec.LookPath(\"codex\"); err != nil {\n\t\tt.Skip(\"codex CLI not found in PATH\")\n\t}\n\n\t// Run codex directly and verify our parser handles real output\n\tcmd := exec.Command(\"codex\", \"exec\",\n\t\t\"--json\",\n\t\t\"--dangerously-bypass-approvals-and-sandbox\",\n\t\t\"--skip-git-repo-check\",\n\t\t\"--color\", \"never\",\n\t\t\"-C\", t.TempDir(),\n\t\t\"-\")\n\tcmd.Stdin = strings.NewReader(\"Say hi\")\n\n\toutput, err := cmd.Output()\n\tif err != nil {\n\t\t// codex may write diagnostic noise to stderr but still produce valid output\n\t\tif len(output) == 0 {\n\t\t\tt.Fatalf(\"codex CLI failed: %v\", err)\n\t\t}\n\t}\n\n\tt.Logf(\"Raw CLI output (first 500 chars): %s\", string(output[:min(len(output), 500)]))\n\n\t// Verify our parser can handle real output\n\tp := NewCodexCliProvider(\"\")\n\tresp, err := p.parseJSONLEvents(string(output))\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() failed on real CLI output: %v\", err)\n\t}\n\n\tif resp.Content == \"\" {\n\t\tt.Error(\"parsed Content is empty\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want stop\", resp.FinishReason)\n\t}\n\n\tt.Logf(\"Parsed: content=%q, finish=%s, usage=%+v\", resp.Content, resp.FinishReason, resp.Usage)\n}\n"
  },
  {
    "path": "pkg/providers/codex_cli_provider_test.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// --- JSONL Event Parsing Tests ---\n\nfunc TestParseJSONLEvents_AgentMessage(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"thread.started\",\"thread_id\":\"abc-123\"}\n{\"type\":\"turn.started\"}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Hello from Codex!\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":100,\"cached_input_tokens\":50,\"output_tokens\":20}}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\tif resp.Content != \"Hello from Codex!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hello from Codex!\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage == nil {\n\t\tt.Fatal(\"Usage should not be nil\")\n\t}\n\tif resp.Usage.PromptTokens != 150 {\n\t\tt.Errorf(\"PromptTokens = %d, want 150\", resp.Usage.PromptTokens)\n\t}\n\tif resp.Usage.CompletionTokens != 20 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 20\", resp.Usage.CompletionTokens)\n\t}\n\tif resp.Usage.TotalTokens != 170 {\n\t\tt.Errorf(\"TotalTokens = %d, want 170\", resp.Usage.TotalTokens)\n\t}\n\tif len(resp.ToolCalls) != 0 {\n\t\tt.Errorf(\"ToolCalls should be empty, got %d\", len(resp.ToolCalls))\n\t}\n}\n\nfunc TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {\n\tp := &CodexCliProvider{}\n\ttoolCallText := `Let me read that file.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"/tmp/test.txt\\\"}\"}}]}`\n\t// Build valid JSONL by marshaling the event\n\titem := codexEvent{\n\t\tType: \"item.completed\",\n\t\tItem: &codexEventItem{ID: \"item_1\", Type: \"agent_message\", Text: toolCallText},\n\t}\n\titemJSON, _ := json.Marshal(item)\n\tusageEvt := `{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":50,\"cached_input_tokens\":0,\"output_tokens\":20}}`\n\tevents := `{\"type\":\"turn.started\"}` + \"\\n\" + string(itemJSON) + \"\\n\" + usageEvt\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\tif resp.FinishReason != \"tool_calls\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"tool_calls\")\n\t}\n\tif len(resp.ToolCalls) != 1 {\n\t\tt.Fatalf(\"ToolCalls count = %d, want 1\", len(resp.ToolCalls))\n\t}\n\tif resp.ToolCalls[0].Name != \"read_file\" {\n\t\tt.Errorf(\"ToolCalls[0].Name = %q, want %q\", resp.ToolCalls[0].Name, \"read_file\")\n\t}\n\tif resp.ToolCalls[0].ID != \"call_1\" {\n\t\tt.Errorf(\"ToolCalls[0].ID = %q, want %q\", resp.ToolCalls[0].ID, \"call_1\")\n\t}\n\tif resp.ToolCalls[0].Function.Arguments != `{\"path\":\"/tmp/test.txt\"}` {\n\t\tt.Errorf(\"ToolCalls[0].Function.Arguments = %q\", resp.ToolCalls[0].Function.Arguments)\n\t}\n\t// Content should have the tool call JSON stripped\n\tif strings.Contains(resp.Content, \"tool_calls\") {\n\t\tt.Errorf(\"Content should not contain tool_calls JSON, got: %q\", resp.Content)\n\t}\n}\n\nfunc TestParseJSONLEvents_MultipleToolCalls(t *testing.T) {\n\tp := &CodexCliProvider{}\n\ttoolCallText := `{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a.txt\\\"}\"}},{\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"b.txt\\\",\\\"content\\\":\\\"hello\\\"}\"}}]}`\n\titem := codexEvent{\n\t\tType: \"item.completed\",\n\t\tItem: &codexEventItem{ID: \"item_1\", Type: \"agent_message\", Text: toolCallText},\n\t}\n\titemJSON, _ := json.Marshal(item)\n\tevents := `{\"type\":\"turn.started\"}` + \"\\n\" + string(itemJSON) + \"\\n\" + `{\"type\":\"turn.completed\"}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\tif len(resp.ToolCalls) != 2 {\n\t\tt.Fatalf(\"ToolCalls count = %d, want 2\", len(resp.ToolCalls))\n\t}\n\tif resp.ToolCalls[0].Name != \"read_file\" {\n\t\tt.Errorf(\"ToolCalls[0].Name = %q, want %q\", resp.ToolCalls[0].Name, \"read_file\")\n\t}\n\tif resp.ToolCalls[1].Name != \"write_file\" {\n\t\tt.Errorf(\"ToolCalls[1].Name = %q, want %q\", resp.ToolCalls[1].Name, \"write_file\")\n\t}\n\tif resp.FinishReason != \"tool_calls\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"tool_calls\")\n\t}\n}\n\nfunc TestParseJSONLEvents_MultipleMessages(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"turn.started\"}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"First part.\"}}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"command_execution\",\"command\":\"ls\",\"status\":\"completed\"}}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_3\",\"type\":\"agent_message\",\"text\":\"Second part.\"}}\n{\"type\":\"turn.completed\"}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\tif resp.Content != \"First part.\\nSecond part.\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"First part.\\nSecond part.\")\n\t}\n}\n\nfunc TestParseJSONLEvents_ErrorEvent(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"thread.started\",\"thread_id\":\"abc\"}\n{\"type\":\"turn.started\"}\n{\"type\":\"error\",\"message\":\"token expired\"}\n{\"type\":\"turn.failed\",\"error\":{\"message\":\"token expired\"}}`\n\n\t_, err := p.parseJSONLEvents(events)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"token expired\") {\n\t\tt.Errorf(\"error = %q, want to contain 'token expired'\", err.Error())\n\t}\n}\n\nfunc TestParseJSONLEvents_TurnFailed(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"turn.started\"}\n{\"type\":\"turn.failed\",\"error\":{\"message\":\"rate limit exceeded\"}}`\n\n\t_, err := p.parseJSONLEvents(events)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"rate limit exceeded\") {\n\t\tt.Errorf(\"error = %q, want to contain 'rate limit exceeded'\", err.Error())\n\t}\n}\n\nfunc TestParseJSONLEvents_ErrorWithContent(t *testing.T) {\n\tp := &CodexCliProvider{}\n\t// If there's an error but also content, return the content (partial success)\n\tevents := `{\"type\":\"turn.started\"}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Partial result.\"}}\n{\"type\":\"error\",\"message\":\"connection reset\"}\n{\"type\":\"turn.failed\",\"error\":{\"message\":\"connection reset\"}}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"should not error when content exists: %v\", err)\n\t}\n\tif resp.Content != \"Partial result.\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Partial result.\")\n\t}\n}\n\nfunc TestParseJSONLEvents_EmptyOutput(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tresp, err := p.parseJSONLEvents(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"empty output should not error: %v\", err)\n\t}\n\tif resp.Content != \"\" {\n\t\tt.Errorf(\"Content = %q, want empty\", resp.Content)\n\t}\n}\n\nfunc TestParseJSONLEvents_MalformedLines(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `not json at all\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Good line.\"}}\nanother bad line\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"should skip malformed lines: %v\", err)\n\t}\n\tif resp.Content != \"Good line.\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Good line.\")\n\t}\n\tif resp.Usage == nil || resp.Usage.TotalTokens != 15 {\n\t\tt.Errorf(\"Usage.TotalTokens = %v, want 15\", resp.Usage)\n\t}\n}\n\nfunc TestParseJSONLEvents_CommandExecution(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"turn.started\"}\n{\"type\":\"item.started\",\"item\":{\"id\":\"item_1\",\"type\":\"command_execution\",\"command\":\"bash -lc ls\",\"status\":\"in_progress\"}}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"command_execution\",\"command\":\"bash -lc ls\",\"status\":\"completed\",\"exit_code\":0,\"output\":\"file1.go\\nfile2.go\"}}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"Found 2 files.\"}}\n{\"type\":\"turn.completed\"}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\t// command_execution items should be skipped; only agent_message text is returned\n\tif resp.Content != \"Found 2 files.\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Found 2 files.\")\n\t}\n}\n\nfunc TestParseJSONLEvents_NoUsage(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tevents := `{\"type\":\"turn.started\"}\n{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"No usage info.\"}}\n{\"type\":\"turn.completed\"}`\n\n\tresp, err := p.parseJSONLEvents(events)\n\tif err != nil {\n\t\tt.Fatalf(\"parseJSONLEvents() error: %v\", err)\n\t}\n\tif resp.Usage != nil {\n\t\tt.Errorf(\"Usage should be nil when turn.completed has no usage, got %+v\", resp.Usage)\n\t}\n}\n\n// --- Prompt Building Tests ---\n\nfunc TestBuildPrompt_SystemAsInstructions(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful.\"},\n\t\t{Role: \"user\", Content: \"Hi there\"},\n\t}\n\n\tprompt := p.buildPrompt(messages, nil)\n\n\tif !strings.Contains(prompt, \"## System Instructions\") {\n\t\tt.Error(\"prompt should contain '## System Instructions'\")\n\t}\n\tif !strings.Contains(prompt, \"You are helpful.\") {\n\t\tt.Error(\"prompt should contain system content\")\n\t}\n\tif !strings.Contains(prompt, \"## Task\") {\n\t\tt.Error(\"prompt should contain '## Task'\")\n\t}\n\tif !strings.Contains(prompt, \"Hi there\") {\n\t\tt.Error(\"prompt should contain user message\")\n\t}\n}\n\nfunc TestBuildPrompt_NoSystem(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Just a question\"},\n\t}\n\n\tprompt := p.buildPrompt(messages, nil)\n\n\tif strings.Contains(prompt, \"## System Instructions\") {\n\t\tt.Error(\"prompt should not contain system instructions header\")\n\t}\n\tif prompt != \"Just a question\" {\n\t\tt.Errorf(\"prompt = %q, want %q\", prompt, \"Just a question\")\n\t}\n}\n\nfunc TestBuildPrompt_WithTools(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Get weather\"},\n\t}\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"get_weather\",\n\t\t\t\tDescription: \"Get current weather\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\"city\": map[string]any{\"type\": \"string\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tprompt := p.buildPrompt(messages, tools)\n\n\tif !strings.Contains(prompt, \"## Available Tools\") {\n\t\tt.Error(\"prompt should contain tools section\")\n\t}\n\tif !strings.Contains(prompt, \"get_weather\") {\n\t\tt.Error(\"prompt should contain tool name\")\n\t}\n\tif !strings.Contains(prompt, \"Get current weather\") {\n\t\tt.Error(\"prompt should contain tool description\")\n\t}\n}\n\nfunc TestBuildPrompt_MultipleMessages(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t\t{Role: \"assistant\", Content: \"Hi! How can I help?\"},\n\t\t{Role: \"user\", Content: \"Tell me about Go\"},\n\t}\n\n\tprompt := p.buildPrompt(messages, nil)\n\n\tif !strings.Contains(prompt, \"Hello\") {\n\t\tt.Error(\"prompt should contain first user message\")\n\t}\n\tif !strings.Contains(prompt, \"Assistant: Hi! How can I help?\") {\n\t\tt.Error(\"prompt should contain assistant message with prefix\")\n\t}\n\tif !strings.Contains(prompt, \"Tell me about Go\") {\n\t\tt.Error(\"prompt should contain second user message\")\n\t}\n}\n\nfunc TestBuildPrompt_ToolResults(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Weather?\"},\n\t\t{Role: \"tool\", Content: `{\"temp\": 72}`, ToolCallID: \"call_1\"},\n\t}\n\n\tprompt := p.buildPrompt(messages, nil)\n\n\tif !strings.Contains(prompt, \"[Tool Result for call_1]\") {\n\t\tt.Error(\"prompt should contain tool result\")\n\t}\n\tif !strings.Contains(prompt, `{\"temp\": 72}`) {\n\t\tt.Error(\"prompt should contain tool result content\")\n\t}\n}\n\nfunc TestBuildPrompt_SystemAndTools(t *testing.T) {\n\tp := &CodexCliProvider{}\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"Be concise.\"},\n\t\t{Role: \"user\", Content: \"Do something\"},\n\t}\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"my_tool\",\n\t\t\t\tDescription: \"A tool\",\n\t\t\t},\n\t\t},\n\t}\n\n\tprompt := p.buildPrompt(messages, tools)\n\n\t// System instructions should come first\n\tsysIdx := strings.Index(prompt, \"## System Instructions\")\n\ttoolIdx := strings.Index(prompt, \"## Available Tools\")\n\ttaskIdx := strings.Index(prompt, \"## Task\")\n\n\tif sysIdx == -1 || toolIdx == -1 || taskIdx == -1 {\n\t\tt.Fatal(\"prompt should contain all sections\")\n\t}\n\tif sysIdx >= taskIdx {\n\t\tt.Error(\"system instructions should come before task\")\n\t}\n\tif taskIdx >= toolIdx {\n\t\tt.Error(\"task section should come before tools in the output\")\n\t}\n}\n\n// --- CLI Argument Tests ---\n\nfunc TestCodexCliProvider_GetDefaultModel(t *testing.T) {\n\tp := NewCodexCliProvider(\"\")\n\tif got := p.GetDefaultModel(); got != \"codex-cli\" {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, \"codex-cli\")\n\t}\n}\n\n// --- Mock CLI Integration Test ---\n\nfunc createMockCodexCLI(t *testing.T, events []string) string {\n\tt.Helper()\n\ttmpDir := t.TempDir()\n\tscriptPath := filepath.Join(tmpDir, \"codex\")\n\n\tvar sb strings.Builder\n\tsb.WriteString(\"#!/bin/bash\\n\")\n\tfor _, event := range events {\n\t\tsb.WriteString(fmt.Sprintf(\"echo '%s'\\n\", event))\n\t}\n\n\tif err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn scriptPath\n}\n\nfunc TestCodexCliProvider_MockCLI_Success(t *testing.T) {\n\tscriptPath := createMockCodexCLI(t, []string{\n\t\t`{\"type\":\"thread.started\",\"thread_id\":\"test-123\"}`,\n\t\t`{\"type\":\"turn.started\"}`,\n\t\t`{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Mock response from Codex CLI\"}}`,\n\t\t`{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":50,\"cached_input_tokens\":10,\"output_tokens\":15}}`,\n\t})\n\n\tp := &CodexCliProvider{\n\t\tcommand:   scriptPath,\n\t\tworkspace: \"\",\n\t}\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := p.Chat(context.Background(), messages, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Mock response from Codex CLI\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Mock response from Codex CLI\")\n\t}\n\tif resp.Usage == nil {\n\t\tt.Fatal(\"Usage should not be nil\")\n\t}\n\tif resp.Usage.PromptTokens != 60 {\n\t\tt.Errorf(\"PromptTokens = %d, want 60\", resp.Usage.PromptTokens)\n\t}\n\tif resp.Usage.CompletionTokens != 15 {\n\t\tt.Errorf(\"CompletionTokens = %d, want 15\", resp.Usage.CompletionTokens)\n\t}\n}\n\nfunc TestCodexCliProvider_MockCLI_Error(t *testing.T) {\n\tscriptPath := createMockCodexCLI(t, []string{\n\t\t`{\"type\":\"thread.started\",\"thread_id\":\"test-err\"}`,\n\t\t`{\"type\":\"turn.started\"}`,\n\t\t`{\"type\":\"error\",\"message\":\"auth token expired\"}`,\n\t\t`{\"type\":\"turn.failed\",\"error\":{\"message\":\"auth token expired\"}}`,\n\t})\n\n\tp := &CodexCliProvider{\n\t\tcommand:   scriptPath,\n\t\tworkspace: \"\",\n\t}\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\t_, err := p.Chat(context.Background(), messages, nil, \"\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"auth token expired\") {\n\t\tt.Errorf(\"error = %q, want to contain 'auth token expired'\", err.Error())\n\t}\n}\n\nfunc TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {\n\t// Mock script that captures args to verify model flag is passed\n\ttmpDir := t.TempDir()\n\tscriptPath := filepath.Join(tmpDir, \"codex\")\n\tscript := `#!/bin/bash\n# Write args to a file for verification\necho \"$@\" > \"` + filepath.Join(tmpDir, \"args.txt\") + `\"\necho '{\"type\":\"item.completed\",\"item\":{\"id\":\"1\",\"type\":\"agent_message\",\"text\":\"ok\"}}'\necho '{\"type\":\"turn.completed\"}'`\n\n\tif err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tp := &CodexCliProvider{\n\t\tcommand:   scriptPath,\n\t\tworkspace: \"/tmp/test-workspace\",\n\t}\n\n\tmessages := []Message{{Role: \"user\", Content: \"test\"}}\n\t_, err := p.Chat(context.Background(), messages, nil, \"gpt-5.3-codex\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\n\t// Verify the args\n\targsData, err := os.ReadFile(filepath.Join(tmpDir, \"args.txt\"))\n\tif err != nil {\n\t\tt.Fatalf(\"reading args: %v\", err)\n\t}\n\targs := string(argsData)\n\n\tif !strings.Contains(args, \"-m gpt-5.3-codex\") {\n\t\tt.Errorf(\"args should contain model flag, got: %s\", args)\n\t}\n\tif !strings.Contains(args, \"-C /tmp/test-workspace\") {\n\t\tt.Errorf(\"args should contain workspace flag, got: %s\", args)\n\t}\n\tif !strings.Contains(args, \"--json\") {\n\t\tt.Errorf(\"args should contain --json, got: %s\", args)\n\t}\n\tif !strings.Contains(args, \"--dangerously-bypass-approvals-and-sandbox\") {\n\t\tt.Errorf(\"args should contain bypass flag, got: %s\", args)\n\t}\n}\n\nfunc TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {\n\t// Script that sleeps forever\n\ttmpDir := t.TempDir()\n\tscriptPath := filepath.Join(tmpDir, \"codex\")\n\tscript := \"#!/bin/bash\\nsleep 60\"\n\n\tif err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tp := &CodexCliProvider{\n\t\tcommand:   scriptPath,\n\t\tworkspace: \"\",\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // cancel immediately\n\n\tmessages := []Message{{Role: \"user\", Content: \"test\"}}\n\t_, err := p.Chat(ctx, messages, nil, \"\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error on canceled context\")\n\t}\n}\n\nfunc TestCodexCliProvider_EmptyCommand(t *testing.T) {\n\tp := &CodexCliProvider{command: \"\"}\n\n\tmessages := []Message{{Role: \"user\", Content: \"test\"}}\n\t_, err := p.Chat(context.Background(), messages, nil, \"\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for empty command\")\n\t}\n}\n\n// --- Integration Test (requires real codex CLI with valid auth) ---\n\nfunc TestCodexCliProvider_Integration(t *testing.T) {\n\tif os.Getenv(\"PICOCLAW_INTEGRATION_TESTS\") == \"\" {\n\t\tt.Skip(\"skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)\")\n\t}\n\n\t// Verify codex is available\n\tcodexPath, err := exec.LookPath(\"codex\")\n\tif err != nil {\n\t\tt.Skip(\"codex CLI not found in PATH\")\n\t}\n\n\tp := &CodexCliProvider{\n\t\tcommand:   codexPath,\n\t\tworkspace: \"\",\n\t}\n\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Respond with just the word 'hello' and nothing else.\"},\n\t}\n\n\tresp, err := p.Chat(context.Background(), messages, nil, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\n\tlower := strings.ToLower(strings.TrimSpace(resp.Content))\n\tif !strings.Contains(lower, \"hello\") {\n\t\tt.Errorf(\"Content = %q, expected to contain 'hello'\", resp.Content)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/codex_provider.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/openai/openai-go/v3\"\n\t\"github.com/openai/openai-go/v3/option\"\n\t\"github.com/openai/openai-go/v3/responses\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nconst (\n\tcodexDefaultModel        = \"gpt-5.3-codex\"\n\tcodexDefaultInstructions = \"You are Codex, a coding assistant.\"\n)\n\ntype CodexProvider struct {\n\tclient          *openai.Client\n\taccountID       string\n\ttokenSource     func() (string, string, error)\n\tenableWebSearch bool\n}\n\nconst defaultCodexInstructions = \"You are Codex, a coding assistant.\"\n\nfunc NewCodexProvider(token, accountID string) *CodexProvider {\n\topts := []option.RequestOption{\n\t\toption.WithBaseURL(\"https://chatgpt.com/backend-api/codex\"),\n\t\toption.WithAPIKey(token),\n\t\toption.WithHeader(\"originator\", \"codex_cli_rs\"),\n\t\toption.WithHeader(\"OpenAI-Beta\", \"responses=experimental\"),\n\t}\n\tif accountID != \"\" {\n\t\topts = append(opts, option.WithHeader(\"Chatgpt-Account-Id\", accountID))\n\t}\n\tclient := openai.NewClient(opts...)\n\treturn &CodexProvider{\n\t\tclient:          &client,\n\t\taccountID:       accountID,\n\t\tenableWebSearch: true,\n\t}\n}\n\nfunc NewCodexProviderWithTokenSource(\n\ttoken, accountID string, tokenSource func() (string, string, error),\n) *CodexProvider {\n\tp := NewCodexProvider(token, accountID)\n\tp.tokenSource = tokenSource\n\treturn p\n}\n\nfunc (p *CodexProvider) Chat(\n\tctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,\n) (*LLMResponse, error) {\n\tvar opts []option.RequestOption\n\taccountID := p.accountID\n\tresolvedModel, fallbackReason := resolveCodexModel(model)\n\tif fallbackReason != \"\" {\n\t\tlogger.WarnCF(\n\t\t\t\"provider.codex\",\n\t\t\t\"Requested model is not compatible with Codex backend, using fallback\",\n\t\t\tmap[string]any{\n\t\t\t\t\"requested_model\": model,\n\t\t\t\t\"resolved_model\":  resolvedModel,\n\t\t\t\t\"reason\":          fallbackReason,\n\t\t\t},\n\t\t)\n\t}\n\tif p.tokenSource != nil {\n\t\ttok, accID, err := p.tokenSource()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"refreshing token: %w\", err)\n\t\t}\n\t\topts = append(opts, option.WithAPIKey(tok))\n\t\tif accID != \"\" {\n\t\t\taccountID = accID\n\t\t}\n\t}\n\tif accountID != \"\" {\n\t\topts = append(opts, option.WithHeader(\"Chatgpt-Account-Id\", accountID))\n\t} else {\n\t\tlogger.WarnCF(\n\t\t\t\"provider.codex\",\n\t\t\t\"No account id found for Codex request; backend may reject with 400\",\n\t\t\tmap[string]any{\n\t\t\t\t\"requested_model\": model,\n\t\t\t\t\"resolved_model\":  resolvedModel,\n\t\t\t},\n\t\t)\n\t}\n\n\t// Respect tools.web.prefer_native: only inject native search when the agent\n\t// loop requested it (options[\"native_search\"]), so prefer_native: false\n\tuseNativeSearch := p.enableWebSearch && (options[\"native_search\"] == true)\n\tparams := buildCodexParams(messages, tools, resolvedModel, options, useNativeSearch)\n\n\tstream := p.client.Responses.NewStreaming(ctx, params, opts...)\n\tdefer stream.Close()\n\n\tvar resp *responses.Response\n\tfor stream.Next() {\n\t\tevt := stream.Current()\n\t\tif evt.Type == \"response.completed\" || evt.Type == \"response.failed\" || evt.Type == \"response.incomplete\" {\n\t\t\tevtResp := evt.Response\n\t\t\tif evtResp.ID != \"\" {\n\t\t\t\tevtRespCopy := evtResp\n\t\t\t\tresp = &evtRespCopy\n\t\t\t}\n\t\t}\n\t}\n\terr := stream.Err()\n\tif err != nil {\n\t\tfields := map[string]any{\n\t\t\t\"requested_model\":    model,\n\t\t\t\"resolved_model\":     resolvedModel,\n\t\t\t\"messages_count\":     len(messages),\n\t\t\t\"tools_count\":        len(tools),\n\t\t\t\"account_id_present\": accountID != \"\",\n\t\t\t\"error\":              err.Error(),\n\t\t}\n\t\tvar apiErr *openai.Error\n\t\tif errors.As(err, &apiErr) {\n\t\t\tfields[\"status_code\"] = apiErr.StatusCode\n\t\t\tfields[\"api_type\"] = apiErr.Type\n\t\t\tfields[\"api_code\"] = apiErr.Code\n\t\t\tfields[\"api_param\"] = apiErr.Param\n\t\t\tfields[\"api_message\"] = apiErr.Message\n\t\t\tif apiErr.StatusCode == 400 {\n\t\t\t\tfields[\"hint\"] = \"verify account id header and model compatibility for codex backend\"\n\t\t\t}\n\t\t\tif apiErr.Response != nil {\n\t\t\t\tfields[\"request_id\"] = apiErr.Response.Header.Get(\"x-request-id\")\n\t\t\t}\n\t\t}\n\t\tlogger.ErrorCF(\"provider.codex\", \"Codex API call failed\", fields)\n\t\treturn nil, fmt.Errorf(\"codex API call: %w\", err)\n\t}\n\tif resp == nil {\n\t\tfields := map[string]any{\n\t\t\t\"requested_model\":    model,\n\t\t\t\"resolved_model\":     resolvedModel,\n\t\t\t\"messages_count\":     len(messages),\n\t\t\t\"tools_count\":        len(tools),\n\t\t\t\"account_id_present\": accountID != \"\",\n\t\t}\n\t\tlogger.ErrorCF(\"provider.codex\", \"Codex stream ended without completed response event\", fields)\n\t\treturn nil, fmt.Errorf(\"codex API call: stream ended without completed response\")\n\t}\n\n\treturn parseCodexResponse(resp), nil\n}\n\nfunc (p *CodexProvider) GetDefaultModel() string {\n\treturn codexDefaultModel\n}\n\nfunc (p *CodexProvider) SupportsNativeSearch() bool {\n\treturn p.enableWebSearch\n}\n\nfunc resolveCodexModel(model string) (string, string) {\n\tm := strings.ToLower(strings.TrimSpace(model))\n\tif m == \"\" {\n\t\treturn codexDefaultModel, \"empty model\"\n\t}\n\n\tif after, ok := strings.CutPrefix(m, \"openai/\"); ok {\n\t\tm = after\n\t} else if strings.Contains(m, \"/\") {\n\t\treturn codexDefaultModel, \"non-openai model namespace\"\n\t}\n\n\tunsupportedPrefixes := []string{\n\t\t\"glm\",\n\t\t\"claude\",\n\t\t\"anthropic\",\n\t\t\"gemini\",\n\t\t\"google\",\n\t\t\"moonshot\",\n\t\t\"kimi\",\n\t\t\"qwen\",\n\t\t\"deepseek\",\n\t\t\"llama\",\n\t\t\"meta-llama\",\n\t\t\"mistral\",\n\t\t\"grok\",\n\t\t\"xai\",\n\t\t\"zhipu\",\n\t}\n\tfor _, prefix := range unsupportedPrefixes {\n\t\tif strings.HasPrefix(m, prefix) {\n\t\t\treturn codexDefaultModel, \"unsupported model prefix\"\n\t\t}\n\t}\n\n\tif strings.HasPrefix(m, \"gpt-\") || strings.HasPrefix(m, \"o3\") || strings.HasPrefix(m, \"o4\") {\n\t\treturn m, \"\"\n\t}\n\n\treturn codexDefaultModel, \"unsupported model family\"\n}\n\nfunc buildCodexParams(\n\tmessages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool,\n) responses.ResponseNewParams {\n\tvar inputItems responses.ResponseInputParam\n\tvar instructions string\n\n\tfor _, msg := range messages {\n\t\tswitch msg.Role {\n\t\tcase \"system\":\n\t\t\t// Use the full concatenated system prompt (static + dynamic + summary)\n\t\t\t// as instructions. This keeps behavior consistent with Anthropic and\n\t\t\t// OpenAI-compat adapters where the complete system context lives in\n\t\t\t// one place. Prefix caching is handled by prompt_cache_key below,\n\t\t\t// not by splitting content across instructions vs input messages.\n\t\t\tinstructions = msg.Content\n\t\tcase \"user\":\n\t\t\tif msg.ToolCallID != \"\" {\n\t\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\t\tOfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{\n\t\t\t\t\t\tCallID: msg.ToolCallID,\n\t\t\t\t\t\tOutput: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{\n\t\t\t\t\t\t\tOfString: openai.Opt(msg.Content),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\t\tOfMessage: &responses.EasyInputMessageParam{\n\t\t\t\t\t\tRole:    responses.EasyInputMessageRoleUser,\n\t\t\t\t\t\tContent: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\tcase \"assistant\":\n\t\t\tif len(msg.ToolCalls) > 0 {\n\t\t\t\tif msg.Content != \"\" {\n\t\t\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\t\t\tOfMessage: &responses.EasyInputMessageParam{\n\t\t\t\t\t\t\tRole:    responses.EasyInputMessageRoleAssistant,\n\t\t\t\t\t\t\tContent: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tfor _, tc := range msg.ToolCalls {\n\t\t\t\t\tname, args, ok := resolveCodexToolCall(tc)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tlogger.WarnCF(\"provider.codex\", \"Skipping invalid tool call in history\", map[string]any{\n\t\t\t\t\t\t\t\"call_id\": tc.ID,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\t\t\tOfFunctionCall: &responses.ResponseFunctionToolCallParam{\n\t\t\t\t\t\t\tCallID:    tc.ID,\n\t\t\t\t\t\t\tName:      name,\n\t\t\t\t\t\t\tArguments: args,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\t\tOfMessage: &responses.EasyInputMessageParam{\n\t\t\t\t\t\tRole:    responses.EasyInputMessageRoleAssistant,\n\t\t\t\t\t\tContent: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\tcase \"tool\":\n\t\t\tinputItems = append(inputItems, responses.ResponseInputItemUnionParam{\n\t\t\t\tOfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{\n\t\t\t\t\tCallID: msg.ToolCallID,\n\t\t\t\t\tOutput: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{\n\t\t\t\t\t\tOfString: openai.Opt(msg.Content),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\tparams := responses.ResponseNewParams{\n\t\tModel: model,\n\t\tInput: responses.ResponseNewParamsInputUnion{\n\t\t\tOfInputItemList: inputItems,\n\t\t},\n\t\tInstructions: openai.Opt(instructions),\n\t\tStore:        openai.Opt(false),\n\t}\n\n\tif instructions != \"\" {\n\t\tparams.Instructions = openai.Opt(instructions)\n\t} else {\n\t\t// ChatGPT Codex backend requires instructions to be present.\n\t\tparams.Instructions = openai.Opt(defaultCodexInstructions)\n\t}\n\n\t// Prompt caching: pass a stable cache key so OpenAI can bucket requests\n\t// and reuse prefix KV cache across calls with the same key.\n\t// See: https://platform.openai.com/docs/guides/prompt-caching\n\tif cacheKey, ok := options[\"prompt_cache_key\"].(string); ok && cacheKey != \"\" {\n\t\tparams.PromptCacheKey = openai.Opt(cacheKey)\n\t}\n\n\tif len(tools) > 0 || enableWebSearch {\n\t\tparams.Tools = translateToolsForCodex(tools, enableWebSearch)\n\t}\n\n\treturn params\n}\n\nfunc resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) {\n\tname = tc.Name\n\tif name == \"\" && tc.Function != nil {\n\t\tname = tc.Function.Name\n\t}\n\tif name == \"\" {\n\t\treturn \"\", \"\", false\n\t}\n\n\tif len(tc.Arguments) > 0 {\n\t\targsJSON, err := json.Marshal(tc.Arguments)\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", false\n\t\t}\n\t\treturn name, string(argsJSON), true\n\t}\n\n\tif tc.Function != nil && tc.Function.Arguments != \"\" {\n\t\treturn name, tc.Function.Arguments, true\n\t}\n\n\treturn name, \"{}\", true\n}\n\nfunc translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {\n\tcapHint := len(tools)\n\tif enableWebSearch {\n\t\tcapHint++\n\t}\n\tresult := make([]responses.ToolUnionParam, 0, capHint)\n\tfor _, t := range tools {\n\t\tif t.Type != \"function\" {\n\t\t\tcontinue\n\t\t}\n\t\tif enableWebSearch && strings.EqualFold(t.Function.Name, \"web_search\") {\n\t\t\tcontinue\n\t\t}\n\t\tft := responses.FunctionToolParam{\n\t\t\tName:       t.Function.Name,\n\t\t\tParameters: t.Function.Parameters,\n\t\t\tStrict:     openai.Opt(false),\n\t\t}\n\t\tif t.Function.Description != \"\" {\n\t\t\tft.Description = openai.Opt(t.Function.Description)\n\t\t}\n\t\tresult = append(result, responses.ToolUnionParam{OfFunction: &ft})\n\t}\n\tif enableWebSearch {\n\t\tresult = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch))\n\t}\n\treturn result\n}\n\nfunc parseCodexResponse(resp *responses.Response) *LLMResponse {\n\tvar content strings.Builder\n\tvar toolCalls []ToolCall\n\n\tfor _, item := range resp.Output {\n\t\tswitch item.Type {\n\t\tcase \"message\":\n\t\t\tfor _, c := range item.Content {\n\t\t\t\tif c.Type == \"output_text\" {\n\t\t\t\t\tcontent.WriteString(c.Text)\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"function_call\":\n\t\t\tvar args map[string]any\n\t\t\tif err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {\n\t\t\t\targs = map[string]any{\"raw\": item.Arguments}\n\t\t\t}\n\t\t\ttoolCalls = append(toolCalls, ToolCall{\n\t\t\t\tID:        item.CallID,\n\t\t\t\tName:      item.Name,\n\t\t\t\tArguments: args,\n\t\t\t})\n\t\t}\n\t}\n\n\tfinishReason := \"stop\"\n\tif len(toolCalls) > 0 {\n\t\tfinishReason = \"tool_calls\"\n\t}\n\tif resp.Status == \"incomplete\" {\n\t\tfinishReason = \"length\"\n\t}\n\n\tvar usage *UsageInfo\n\tif resp.Usage.TotalTokens > 0 {\n\t\tusage = &UsageInfo{\n\t\t\tPromptTokens:     int(resp.Usage.InputTokens),\n\t\t\tCompletionTokens: int(resp.Usage.OutputTokens),\n\t\t\tTotalTokens:      int(resp.Usage.TotalTokens),\n\t\t}\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:      content.String(),\n\t\tToolCalls:    toolCalls,\n\t\tFinishReason: finishReason,\n\t\tUsage:        usage,\n\t}\n}\n\nfunc createCodexTokenSource() func() (string, string, error) {\n\treturn func() (string, string, error) {\n\t\tcred, err := auth.GetCredential(\"openai\")\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"loading auth credentials: %w\", err)\n\t\t}\n\t\tif cred == nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"no credentials for openai. Run: picoclaw auth login --provider openai\")\n\t\t}\n\n\t\tif cred.AuthMethod == \"oauth\" && cred.NeedsRefresh() && cred.RefreshToken != \"\" {\n\t\t\toauthCfg := auth.OpenAIOAuthConfig()\n\t\t\trefreshed, err := auth.RefreshAccessToken(cred, oauthCfg)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"refreshing token: %w\", err)\n\t\t\t}\n\t\t\tif refreshed.AccountID == \"\" {\n\t\t\t\trefreshed.AccountID = cred.AccountID\n\t\t\t}\n\t\t\tif err := auth.SetCredential(\"openai\", refreshed); err != nil {\n\t\t\t\treturn \"\", \"\", fmt.Errorf(\"saving refreshed token: %w\", err)\n\t\t\t}\n\t\t\treturn refreshed.AccessToken, refreshed.AccountID, nil\n\t\t}\n\n\t\treturn cred.AccessToken, cred.AccountID, nil\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/codex_provider_test.go",
    "content": "package providers\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/openai/openai-go/v3\"\n\topenaiopt \"github.com/openai/openai-go/v3/option\"\n\t\"github.com/openai/openai-go/v3/responses\"\n)\n\nfunc TestBuildCodexParams_BasicMessage(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Hello\"},\n\t}\n\tparams := buildCodexParams(messages, nil, \"gpt-4o\", map[string]any{\n\t\t\"max_tokens\":  2048,\n\t\t\"temperature\": 0.7,\n\t}, true)\n\tif params.Model != \"gpt-4o\" {\n\t\tt.Errorf(\"Model = %q, want %q\", params.Model, \"gpt-4o\")\n\t}\n\tif !params.Instructions.Valid() {\n\t\tt.Fatal(\"Instructions should be set\")\n\t}\n\tif params.Instructions.Or(\"\") != defaultCodexInstructions {\n\t\tt.Errorf(\"Instructions = %q, want %q\", params.Instructions.Or(\"\"), defaultCodexInstructions)\n\t}\n\tif params.MaxOutputTokens.Valid() {\n\t\tt.Fatalf(\"MaxOutputTokens should not be set for Codex backend\")\n\t}\n}\n\nfunc TestBuildCodexParams_SystemAsInstructions(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"system\", Content: \"You are helpful\"},\n\t\t{Role: \"user\", Content: \"Hi\"},\n\t}\n\tparams := buildCodexParams(messages, nil, \"gpt-4o\", map[string]any{}, true)\n\tif !params.Instructions.Valid() {\n\t\tt.Fatal(\"Instructions should be set\")\n\t}\n\tif params.Instructions.Or(\"\") != \"You are helpful\" {\n\t\tt.Errorf(\"Instructions = %q, want %q\", params.Instructions.Or(\"\"), \"You are helpful\")\n\t}\n}\n\nfunc TestBuildCodexParams_ToolCallConversation(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"What's the weather?\"},\n\t\t{\n\t\t\tRole: \"assistant\",\n\t\t\tToolCalls: []ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"get_weather\", Arguments: map[string]any{\"city\": \"SF\"}},\n\t\t\t},\n\t\t},\n\t\t{Role: \"tool\", Content: `{\"temp\": 72}`, ToolCallID: \"call_1\"},\n\t}\n\tparams := buildCodexParams(messages, nil, \"gpt-4o\", map[string]any{}, false)\n\tif params.Input.OfInputItemList == nil {\n\t\tt.Fatal(\"Input.OfInputItemList should not be nil\")\n\t}\n\tif len(params.Input.OfInputItemList) != 3 {\n\t\tt.Errorf(\"len(Input items) = %d, want 3\", len(params.Input.OfInputItemList))\n\t}\n}\n\nfunc TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"Read a file\"},\n\t\t{\n\t\t\tRole: \"assistant\",\n\t\t\tToolCalls: []ToolCall{\n\t\t\t\t{\n\t\t\t\t\tID:   \"call_1\",\n\t\t\t\t\tType: \"function\",\n\t\t\t\t\tFunction: &FunctionCall{\n\t\t\t\t\t\tName:      \"read_file\",\n\t\t\t\t\t\tArguments: `{\"path\":\"README.md\"}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{Role: \"tool\", Content: \"ok\", ToolCallID: \"call_1\"},\n\t}\n\n\tparams := buildCodexParams(messages, nil, \"gpt-4o\", map[string]any{}, false)\n\tif params.Input.OfInputItemList == nil {\n\t\tt.Fatal(\"Input.OfInputItemList should not be nil\")\n\t}\n\tif len(params.Input.OfInputItemList) != 3 {\n\t\tt.Fatalf(\"len(Input items) = %d, want 3\", len(params.Input.OfInputItemList))\n\t}\n\n\tfc := params.Input.OfInputItemList[1].OfFunctionCall\n\tif fc == nil {\n\t\tt.Fatal(\"assistant tool call should be converted to function_call input item\")\n\t}\n\tif fc.Name != \"read_file\" {\n\t\tt.Errorf(\"Function call name = %q, want %q\", fc.Name, \"read_file\")\n\t}\n\tif fc.Arguments != `{\"path\":\"README.md\"}` {\n\t\tt.Errorf(\"Function call arguments = %q, want %q\", fc.Arguments, `{\"path\":\"README.md\"}`)\n\t}\n}\n\nfunc TestBuildCodexParams_WithTools(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"get_weather\",\n\t\t\t\tDescription: \"Get weather\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\t\"city\": map[string]any{\"type\": \"string\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tparams := buildCodexParams([]Message{{Role: \"user\", Content: \"Hi\"}}, tools, \"gpt-4o\", map[string]any{}, false)\n\tif len(params.Tools) != 1 {\n\t\tt.Fatalf(\"len(Tools) = %d, want 1\", len(params.Tools))\n\t}\n\tif params.Tools[0].OfFunction == nil {\n\t\tt.Fatal(\"Tool should be a function tool\")\n\t}\n\tif params.Tools[0].OfFunction.Name != \"get_weather\" {\n\t\tt.Errorf(\"Tool name = %q, want %q\", params.Tools[0].OfFunction.Name, \"get_weather\")\n\t}\n}\n\nfunc TestBuildCodexParams_StoreIsFalse(t *testing.T) {\n\tparams := buildCodexParams([]Message{{Role: \"user\", Content: \"Hi\"}}, nil, \"gpt-4o\", map[string]any{}, false)\n\tif !params.Store.Valid() || params.Store.Or(true) != false {\n\t\tt.Error(\"Store should be explicitly set to false\")\n\t}\n}\n\nfunc TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {\n\tparams := buildCodexParams([]Message{{Role: \"user\", Content: \"Hi\"}}, nil, \"gpt-4o\", map[string]any{}, true)\n\tif len(params.Tools) != 1 {\n\t\tt.Fatalf(\"len(Tools) = %d, want 1\", len(params.Tools))\n\t}\n\tif params.Tools[0].OfWebSearch == nil {\n\t\tt.Fatal(\"Tool should include built-in web_search\")\n\t}\n\tif params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {\n\t\tt.Errorf(\n\t\t\t\"Web search tool type = %q, want %q\",\n\t\t\tparams.Tools[0].OfWebSearch.Type,\n\t\t\tresponses.WebSearchToolTypeWebSearch,\n\t\t)\n\t}\n}\n\nfunc TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"web_search\",\n\t\t\t\tDescription: \"local web search\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tType: \"function\",\n\t\t\tFunction: ToolFunctionDefinition{\n\t\t\t\tName:        \"read_file\",\n\t\t\t\tDescription: \"read file\",\n\t\t\t\tParameters: map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tparams := buildCodexParams([]Message{{Role: \"user\", Content: \"Hi\"}}, tools, \"gpt-4o\", map[string]any{}, true)\n\tif len(params.Tools) != 2 {\n\t\tt.Fatalf(\"len(Tools) = %d, want 2\", len(params.Tools))\n\t}\n\tif params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != \"read_file\" {\n\t\tt.Fatalf(\"first tool should be function read_file, got %#v\", params.Tools[0])\n\t}\n\tif params.Tools[1].OfWebSearch == nil {\n\t\tt.Fatalf(\"second tool should be built-in web_search, got %#v\", params.Tools[1])\n\t}\n}\n\nfunc TestParseCodexResponse_TextOutput(t *testing.T) {\n\trespJSON := `{\n\t\t\"id\": \"resp_test\",\n\t\t\"object\": \"response\",\n\t\t\"status\": \"completed\",\n\t\t\"output\": [\n\t\t\t{\n\t\t\t\t\"id\": \"msg_1\",\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"status\": \"completed\",\n\t\t\t\t\"content\": [\n\t\t\t\t\t{\"type\": \"output_text\", \"text\": \"Hello there!\"}\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t\"usage\": {\n\t\t\t\"input_tokens\": 10,\n\t\t\t\"output_tokens\": 5,\n\t\t\t\"total_tokens\": 15,\n\t\t\t\"input_tokens_details\": {\"cached_tokens\": 0},\n\t\t\t\"output_tokens_details\": {\"reasoning_tokens\": 0}\n\t\t}\n\t}`\n\n\tvar resp responses.Response\n\tif err := json.Unmarshal([]byte(respJSON), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\n\tresult := parseCodexResponse(&resp)\n\tif result.Content != \"Hello there!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", result.Content, \"Hello there!\")\n\t}\n\tif result.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", result.FinishReason, \"stop\")\n\t}\n\tif result.Usage.TotalTokens != 15 {\n\t\tt.Errorf(\"TotalTokens = %d, want 15\", result.Usage.TotalTokens)\n\t}\n}\n\nfunc TestParseCodexResponse_FunctionCall(t *testing.T) {\n\trespJSON := `{\n\t\t\"id\": \"resp_test\",\n\t\t\"object\": \"response\",\n\t\t\"status\": \"completed\",\n\t\t\"output\": [\n\t\t\t{\n\t\t\t\t\"id\": \"fc_1\",\n\t\t\t\t\"type\": \"function_call\",\n\t\t\t\t\"call_id\": \"call_abc\",\n\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"SF\\\"}\",\n\t\t\t\t\"status\": \"completed\"\n\t\t\t}\n\t\t],\n\t\t\"usage\": {\n\t\t\t\"input_tokens\": 10,\n\t\t\t\"output_tokens\": 8,\n\t\t\t\"total_tokens\": 18,\n\t\t\t\"input_tokens_details\": {\"cached_tokens\": 0},\n\t\t\t\"output_tokens_details\": {\"reasoning_tokens\": 0}\n\t\t}\n\t}`\n\n\tvar resp responses.Response\n\tif err := json.Unmarshal([]byte(respJSON), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\n\tresult := parseCodexResponse(&resp)\n\tif len(result.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(result.ToolCalls))\n\t}\n\ttc := result.ToolCalls[0]\n\tif tc.Name != \"get_weather\" {\n\t\tt.Errorf(\"ToolCall.Name = %q, want %q\", tc.Name, \"get_weather\")\n\t}\n\tif tc.ID != \"call_abc\" {\n\t\tt.Errorf(\"ToolCall.ID = %q, want %q\", tc.ID, \"call_abc\")\n\t}\n\tif tc.Arguments[\"city\"] != \"SF\" {\n\t\tt.Errorf(\"ToolCall.Arguments[city] = %v, want SF\", tc.Arguments[\"city\"])\n\t}\n\tif result.FinishReason != \"tool_calls\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", result.FinishReason, \"tool_calls\")\n\t}\n}\n\nfunc TestCodexProvider_ChatRoundTrip(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/responses\" {\n\t\t\thttp.Error(w, \"not found: \"+r.URL.Path, http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer test-token\" {\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Chatgpt-Account-Id\") != \"acc-123\" {\n\t\t\thttp.Error(w, \"missing account id\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tif err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {\n\t\t\thttp.Error(w, \"invalid json\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"stream\"] != true {\n\t\t\thttp.Error(w, \"stream must be true\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif _, ok := reqBody[\"max_output_tokens\"]; ok {\n\t\t\thttp.Error(w, \"max_output_tokens is not supported\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\ttoolsAny, ok := reqBody[\"tools\"].([]any)\n\t\tif !ok || len(toolsAny) != 1 {\n\t\t\thttp.Error(w, \"missing default web search tool\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\ttoolObj, ok := toolsAny[0].(map[string]any)\n\t\tif !ok || toolObj[\"type\"] != \"web_search\" {\n\t\t\thttp.Error(w, \"expected web_search tool\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":     \"resp_test\",\n\t\t\t\"object\": \"response\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"output\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"id\":     \"msg_1\",\n\t\t\t\t\t\"type\":   \"message\",\n\t\t\t\t\t\"role\":   \"assistant\",\n\t\t\t\t\t\"status\": \"completed\",\n\t\t\t\t\t\"content\": []map[string]any{\n\t\t\t\t\t\t{\"type\": \"output_text\", \"text\": \"Hi from Codex!\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":          12,\n\t\t\t\t\"output_tokens\":         6,\n\t\t\t\t\"total_tokens\":          18,\n\t\t\t\t\"input_tokens_details\":  map[string]any{\"cached_tokens\": 0},\n\t\t\t\t\"output_tokens_details\": map[string]any{\"reasoning_tokens\": 0},\n\t\t\t},\n\t\t}\n\t\twriteCompletedSSE(w, resp)\n\t}))\n\tdefer server.Close()\n\n\tprovider := NewCodexProvider(\"test-token\", \"acc-123\")\n\tprovider.client = createOpenAITestClient(server.URL, \"test-token\", \"acc-123\")\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\t// Pass native_search so Codex injects built-in web search (mirrors agent loop when prefer_native is true).\n\topts := map[string]any{\"max_tokens\": 1024, \"native_search\": true}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"gpt-4o\", opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hi from Codex!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hi from Codex!\")\n\t}\n\tif resp.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", resp.FinishReason, \"stop\")\n\t}\n\tif resp.Usage.TotalTokens != 18 {\n\t\tt.Errorf(\"TotalTokens = %d, want 18\", resp.Usage.TotalTokens)\n\t}\n}\n\nfunc TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/responses\" {\n\t\t\thttp.Error(w, \"not found: \"+r.URL.Path, http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tif err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {\n\t\t\thttp.Error(w, \"invalid json\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif _, ok := reqBody[\"tools\"]; ok {\n\t\t\thttp.Error(w, \"tools should be absent when web search disabled\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":     \"resp_test\",\n\t\t\t\"object\": \"response\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"output\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"id\":     \"msg_1\",\n\t\t\t\t\t\"type\":   \"message\",\n\t\t\t\t\t\"role\":   \"assistant\",\n\t\t\t\t\t\"status\": \"completed\",\n\t\t\t\t\t\"content\": []map[string]any{\n\t\t\t\t\t\t{\"type\": \"output_text\", \"text\": \"Hi from Codex!\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":          4,\n\t\t\t\t\"output_tokens\":         3,\n\t\t\t\t\"total_tokens\":          7,\n\t\t\t\t\"input_tokens_details\":  map[string]any{\"cached_tokens\": 0},\n\t\t\t\t\"output_tokens_details\": map[string]any{\"reasoning_tokens\": 0},\n\t\t\t},\n\t\t}\n\t\twriteCompletedSSE(w, resp)\n\t}))\n\tdefer server.Close()\n\n\tprovider := NewCodexProvider(\"test-token\", \"acc-123\")\n\tprovider.enableWebSearch = false\n\tprovider.client = createOpenAITestClient(server.URL, \"test-token\", \"acc-123\")\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"gpt-4o\", map[string]any{})\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hi from Codex!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hi from Codex!\")\n\t}\n}\n\nfunc TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/responses\" {\n\t\t\thttp.Error(w, \"not found: \"+r.URL.Path, http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer refreshed-token\" {\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tif r.Header.Get(\"Chatgpt-Account-Id\") != \"acc-123\" {\n\t\t\thttp.Error(w, \"missing account id\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tif err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {\n\t\t\thttp.Error(w, \"invalid json\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif _, ok := reqBody[\"instructions\"]; !ok {\n\t\t\thttp.Error(w, \"missing instructions\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"instructions\"] == \"\" {\n\t\t\thttp.Error(w, \"instructions must not be empty\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif _, ok := reqBody[\"temperature\"]; ok {\n\t\t\thttp.Error(w, \"temperature is not supported\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif _, ok := reqBody[\"max_output_tokens\"]; ok {\n\t\t\thttp.Error(w, \"max_output_tokens is not supported\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"stream\"] != true {\n\t\t\thttp.Error(w, \"stream must be true\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":     \"resp_test\",\n\t\t\t\"object\": \"response\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"output\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"id\":     \"msg_1\",\n\t\t\t\t\t\"type\":   \"message\",\n\t\t\t\t\t\"role\":   \"assistant\",\n\t\t\t\t\t\"status\": \"completed\",\n\t\t\t\t\t\"content\": []map[string]any{\n\t\t\t\t\t\t{\"type\": \"output_text\", \"text\": \"Hi from Codex!\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":          8,\n\t\t\t\t\"output_tokens\":         4,\n\t\t\t\t\"total_tokens\":          12,\n\t\t\t\t\"input_tokens_details\":  map[string]any{\"cached_tokens\": 0},\n\t\t\t\t\"output_tokens_details\": map[string]any{\"reasoning_tokens\": 0},\n\t\t\t},\n\t\t}\n\t\twriteCompletedSSE(w, resp)\n\t}))\n\tdefer server.Close()\n\n\tprovider := NewCodexProvider(\"stale-token\", \"acc-123\")\n\tprovider.client = createOpenAITestClient(server.URL, \"stale-token\", \"\")\n\tprovider.tokenSource = func() (string, string, error) {\n\t\treturn \"refreshed-token\", \"\", nil\n\t}\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"gpt-4o\", map[string]any{\"temperature\": 0.7})\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hi from Codex!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hi from Codex!\")\n\t}\n}\n\nfunc TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/responses\" {\n\t\t\thttp.Error(w, \"not found: \"+r.URL.Path, http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tvar reqBody map[string]any\n\t\tif err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {\n\t\t\thttp.Error(w, \"invalid json\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"model\"] != codexDefaultModel {\n\t\t\thttp.Error(w, \"unsupported model\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"stream\"] != true {\n\t\t\thttp.Error(w, \"stream must be true\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif reqBody[\"instructions\"] != codexDefaultInstructions {\n\t\t\thttp.Error(w, \"missing default instructions\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tresp := map[string]any{\n\t\t\t\"id\":     \"resp_test\",\n\t\t\t\"object\": \"response\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"output\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"id\":     \"msg_1\",\n\t\t\t\t\t\"type\":   \"message\",\n\t\t\t\t\t\"role\":   \"assistant\",\n\t\t\t\t\t\"status\": \"completed\",\n\t\t\t\t\t\"content\": []map[string]any{\n\t\t\t\t\t\t{\"type\": \"output_text\", \"text\": \"Hi from Codex!\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"input_tokens\":          8,\n\t\t\t\t\"output_tokens\":         4,\n\t\t\t\t\"total_tokens\":          12,\n\t\t\t\t\"input_tokens_details\":  map[string]any{\"cached_tokens\": 0},\n\t\t\t\t\"output_tokens_details\": map[string]any{\"reasoning_tokens\": 0},\n\t\t\t},\n\t\t}\n\t\twriteCompletedSSE(w, resp)\n\t}))\n\tdefer server.Close()\n\n\tprovider := NewCodexProvider(\"test-token\", \"acc-123\")\n\tprovider.client = createOpenAITestClient(server.URL, \"test-token\", \"acc-123\")\n\n\tmessages := []Message{{Role: \"user\", Content: \"Hello\"}}\n\tresp, err := provider.Chat(t.Context(), messages, nil, \"gpt-5.3-codex\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error: %v\", err)\n\t}\n\tif resp.Content != \"Hi from Codex!\" {\n\t\tt.Errorf(\"Content = %q, want %q\", resp.Content, \"Hi from Codex!\")\n\t}\n}\n\nfunc TestCodexProvider_GetDefaultModel(t *testing.T) {\n\tp := NewCodexProvider(\"test-token\", \"\")\n\tif got := p.GetDefaultModel(); got != codexDefaultModel {\n\t\tt.Errorf(\"GetDefaultModel() = %q, want %q\", got, codexDefaultModel)\n\t}\n}\n\nfunc TestResolveCodexModel(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tinput        string\n\t\twantModel    string\n\t\twantFallback bool\n\t}{\n\t\t{name: \"empty\", input: \"\", wantModel: codexDefaultModel, wantFallback: true},\n\t\t{\n\t\t\tname:         \"unsupported namespace\",\n\t\t\tinput:        \"anthropic/claude-3.5\",\n\t\t\twantModel:    codexDefaultModel,\n\t\t\twantFallback: true,\n\t\t},\n\t\t{name: \"non-openai prefixed\", input: \"glm-4.7\", wantModel: codexDefaultModel, wantFallback: true},\n\t\t{name: \"openai prefix\", input: \"openai/gpt-5.3-codex\", wantModel: \"gpt-5.3-codex\", wantFallback: false},\n\t\t{name: \"direct gpt\", input: \"gpt-4o\", wantModel: \"gpt-4o\", wantFallback: false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotModel, reason := resolveCodexModel(tt.input)\n\t\t\tif gotModel != tt.wantModel {\n\t\t\t\tt.Fatalf(\"resolveCodexModel(%q) model = %q, want %q\", tt.input, gotModel, tt.wantModel)\n\t\t\t}\n\t\t\tif tt.wantFallback && reason == \"\" {\n\t\t\t\tt.Fatalf(\"resolveCodexModel(%q) expected fallback reason\", tt.input)\n\t\t\t}\n\t\t\tif !tt.wantFallback && reason != \"\" {\n\t\t\t\tt.Fatalf(\"resolveCodexModel(%q) unexpected fallback reason: %q\", tt.input, reason)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc createOpenAITestClient(baseURL, token, accountID string) *openai.Client {\n\topts := []openaiopt.RequestOption{\n\t\topenaiopt.WithBaseURL(baseURL),\n\t\topenaiopt.WithAPIKey(token),\n\t}\n\tif accountID != \"\" {\n\t\topts = append(opts, openaiopt.WithHeader(\"Chatgpt-Account-Id\", accountID))\n\t}\n\tc := openai.NewClient(opts...)\n\treturn &c\n}\n\nfunc writeCompletedSSE(w http.ResponseWriter, response map[string]any) {\n\tevent := map[string]any{\n\t\t\"type\":            \"response.completed\",\n\t\t\"sequence_number\": 1,\n\t\t\"response\":        response,\n\t}\n\tb, _ := json.Marshal(event)\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tfmt.Fprintf(w, \"event: response.completed\\n\")\n\tfmt.Fprintf(w, \"data: %s\\n\\n\", string(b))\n\tfmt.Fprintf(w, \"data: [DONE]\\n\\n\")\n}\n"
  },
  {
    "path": "pkg/providers/common/common.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\n// Package common provides shared utilities used by multiple LLM provider\n// implementations (openai_compat, azure, etc.).\npackage common\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\n// Re-export protocol types used across providers.\ntype (\n\tToolCall               = protocoltypes.ToolCall\n\tFunctionCall           = protocoltypes.FunctionCall\n\tLLMResponse            = protocoltypes.LLMResponse\n\tUsageInfo              = protocoltypes.UsageInfo\n\tMessage                = protocoltypes.Message\n\tToolDefinition         = protocoltypes.ToolDefinition\n\tToolFunctionDefinition = protocoltypes.ToolFunctionDefinition\n\tExtraContent           = protocoltypes.ExtraContent\n\tGoogleExtra            = protocoltypes.GoogleExtra\n\tReasoningDetail        = protocoltypes.ReasoningDetail\n)\n\nconst DefaultRequestTimeout = 120 * time.Second\n\n// NewHTTPClient creates an *http.Client with an optional proxy and the default timeout.\nfunc NewHTTPClient(proxy string) *http.Client {\n\tclient := &http.Client{\n\t\tTimeout: DefaultRequestTimeout,\n\t}\n\tif proxy != \"\" {\n\t\tparsed, err := url.Parse(proxy)\n\t\tif err == nil {\n\t\t\t// Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.)\n\t\t\tif base, ok := http.DefaultTransport.(*http.Transport); ok {\n\t\t\t\ttr := base.Clone()\n\t\t\t\ttr.Proxy = http.ProxyURL(parsed)\n\t\t\t\tclient.Transport = tr\n\t\t\t} else {\n\t\t\t\t// Fallback: minimal transport if DefaultTransport is not *http.Transport.\n\t\t\t\tclient.Transport = &http.Transport{\n\t\t\t\t\tProxy: http.ProxyURL(parsed),\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Printf(\"common: invalid proxy URL %q: %v\", proxy, err)\n\t\t}\n\t}\n\treturn client\n}\n\n// --- Message serialization ---\n\n// openaiMessage is the wire-format message for OpenAI-compatible APIs.\n// It mirrors protocoltypes.Message but omits SystemParts, which is an\n// internal field that would be unknown to third-party endpoints.\ntype openaiMessage struct {\n\tRole             string     `json:\"role\"`\n\tContent          string     `json:\"content\"`\n\tReasoningContent string     `json:\"reasoning_content,omitempty\"`\n\tToolCalls        []ToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID       string     `json:\"tool_call_id,omitempty\"`\n}\n\n// SerializeMessages converts internal Message structs to the OpenAI wire format.\n//   - Strips SystemParts (unknown to third-party endpoints)\n//   - Converts messages with Media to multipart content format (text + image_url parts)\n//   - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages\nfunc SerializeMessages(messages []Message) []any {\n\tout := make([]any, 0, len(messages))\n\tfor _, m := range messages {\n\t\tif len(m.Media) == 0 {\n\t\t\tout = append(out, openaiMessage{\n\t\t\t\tRole:             m.Role,\n\t\t\t\tContent:          m.Content,\n\t\t\t\tReasoningContent: m.ReasoningContent,\n\t\t\t\tToolCalls:        m.ToolCalls,\n\t\t\t\tToolCallID:       m.ToolCallID,\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\t// Multipart content format for messages with media\n\t\tparts := make([]map[string]any, 0, 1+len(m.Media))\n\t\tif m.Content != \"\" {\n\t\t\tparts = append(parts, map[string]any{\n\t\t\t\t\"type\": \"text\",\n\t\t\t\t\"text\": m.Content,\n\t\t\t})\n\t\t}\n\t\tfor _, mediaURL := range m.Media {\n\t\t\tif strings.HasPrefix(mediaURL, \"data:image/\") {\n\t\t\t\tparts = append(parts, map[string]any{\n\t\t\t\t\t\"type\": \"image_url\",\n\t\t\t\t\t\"image_url\": map[string]any{\n\t\t\t\t\t\t\"url\": mediaURL,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\tmsg := map[string]any{\n\t\t\t\"role\":    m.Role,\n\t\t\t\"content\": parts,\n\t\t}\n\t\tif m.ToolCallID != \"\" {\n\t\t\tmsg[\"tool_call_id\"] = m.ToolCallID\n\t\t}\n\t\tif len(m.ToolCalls) > 0 {\n\t\t\tmsg[\"tool_calls\"] = m.ToolCalls\n\t\t}\n\t\tif m.ReasoningContent != \"\" {\n\t\t\tmsg[\"reasoning_content\"] = m.ReasoningContent\n\t\t}\n\t\tout = append(out, msg)\n\t}\n\treturn out\n}\n\n// --- Response parsing ---\n\n// ParseResponse parses a JSON chat completion response body into an LLMResponse.\nfunc ParseResponse(body io.Reader) (*LLMResponse, error) {\n\tvar apiResponse struct {\n\t\tChoices []struct {\n\t\t\tMessage struct {\n\t\t\t\tContent          string            `json:\"content\"`\n\t\t\t\tReasoningContent string            `json:\"reasoning_content\"`\n\t\t\t\tReasoning        string            `json:\"reasoning\"`\n\t\t\t\tReasoningDetails []ReasoningDetail `json:\"reasoning_details\"`\n\t\t\t\tToolCalls        []struct {\n\t\t\t\t\tID       string `json:\"id\"`\n\t\t\t\t\tType     string `json:\"type\"`\n\t\t\t\t\tFunction *struct {\n\t\t\t\t\t\tName      string          `json:\"name\"`\n\t\t\t\t\t\tArguments json.RawMessage `json:\"arguments\"`\n\t\t\t\t\t} `json:\"function\"`\n\t\t\t\t\tExtraContent *struct {\n\t\t\t\t\t\tGoogle *struct {\n\t\t\t\t\t\t\tThoughtSignature string `json:\"thought_signature\"`\n\t\t\t\t\t\t} `json:\"google\"`\n\t\t\t\t\t} `json:\"extra_content\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t} `json:\"message\"`\n\t\t\tFinishReason string `json:\"finish_reason\"`\n\t\t} `json:\"choices\"`\n\t\tUsage *UsageInfo `json:\"usage\"`\n\t}\n\n\tif err := json.NewDecoder(body).Decode(&apiResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif len(apiResponse.Choices) == 0 {\n\t\treturn &LLMResponse{\n\t\t\tContent:      \"\",\n\t\t\tFinishReason: \"stop\",\n\t\t}, nil\n\t}\n\n\tchoice := apiResponse.Choices[0]\n\ttoolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))\n\tfor _, tc := range choice.Message.ToolCalls {\n\t\targuments := make(map[string]any)\n\t\tname := \"\"\n\n\t\t// Extract thought_signature from Gemini/Google-specific extra content\n\t\tthoughtSignature := \"\"\n\t\tif tc.ExtraContent != nil && tc.ExtraContent.Google != nil {\n\t\t\tthoughtSignature = tc.ExtraContent.Google.ThoughtSignature\n\t\t}\n\n\t\tif tc.Function != nil {\n\t\t\tname = tc.Function.Name\n\t\t\targuments = DecodeToolCallArguments(tc.Function.Arguments, name)\n\t\t}\n\n\t\ttoolCall := ToolCall{\n\t\t\tID:               tc.ID,\n\t\t\tName:             name,\n\t\t\tArguments:        arguments,\n\t\t\tThoughtSignature: thoughtSignature,\n\t\t}\n\n\t\tif thoughtSignature != \"\" {\n\t\t\ttoolCall.ExtraContent = &ExtraContent{\n\t\t\t\tGoogle: &GoogleExtra{\n\t\t\t\t\tThoughtSignature: thoughtSignature,\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\n\t\ttoolCalls = append(toolCalls, toolCall)\n\t}\n\n\treturn &LLMResponse{\n\t\tContent:          choice.Message.Content,\n\t\tReasoningContent: choice.Message.ReasoningContent,\n\t\tReasoning:        choice.Message.Reasoning,\n\t\tReasoningDetails: choice.Message.ReasoningDetails,\n\t\tToolCalls:        toolCalls,\n\t\tFinishReason:     choice.FinishReason,\n\t\tUsage:            apiResponse.Usage,\n\t}, nil\n}\n\n// DecodeToolCallArguments decodes a tool call's arguments from raw JSON.\nfunc DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any {\n\targuments := make(map[string]any)\n\traw = bytes.TrimSpace(raw)\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn arguments\n\t}\n\n\tvar decoded any\n\tif err := json.Unmarshal(raw, &decoded); err != nil {\n\t\tlog.Printf(\"common: failed to decode tool call arguments payload for %q: %v\", name, err)\n\t\targuments[\"raw\"] = string(raw)\n\t\treturn arguments\n\t}\n\n\tswitch v := decoded.(type) {\n\tcase string:\n\t\tif strings.TrimSpace(v) == \"\" {\n\t\t\treturn arguments\n\t\t}\n\t\tif err := json.Unmarshal([]byte(v), &arguments); err != nil {\n\t\t\tlog.Printf(\"common: failed to decode tool call arguments for %q: %v\", name, err)\n\t\t\targuments[\"raw\"] = v\n\t\t}\n\t\treturn arguments\n\tcase map[string]any:\n\t\treturn v\n\tdefault:\n\t\tlog.Printf(\"common: unsupported tool call arguments type for %q: %T\", name, decoded)\n\t\targuments[\"raw\"] = string(raw)\n\t\treturn arguments\n\t}\n}\n\n// --- HTTP response helpers ---\n\n// HandleErrorResponse reads a non-200 response body and returns an appropriate error.\nfunc HandleErrorResponse(resp *http.Response, apiBase string) error {\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\tbody, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))\n\tif readErr != nil {\n\t\treturn fmt.Errorf(\"failed to read response: %w\", readErr)\n\t}\n\tif LooksLikeHTML(body, contentType) {\n\t\treturn WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase)\n\t}\n\treturn fmt.Errorf(\n\t\t\"API request failed:\\n  Status: %d\\n  Body:   %s\",\n\t\tresp.StatusCode,\n\t\tResponsePreview(body, 128),\n\t)\n}\n\n// ReadAndParseResponse peeks at the response body to detect HTML errors,\n// then parses the JSON response into an LLMResponse.\nfunc ReadAndParseResponse(resp *http.Response, apiBase string) (*LLMResponse, error) {\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\treader := bufio.NewReader(resp.Body)\n\tprefix, err := reader.Peek(256)\n\tif err != nil && err != io.EOF && err != bufio.ErrBufferFull {\n\t\treturn nil, fmt.Errorf(\"failed to inspect response: %w\", err)\n\t}\n\tif LooksLikeHTML(prefix, contentType) {\n\t\treturn nil, WrapHTMLResponseError(resp.StatusCode, prefix, contentType, apiBase)\n\t}\n\tout, err := ParseResponse(reader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse JSON response: %w\", err)\n\t}\n\treturn out, nil\n}\n\n// LooksLikeHTML checks if the response body appears to be HTML.\nfunc LooksLikeHTML(body []byte, contentType string) bool {\n\tcontentType = strings.ToLower(strings.TrimSpace(contentType))\n\tif strings.Contains(contentType, \"text/html\") || strings.Contains(contentType, \"application/xhtml+xml\") {\n\t\treturn true\n\t}\n\tprefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))\n\treturn bytes.HasPrefix(prefix, []byte(\"<!doctype html\")) ||\n\t\tbytes.HasPrefix(prefix, []byte(\"<html\")) ||\n\t\tbytes.HasPrefix(prefix, []byte(\"<head\")) ||\n\t\tbytes.HasPrefix(prefix, []byte(\"<body\"))\n}\n\n// WrapHTMLResponseError creates a descriptive error for HTML responses.\nfunc WrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {\n\trespPreview := ResponsePreview(body, 128)\n\treturn fmt.Errorf(\n\t\t\"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\\n  Status: %d\\n  Body:   %s\",\n\t\tapiBase,\n\t\tcontentType,\n\t\tstatusCode,\n\t\trespPreview,\n\t)\n}\n\n// ResponsePreview returns a truncated preview of response body for error messages.\nfunc ResponsePreview(body []byte, maxLen int) string {\n\ttrimmed := bytes.TrimSpace(body)\n\tif len(trimmed) == 0 {\n\t\treturn \"<empty>\"\n\t}\n\tif len(trimmed) <= maxLen {\n\t\treturn string(trimmed)\n\t}\n\treturn string(trimmed[:maxLen]) + \"...\"\n}\n\nfunc leadingTrimmedPrefix(body []byte, maxLen int) []byte {\n\ti := 0\n\tfor i < len(body) {\n\t\tswitch body[i] {\n\t\tcase ' ', '\\t', '\\n', '\\r', '\\f', '\\v':\n\t\t\ti++\n\t\tdefault:\n\t\t\tend := i + maxLen\n\t\t\tif end > len(body) {\n\t\t\t\tend = len(body)\n\t\t\t}\n\t\t\treturn body[i:end]\n\t\t}\n\t}\n\treturn nil\n}\n\n// --- Numeric helpers ---\n\n// AsInt converts various numeric types to int.\nfunc AsInt(v any) (int, bool) {\n\tswitch val := v.(type) {\n\tcase int:\n\t\treturn val, true\n\tcase int64:\n\t\treturn int(val), true\n\tcase float64:\n\t\treturn int(val), true\n\tcase float32:\n\t\treturn int(val), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// AsFloat converts various numeric types to float64.\nfunc AsFloat(v any) (float64, bool) {\n\tswitch val := v.(type) {\n\tcase float64:\n\t\treturn val, true\n\tcase float32:\n\t\treturn float64(val), true\n\tcase int:\n\t\treturn float64(val), true\n\tcase int64:\n\t\treturn float64(val), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/common/common_test.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\n// --- NewHTTPClient tests ---\n\nfunc TestNewHTTPClient_DefaultTimeout(t *testing.T) {\n\tclient := NewHTTPClient(\"\")\n\tif client.Timeout != DefaultRequestTimeout {\n\t\tt.Errorf(\"timeout = %v, want %v\", client.Timeout, DefaultRequestTimeout)\n\t}\n}\n\nfunc TestNewHTTPClient_WithProxy(t *testing.T) {\n\tclient := NewHTTPClient(\"http://127.0.0.1:8080\")\n\ttransport, ok := client.Transport.(*http.Transport)\n\tif !ok || transport == nil {\n\t\tt.Fatalf(\"expected http.Transport with proxy, got %T\", client.Transport)\n\t}\n\treq := &http.Request{URL: &url.URL{Scheme: \"https\", Host: \"api.example.com\"}}\n\tgotProxy, err := transport.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"proxy function error: %v\", err)\n\t}\n\tif gotProxy == nil || gotProxy.String() != \"http://127.0.0.1:8080\" {\n\t\tt.Errorf(\"proxy = %v, want http://127.0.0.1:8080\", gotProxy)\n\t}\n}\n\nfunc TestNewHTTPClient_NoProxy(t *testing.T) {\n\tclient := NewHTTPClient(\"\")\n\tif client.Transport != nil {\n\t\tt.Errorf(\"expected nil transport without proxy, got %T\", client.Transport)\n\t}\n}\n\nfunc TestNewHTTPClient_InvalidProxy(t *testing.T) {\n\t// Should not panic, just log and return client without proxy\n\tclient := NewHTTPClient(\"://bad-url\")\n\tif client == nil {\n\t\tt.Fatal(\"expected non-nil client even with invalid proxy\")\n\t}\n}\n\n// --- SerializeMessages tests ---\n\nfunc TestSerializeMessages_PlainText(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"hello\"},\n\t\t{Role: \"assistant\", Content: \"hi\", ReasoningContent: \"thinking...\"},\n\t}\n\tresult := SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tif msgs[0][\"content\"] != \"hello\" {\n\t\tt.Errorf(\"expected plain string content, got %v\", msgs[0][\"content\"])\n\t}\n\tif msgs[1][\"reasoning_content\"] != \"thinking...\" {\n\t\tt.Errorf(\"reasoning_content not preserved, got %v\", msgs[1][\"reasoning_content\"])\n\t}\n}\n\nfunc TestSerializeMessages_WithMedia(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"describe this\", Media: []string{\"data:image/png;base64,abc123\"}},\n\t}\n\tresult := SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tcontent, ok := msgs[0][\"content\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected array content for media message, got %T\", msgs[0][\"content\"])\n\t}\n\tif len(content) != 2 {\n\t\tt.Fatalf(\"expected 2 content parts, got %d\", len(content))\n\t}\n}\n\nfunc TestSerializeMessages_MediaWithToolCallID(t *testing.T) {\n\tmessages := []Message{\n\t\t{Role: \"tool\", Content: \"result\", Media: []string{\"data:image/png;base64,xyz\"}, ToolCallID: \"call_1\"},\n\t}\n\tresult := SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tif msgs[0][\"tool_call_id\"] != \"call_1\" {\n\t\tt.Errorf(\"tool_call_id not preserved, got %v\", msgs[0][\"tool_call_id\"])\n\t}\n}\n\nfunc TestSerializeMessages_StripsSystemParts(t *testing.T) {\n\tmessages := []Message{\n\t\t{\n\t\t\tRole:    \"system\",\n\t\t\tContent: \"you are helpful\",\n\t\t\tSystemParts: []protocoltypes.ContentBlock{\n\t\t\t\t{Type: \"text\", Text: \"you are helpful\"},\n\t\t\t},\n\t\t},\n\t}\n\tresult := SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tif strings.Contains(string(data), \"system_parts\") {\n\t\tt.Error(\"system_parts should not appear in serialized output\")\n\t}\n}\n\n// --- ParseResponse tests ---\n\nfunc TestParseResponse_BasicContent(t *testing.T) {\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"hello world\"},\"finish_reason\":\"stop\"}]}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif out.Content != \"hello world\" {\n\t\tt.Errorf(\"Content = %q, want %q\", out.Content, \"hello world\")\n\t}\n\tif out.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", out.FinishReason, \"stop\")\n\t}\n}\n\nfunc TestParseResponse_EmptyChoices(t *testing.T) {\n\tbody := `{\"choices\":[]}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif out.Content != \"\" {\n\t\tt.Errorf(\"Content = %q, want empty\", out.Content)\n\t}\n\tif out.FinishReason != \"stop\" {\n\t\tt.Errorf(\"FinishReason = %q, want %q\", out.FinishReason, \"stop\")\n\t}\n}\n\nfunc TestParseResponse_WithToolCalls(t *testing.T) {\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"SF\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n\tif out.ToolCalls[0].Name != \"get_weather\" {\n\t\tt.Errorf(\"ToolCalls[0].Name = %q, want %q\", out.ToolCalls[0].Name, \"get_weather\")\n\t}\n\tif out.ToolCalls[0].Arguments[\"city\"] != \"SF\" {\n\t\tt.Errorf(\"ToolCalls[0].Arguments[city] = %v, want SF\", out.ToolCalls[0].Arguments[\"city\"])\n\t}\n}\n\nfunc TestParseResponse_WithUsage(t *testing.T) {\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif out.Usage == nil {\n\t\tt.Fatal(\"Usage is nil\")\n\t}\n\tif out.Usage.PromptTokens != 10 {\n\t\tt.Errorf(\"PromptTokens = %d, want 10\", out.Usage.PromptTokens)\n\t}\n}\n\nfunc TestParseResponse_WithReasoningContent(t *testing.T) {\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"2\",\"reasoning_content\":\"Let me think... 1+1=2\"},\"finish_reason\":\"stop\"}]}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif out.ReasoningContent != \"Let me think... 1+1=2\" {\n\t\tt.Errorf(\"ReasoningContent = %q, want %q\", out.ReasoningContent, \"Let me think... 1+1=2\")\n\t}\n}\n\nfunc TestParseResponse_InvalidJSON(t *testing.T) {\n\t_, err := ParseResponse(strings.NewReader(\"not json\"))\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid JSON\")\n\t}\n}\n\n// --- DecodeToolCallArguments tests ---\n\nfunc TestDecodeToolCallArguments_ObjectJSON(t *testing.T) {\n\traw := json.RawMessage(`{\"city\":\"Seattle\",\"units\":\"metric\"}`)\n\targs := DecodeToolCallArguments(raw, \"test\")\n\tif args[\"city\"] != \"Seattle\" {\n\t\tt.Errorf(\"city = %v, want Seattle\", args[\"city\"])\n\t}\n\tif args[\"units\"] != \"metric\" {\n\t\tt.Errorf(\"units = %v, want metric\", args[\"units\"])\n\t}\n}\n\nfunc TestDecodeToolCallArguments_StringJSON(t *testing.T) {\n\traw := json.RawMessage(`\"{\\\"city\\\":\\\"SF\\\"}\"`)\n\targs := DecodeToolCallArguments(raw, \"test\")\n\tif args[\"city\"] != \"SF\" {\n\t\tt.Errorf(\"city = %v, want SF\", args[\"city\"])\n\t}\n}\n\nfunc TestDecodeToolCallArguments_EmptyInput(t *testing.T) {\n\targs := DecodeToolCallArguments(nil, \"test\")\n\tif len(args) != 0 {\n\t\tt.Errorf(\"expected empty map, got %v\", args)\n\t}\n}\n\nfunc TestDecodeToolCallArguments_NullInput(t *testing.T) {\n\targs := DecodeToolCallArguments(json.RawMessage(`null`), \"test\")\n\tif len(args) != 0 {\n\t\tt.Errorf(\"expected empty map, got %v\", args)\n\t}\n}\n\nfunc TestDecodeToolCallArguments_InvalidJSON(t *testing.T) {\n\targs := DecodeToolCallArguments(json.RawMessage(`not-json`), \"test\")\n\tif _, ok := args[\"raw\"]; !ok {\n\t\tt.Error(\"expected 'raw' fallback key for invalid JSON\")\n\t}\n}\n\nfunc TestDecodeToolCallArguments_EmptyStringJSON(t *testing.T) {\n\targs := DecodeToolCallArguments(json.RawMessage(`\"  \"`), \"test\")\n\tif len(args) != 0 {\n\t\tt.Errorf(\"expected empty map for whitespace string, got %v\", args)\n\t}\n}\n\n// --- HandleErrorResponse tests ---\n\nfunc TestHandleErrorResponse_JSONError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tw.Write([]byte(`{\"error\":\"bad request\"}`))\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\terr = HandleErrorResponse(resp, server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"400\") {\n\t\tt.Errorf(\"error should contain status code, got %v\", err)\n\t}\n\tif strings.Contains(err.Error(), \"HTML\") {\n\t\tt.Errorf(\"should not mention HTML for JSON error, got %v\", err)\n\t}\n}\n\nfunc TestHandleErrorResponse_HTMLError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.WriteHeader(http.StatusBadGateway)\n\t\tw.Write([]byte(\"<!DOCTYPE html><html><body>bad gateway</body></html>\"))\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\terr = HandleErrorResponse(resp, server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"HTML instead of JSON\") {\n\t\tt.Errorf(\"expected HTML error message, got %v\", err)\n\t}\n}\n\n// --- ReadAndParseResponse tests ---\n\nfunc TestReadAndParseResponse_ValidJSON(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Write([]byte(`{\"choices\":[{\"message\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\tout, err := ReadAndParseResponse(resp, server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadAndParseResponse() error = %v\", err)\n\t}\n\tif out.Content != \"ok\" {\n\t\tt.Errorf(\"Content = %q, want %q\", out.Content, \"ok\")\n\t}\n}\n\nfunc TestReadAndParseResponse_HTMLResponse(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.Write([]byte(\"<!DOCTYPE html><html><body>login page</body></html>\"))\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\t_, err = ReadAndParseResponse(resp, server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for HTML response\")\n\t}\n\tif !strings.Contains(err.Error(), \"HTML instead of JSON\") {\n\t\tt.Errorf(\"expected HTML error, got %v\", err)\n\t}\n}\n\n// --- LooksLikeHTML tests ---\n\nfunc TestLooksLikeHTML_ContentTypeHTML(t *testing.T) {\n\tif !LooksLikeHTML(nil, \"text/html; charset=utf-8\") {\n\t\tt.Error(\"expected true for text/html content type\")\n\t}\n}\n\nfunc TestLooksLikeHTML_ContentTypeXHTML(t *testing.T) {\n\tif !LooksLikeHTML(nil, \"application/xhtml+xml\") {\n\t\tt.Error(\"expected true for xhtml content type\")\n\t}\n}\n\nfunc TestLooksLikeHTML_BodyPrefix(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t}{\n\t\t{\"doctype\", \"<!DOCTYPE html><html>\"},\n\t\t{\"html tag\", \"<html><body>\"},\n\t\t{\"head tag\", \"<head><title>\"},\n\t\t{\"body tag\", \"<body>content\"},\n\t\t{\"whitespace before\", \"  \\n\\t<!DOCTYPE html>\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif !LooksLikeHTML([]byte(tt.body), \"application/json\") {\n\t\t\t\tt.Errorf(\"expected true for body %q\", tt.body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestLooksLikeHTML_NotHTML(t *testing.T) {\n\tif LooksLikeHTML([]byte(`{\"error\":\"bad\"}`), \"application/json\") {\n\t\tt.Error(\"expected false for JSON body\")\n\t}\n}\n\n// --- ResponsePreview tests ---\n\nfunc TestResponsePreview_Short(t *testing.T) {\n\tgot := ResponsePreview([]byte(\"hello\"), 128)\n\tif got != \"hello\" {\n\t\tt.Errorf(\"got %q, want %q\", got, \"hello\")\n\t}\n}\n\nfunc TestResponsePreview_Truncated(t *testing.T) {\n\tbody := strings.Repeat(\"a\", 200)\n\tgot := ResponsePreview([]byte(body), 128)\n\tif len(got) != 131 { // 128 + \"...\"\n\t\tt.Errorf(\"len = %d, want 131\", len(got))\n\t}\n\tif !strings.HasSuffix(got, \"...\") {\n\t\tt.Error(\"expected ... suffix\")\n\t}\n}\n\nfunc TestResponsePreview_Empty(t *testing.T) {\n\tgot := ResponsePreview([]byte(\"\"), 128)\n\tif got != \"<empty>\" {\n\t\tt.Errorf(\"got %q, want %q\", got, \"<empty>\")\n\t}\n}\n\nfunc TestResponsePreview_Whitespace(t *testing.T) {\n\tgot := ResponsePreview([]byte(\"  \\n\\t  \"), 128)\n\tif got != \"<empty>\" {\n\t\tt.Errorf(\"got %q, want %q for whitespace-only body\", got, \"<empty>\")\n\t}\n}\n\n// --- AsInt tests ---\n\nfunc TestAsInt(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tval  any\n\t\twant int\n\t\tok   bool\n\t}{\n\t\t{\"int\", 42, 42, true},\n\t\t{\"int64\", int64(99), 99, true},\n\t\t{\"float64\", float64(512), 512, true},\n\t\t{\"float32\", float32(256), 256, true},\n\t\t{\"string\", \"nope\", 0, false},\n\t\t{\"nil\", nil, 0, false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := AsInt(tt.val)\n\t\t\tif ok != tt.ok || got != tt.want {\n\t\t\t\tt.Errorf(\"AsInt(%v) = (%d, %v), want (%d, %v)\", tt.val, got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- AsFloat tests ---\n\nfunc TestAsFloat(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tval  any\n\t\twant float64\n\t\tok   bool\n\t}{\n\t\t{\"float64\", float64(0.7), 0.7, true},\n\t\t{\"float32\", float32(0.5), float64(float32(0.5)), true},\n\t\t{\"int\", 1, 1.0, true},\n\t\t{\"int64\", int64(100), 100.0, true},\n\t\t{\"string\", \"nope\", 0, false},\n\t\t{\"nil\", nil, 0, false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := AsFloat(tt.val)\n\t\t\tif ok != tt.ok || got != tt.want {\n\t\t\t\tt.Errorf(\"AsFloat(%v) = (%f, %v), want (%f, %v)\", tt.val, got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- WrapHTMLResponseError tests ---\n\nfunc TestWrapHTMLResponseError(t *testing.T) {\n\terr := WrapHTMLResponseError(502, []byte(\"<html>bad</html>\"), \"text/html\", \"https://api.example.com\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tmsg := err.Error()\n\tif !strings.Contains(msg, \"502\") {\n\t\tt.Errorf(\"expected status code in error, got %v\", msg)\n\t}\n\tif !strings.Contains(msg, \"https://api.example.com\") {\n\t\tt.Errorf(\"expected api base in error, got %v\", msg)\n\t}\n\tif !strings.Contains(msg, \"HTML instead of JSON\") {\n\t\tt.Errorf(\"expected HTML mention in error, got %v\", msg)\n\t}\n}\n\n// --- HandleErrorResponse with read failure ---\n\nfunc TestHandleErrorResponse_EmptyBody(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t// empty body\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\terr = HandleErrorResponse(resp, server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !strings.Contains(err.Error(), \"500\") {\n\t\tt.Errorf(\"expected status code, got %v\", err)\n\t}\n}\n\n// --- ReadAndParseResponse with invalid JSON ---\n\nfunc TestReadAndParseResponse_InvalidJSON(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.Write([]byte(\"not valid json\"))\n\t}))\n\tdefer server.Close()\n\n\tresp, err := http.Get(server.URL)\n\tif err != nil {\n\t\tt.Fatalf(\"http.Get() error = %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\t_, err = ReadAndParseResponse(resp, server.URL)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid JSON\")\n\t}\n}\n\n// --- ParseResponse with thought_signature (Google/Gemini) ---\n\nfunc TestParseResponse_WithThoughtSignature(t *testing.T) {\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"test_tool\",\"arguments\":\"{}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig123\"}}}]},\"finish_reason\":\"tool_calls\"}]}`\n\tout, err := ParseResponse(strings.NewReader(body))\n\tif err != nil {\n\t\tt.Fatalf(\"ParseResponse() error = %v\", err)\n\t}\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n\tif out.ToolCalls[0].ThoughtSignature != \"sig123\" {\n\t\tt.Errorf(\"ThoughtSignature = %q, want %q\", out.ToolCalls[0].ThoughtSignature, \"sig123\")\n\t}\n\tif out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil {\n\t\tt.Fatal(\"ExtraContent.Google is nil\")\n\t}\n\tif out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != \"sig123\" {\n\t\tt.Errorf(\"ExtraContent.Google.ThoughtSignature = %q, want %q\",\n\t\t\tout.ToolCalls[0].ExtraContent.Google.ThoughtSignature, \"sig123\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/cooldown.go",
    "content": "package providers\n\nimport (\n\t\"math\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\tdefaultFailureWindow = 24 * time.Hour\n)\n\n// CooldownTracker manages per-provider cooldown state for the fallback chain.\n// Thread-safe via sync.RWMutex. In-memory only (resets on restart).\ntype CooldownTracker struct {\n\tmu            sync.RWMutex\n\tentries       map[string]*cooldownEntry\n\tfailureWindow time.Duration\n\tnowFunc       func() time.Time // for testing\n}\n\ntype cooldownEntry struct {\n\tErrorCount     int\n\tFailureCounts  map[FailoverReason]int\n\tCooldownEnd    time.Time      // standard cooldown expiry\n\tDisabledUntil  time.Time      // billing-specific disable expiry\n\tDisabledReason FailoverReason // reason for disable (billing)\n\tLastFailure    time.Time\n}\n\n// NewCooldownTracker creates a tracker with default 24h failure window.\nfunc NewCooldownTracker() *CooldownTracker {\n\treturn &CooldownTracker{\n\t\tentries:       make(map[string]*cooldownEntry),\n\t\tfailureWindow: defaultFailureWindow,\n\t\tnowFunc:       time.Now,\n\t}\n}\n\n// MarkFailure records a failure for a provider and sets appropriate cooldown.\n// Resets error counts if last failure was more than failureWindow ago.\nfunc (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {\n\tct.mu.Lock()\n\tdefer ct.mu.Unlock()\n\n\tnow := ct.nowFunc()\n\tentry := ct.getOrCreate(provider)\n\n\t// 24h failure window reset: if no failure in failureWindow, reset counters.\n\tif !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow {\n\t\tentry.ErrorCount = 0\n\t\tentry.FailureCounts = make(map[FailoverReason]int)\n\t}\n\n\tentry.ErrorCount++\n\tentry.FailureCounts[reason]++\n\tentry.LastFailure = now\n\n\tif reason == FailoverBilling {\n\t\tbillingCount := entry.FailureCounts[FailoverBilling]\n\t\tentry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount))\n\t\tentry.DisabledReason = FailoverBilling\n\t} else {\n\t\tentry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount))\n\t}\n}\n\n// MarkSuccess resets all counters and cooldowns for a provider.\nfunc (ct *CooldownTracker) MarkSuccess(provider string) {\n\tct.mu.Lock()\n\tdefer ct.mu.Unlock()\n\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\treturn\n\t}\n\n\tentry.ErrorCount = 0\n\tentry.FailureCounts = make(map[FailoverReason]int)\n\tentry.CooldownEnd = time.Time{}\n\tentry.DisabledUntil = time.Time{}\n\tentry.DisabledReason = \"\"\n}\n\n// IsAvailable returns true if the provider is not in cooldown or disabled.\nfunc (ct *CooldownTracker) IsAvailable(provider string) bool {\n\tct.mu.RLock()\n\tdefer ct.mu.RUnlock()\n\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\treturn true\n\t}\n\n\tnow := ct.nowFunc()\n\n\t// Billing disable takes precedence (longer cooldown).\n\tif !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {\n\t\treturn false\n\t}\n\n\t// Standard cooldown.\n\tif !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// CooldownRemaining returns how long until the provider becomes available.\n// Returns 0 if already available.\nfunc (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration {\n\tct.mu.RLock()\n\tdefer ct.mu.RUnlock()\n\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\treturn 0\n\t}\n\n\tnow := ct.nowFunc()\n\tvar remaining time.Duration\n\n\tif !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {\n\t\td := entry.DisabledUntil.Sub(now)\n\t\tif d > remaining {\n\t\t\tremaining = d\n\t\t}\n\t}\n\n\tif !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {\n\t\td := entry.CooldownEnd.Sub(now)\n\t\tif d > remaining {\n\t\t\tremaining = d\n\t\t}\n\t}\n\n\treturn remaining\n}\n\n// ErrorCount returns the current error count for a provider.\nfunc (ct *CooldownTracker) ErrorCount(provider string) int {\n\tct.mu.RLock()\n\tdefer ct.mu.RUnlock()\n\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\treturn 0\n\t}\n\treturn entry.ErrorCount\n}\n\n// FailureCount returns the failure count for a specific reason.\nfunc (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int {\n\tct.mu.RLock()\n\tdefer ct.mu.RUnlock()\n\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\treturn 0\n\t}\n\treturn entry.FailureCounts[reason]\n}\n\nfunc (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry {\n\tentry := ct.entries[provider]\n\tif entry == nil {\n\t\tentry = &cooldownEntry{\n\t\t\tFailureCounts: make(map[FailoverReason]int),\n\t\t}\n\t\tct.entries[provider] = entry\n\t}\n\treturn entry\n}\n\n// calculateStandardCooldown computes standard exponential backoff.\n// Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3))\n//\n//\t1 error  → 1 min\n//\t2 errors → 5 min\n//\t3 errors → 25 min\n//\t4+ errors → 1 hour (cap)\nfunc calculateStandardCooldown(errorCount int) time.Duration {\n\tn := max(1, errorCount)\n\texp := min(n-1, 3)\n\tms := 60_000 * int(math.Pow(5, float64(exp)))\n\tms = min(3_600_000, ms) // cap at 1 hour\n\treturn time.Duration(ms) * time.Millisecond\n}\n\n// calculateBillingCooldown computes billing-specific exponential backoff.\n// Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10))\n//\n//\t1 error  → 5 hours\n//\t2 errors → 10 hours\n//\t3 errors → 20 hours\n//\t4+ errors → 24 hours (cap)\nfunc calculateBillingCooldown(billingErrorCount int) time.Duration {\n\tconst baseMs = 5 * 60 * 60 * 1000 // 5 hours\n\tconst maxMs = 24 * 60 * 60 * 1000 // 24 hours\n\n\tn := max(1, billingErrorCount)\n\texp := min(n-1, 10)\n\traw := float64(baseMs) * math.Pow(2, float64(exp))\n\tms := int(math.Min(float64(maxMs), raw))\n\treturn time.Duration(ms) * time.Millisecond\n}\n"
  },
  {
    "path": "pkg/providers/cooldown_test.go",
    "content": "package providers\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc newTestTracker(now time.Time) (*CooldownTracker, *time.Time) {\n\tcurrent := now\n\tct := NewCooldownTracker()\n\tct.nowFunc = func() time.Time { return current }\n\treturn ct, &current\n}\n\nfunc TestCooldown_InitiallyAvailable(t *testing.T) {\n\tct := NewCooldownTracker()\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"new provider should be available\")\n\t}\n\tif ct.ErrorCount(\"openai\") != 0 {\n\t\tt.Error(\"new provider should have 0 errors\")\n\t}\n}\n\nfunc TestCooldown_StandardEscalation(t *testing.T) {\n\tnow := time.Now()\n\tct, current := newTestTracker(now)\n\n\t// 1st error → 1 min cooldown\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be in cooldown after 1st error\")\n\t}\n\n\t// Advance 61 seconds → available\n\t*current = now.Add(61 * time.Second)\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be available after 1 min cooldown\")\n\t}\n\n\t// 2nd error → 5 min cooldown\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\t*current = now.Add(61*time.Second + 4*time.Minute)\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be in cooldown (5 min) after 2nd error\")\n\t}\n\t*current = now.Add(61*time.Second + 6*time.Minute)\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be available after 5 min cooldown\")\n\t}\n}\n\nfunc TestCooldown_StandardCap(t *testing.T) {\n\t// Verify formula: 1m, 5m, 25m, 1h, 1h, 1h...\n\texpected := []time.Duration{\n\t\t1 * time.Minute,\n\t\t5 * time.Minute,\n\t\t25 * time.Minute,\n\t\t1 * time.Hour,\n\t\t1 * time.Hour,\n\t}\n\n\tfor i, want := range expected {\n\t\tgot := calculateStandardCooldown(i + 1)\n\t\tif got != want {\n\t\t\tt.Errorf(\"calculateStandardCooldown(%d) = %v, want %v\", i+1, got, want)\n\t\t}\n\t}\n}\n\nfunc TestCooldown_BillingEscalation(t *testing.T) {\n\tnow := time.Now()\n\tct, current := newTestTracker(now)\n\n\t// 1st billing error → 5h cooldown\n\tct.MarkFailure(\"openai\", FailoverBilling)\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be disabled after billing error\")\n\t}\n\n\t// Advance 4h → still disabled\n\t*current = now.Add(4 * time.Hour)\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should still be disabled (5h cooldown)\")\n\t}\n\n\t// Advance 5h + 1s → available\n\t*current = now.Add(5*time.Hour + 1*time.Second)\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be available after 5h billing cooldown\")\n\t}\n}\n\nfunc TestCooldown_BillingCap(t *testing.T) {\n\texpected := []time.Duration{\n\t\t5 * time.Hour,\n\t\t10 * time.Hour,\n\t\t20 * time.Hour,\n\t\t24 * time.Hour,\n\t\t24 * time.Hour,\n\t}\n\n\tfor i, want := range expected {\n\t\tgot := calculateBillingCooldown(i + 1)\n\t\tif got != want {\n\t\t\tt.Errorf(\"calculateBillingCooldown(%d) = %v, want %v\", i+1, got, want)\n\t\t}\n\t}\n}\n\nfunc TestCooldown_SuccessReset(t *testing.T) {\n\tct := NewCooldownTracker()\n\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tct.MarkFailure(\"openai\", FailoverBilling)\n\tif ct.ErrorCount(\"openai\") != 2 {\n\t\tt.Errorf(\"error count = %d, want 2\", ct.ErrorCount(\"openai\"))\n\t}\n\n\tct.MarkSuccess(\"openai\")\n\tif ct.ErrorCount(\"openai\") != 0 {\n\t\tt.Errorf(\"error count after success = %d, want 0\", ct.ErrorCount(\"openai\"))\n\t}\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be available after success\")\n\t}\n\tif ct.FailureCount(\"openai\", FailoverRateLimit) != 0 {\n\t\tt.Error(\"failure counts should be reset after success\")\n\t}\n\tif ct.FailureCount(\"openai\", FailoverBilling) != 0 {\n\t\tt.Error(\"billing failure count should be reset after success\")\n\t}\n}\n\nfunc TestCooldown_FailureWindowReset(t *testing.T) {\n\tnow := time.Now()\n\tct, current := newTestTracker(now)\n\n\t// 4 errors → 1h cooldown\n\tfor range 4 {\n\t\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\t\t*current = current.Add(2 * time.Second) // small advance between errors\n\t}\n\tif ct.ErrorCount(\"openai\") != 4 {\n\t\tt.Errorf(\"error count = %d, want 4\", ct.ErrorCount(\"openai\"))\n\t}\n\n\t// Advance 25 hours (past 24h failure window)\n\t*current = now.Add(25 * time.Hour)\n\n\t// Next error should reset counters first, then increment to 1\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tif ct.ErrorCount(\"openai\") != 1 {\n\t\tt.Errorf(\"error count after window reset = %d, want 1 (reset + 1)\", ct.ErrorCount(\"openai\"))\n\t}\n}\n\nfunc TestCooldown_PerReasonTracking(t *testing.T) {\n\tct := NewCooldownTracker()\n\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tct.MarkFailure(\"openai\", FailoverBilling)\n\tct.MarkFailure(\"openai\", FailoverAuth)\n\n\tif ct.FailureCount(\"openai\", FailoverRateLimit) != 2 {\n\t\tt.Errorf(\"rate_limit count = %d, want 2\", ct.FailureCount(\"openai\", FailoverRateLimit))\n\t}\n\tif ct.FailureCount(\"openai\", FailoverBilling) != 1 {\n\t\tt.Errorf(\"billing count = %d, want 1\", ct.FailureCount(\"openai\", FailoverBilling))\n\t}\n\tif ct.FailureCount(\"openai\", FailoverAuth) != 1 {\n\t\tt.Errorf(\"auth count = %d, want 1\", ct.FailureCount(\"openai\", FailoverAuth))\n\t}\n\tif ct.ErrorCount(\"openai\") != 4 {\n\t\tt.Errorf(\"total error count = %d, want 4\", ct.ErrorCount(\"openai\"))\n\t}\n}\n\nfunc TestCooldown_BillingTakesPrecedence(t *testing.T) {\n\tnow := time.Now()\n\tct, current := newTestTracker(now)\n\n\t// Standard cooldown (1 min) + billing disable (5h)\n\tct.MarkFailure(\"openai\", FailoverRateLimit) // 1 min cooldown\n\tct.MarkFailure(\"openai\", FailoverBilling)   // 5h disable\n\n\t// After 2 min: standard cooldown expired but billing still active\n\t*current = now.Add(2 * time.Minute)\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"billing disable should take precedence over standard cooldown\")\n\t}\n\n\t// After 5h + 1s: both expired\n\t*current = now.Add(5*time.Hour + 1*time.Second)\n\tif !ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"should be available after all cooldowns expire\")\n\t}\n}\n\nfunc TestCooldown_CooldownRemaining(t *testing.T) {\n\tnow := time.Now()\n\tct, current := newTestTracker(now)\n\n\t// No failures → 0 remaining\n\tif ct.CooldownRemaining(\"openai\") != 0 {\n\t\tt.Error(\"expected 0 remaining for new provider\")\n\t}\n\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\n\t*current = now.Add(30 * time.Second)\n\tremaining := ct.CooldownRemaining(\"openai\")\n\tif remaining <= 0 || remaining > 1*time.Minute {\n\t\tt.Errorf(\"remaining = %v, expected ~30s\", remaining)\n\t}\n}\n\nfunc TestCooldown_SuccessOnUnknownProvider(t *testing.T) {\n\tct := NewCooldownTracker()\n\t// Should not panic\n\tct.MarkSuccess(\"nonexistent\")\n\tif !ct.IsAvailable(\"nonexistent\") {\n\t\tt.Error(\"nonexistent provider should be available\")\n\t}\n}\n\nfunc TestCooldown_ConcurrentAccess(t *testing.T) {\n\tct := NewCooldownTracker()\n\tvar wg sync.WaitGroup\n\n\tfor range 100 {\n\t\twg.Add(3)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\t\t}()\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tct.IsAvailable(\"openai\")\n\t\t}()\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tct.MarkSuccess(\"openai\")\n\t\t}()\n\t}\n\n\twg.Wait()\n\t// If we got here without panic, concurrent access is safe\n}\n\nfunc TestCooldown_MultipleProviders(t *testing.T) {\n\tct := NewCooldownTracker()\n\n\tct.MarkFailure(\"openai\", FailoverRateLimit)\n\tct.MarkFailure(\"anthropic\", FailoverBilling)\n\n\tif ct.IsAvailable(\"openai\") {\n\t\tt.Error(\"openai should be in cooldown\")\n\t}\n\tif ct.IsAvailable(\"anthropic\") {\n\t\tt.Error(\"anthropic should be in cooldown\")\n\t}\n\t// groq was never touched\n\tif !ct.IsAvailable(\"groq\") {\n\t\tt.Error(\"groq should be available\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/error_classifier.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Common patterns in Go HTTP error messages\nvar httpStatusPatterns = []*regexp.Regexp{\n\tregexp.MustCompile(`status[:\\s]+(\\d{3})`),\n\tregexp.MustCompile(`http[/\\s]+\\d*\\.?\\d*\\s+(\\d{3})`),\n\tregexp.MustCompile(`\\b([3-5]\\d{2})\\b`),\n}\n\n// errorPattern defines a single pattern (string or regex) for error classification.\ntype errorPattern struct {\n\tsubstring string\n\tregex     *regexp.Regexp\n}\n\nfunc substr(s string) errorPattern { return errorPattern{substring: s} }\nfunc rxp(r string) errorPattern    { return errorPattern{regex: regexp.MustCompile(\"(?i)\" + r)} }\n\n// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns).\nvar (\n\trateLimitPatterns = []errorPattern{\n\t\trxp(`rate[_ ]limit`),\n\t\tsubstr(\"too many requests\"),\n\t\tsubstr(\"429\"),\n\t\tsubstr(\"exceeded your current quota\"),\n\t\trxp(`exceeded.*quota`),\n\t\trxp(`resource has been exhausted`),\n\t\trxp(`resource.*exhausted`),\n\t\tsubstr(\"resource_exhausted\"),\n\t\tsubstr(\"quota exceeded\"),\n\t\tsubstr(\"usage limit\"),\n\t}\n\n\toverloadedPatterns = []errorPattern{\n\t\trxp(`overloaded_error`),\n\t\trxp(`\"type\"\\s*:\\s*\"overloaded_error\"`),\n\t\tsubstr(\"overloaded\"),\n\t}\n\n\ttimeoutPatterns = []errorPattern{\n\t\tsubstr(\"timeout\"),\n\t\tsubstr(\"timed out\"),\n\t\tsubstr(\"deadline exceeded\"),\n\t\tsubstr(\"context deadline exceeded\"),\n\t}\n\n\tbillingPatterns = []errorPattern{\n\t\trxp(`\\b402\\b`),\n\t\tsubstr(\"payment required\"),\n\t\tsubstr(\"insufficient credits\"),\n\t\tsubstr(\"credit balance\"),\n\t\tsubstr(\"plans & billing\"),\n\t\tsubstr(\"insufficient balance\"),\n\t}\n\n\tauthPatterns = []errorPattern{\n\t\trxp(`invalid[_ ]?api[_ ]?key`),\n\t\tsubstr(\"incorrect api key\"),\n\t\tsubstr(\"invalid token\"),\n\t\tsubstr(\"authentication\"),\n\t\tsubstr(\"re-authenticate\"),\n\t\tsubstr(\"oauth token refresh failed\"),\n\t\tsubstr(\"unauthorized\"),\n\t\tsubstr(\"forbidden\"),\n\t\tsubstr(\"access denied\"),\n\t\tsubstr(\"expired\"),\n\t\tsubstr(\"token has expired\"),\n\t\trxp(`\\b401\\b`),\n\t\trxp(`\\b403\\b`),\n\t\tsubstr(\"no credentials found\"),\n\t\tsubstr(\"no api key found\"),\n\t}\n\n\tformatPatterns = []errorPattern{\n\t\tsubstr(\"string should match pattern\"),\n\t\tsubstr(\"tool_use.id\"),\n\t\tsubstr(\"tool_use_id\"),\n\t\tsubstr(\"messages.1.content.1.tool_use.id\"),\n\t\tsubstr(\"invalid request format\"),\n\t}\n\n\timageDimensionPatterns = []errorPattern{\n\t\trxp(`image dimensions exceed max`),\n\t}\n\n\timageSizePatterns = []errorPattern{\n\t\trxp(`image exceeds.*mb`),\n\t}\n\n\t// Transient HTTP status codes that map to timeout (server-side failures).\n\ttransientStatusCodes = map[int]bool{\n\t\t500: true, 502: true, 503: true,\n\t\t521: true, 522: true, 523: true, 524: true,\n\t\t529: true,\n\t}\n)\n\n// ClassifyError classifies an error into a FailoverError with reason.\n// Returns nil if the error is not classifiable (unknown errors should not trigger fallback).\nfunc ClassifyError(err error, provider, model string) *FailoverError {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\t// Context cancellation: user abort, never fallback.\n\tif err == context.Canceled {\n\t\treturn nil\n\t}\n\n\t// Context deadline exceeded: treat as timeout, always fallback.\n\tif err == context.DeadlineExceeded {\n\t\treturn &FailoverError{\n\t\t\tReason:   FailoverTimeout,\n\t\t\tProvider: provider,\n\t\t\tModel:    model,\n\t\t\tWrapped:  err,\n\t\t}\n\t}\n\n\tmsg := strings.ToLower(err.Error())\n\n\t// Image dimension/size errors: non-retriable, non-fallback.\n\tif IsImageDimensionError(msg) || IsImageSizeError(msg) {\n\t\treturn &FailoverError{\n\t\t\tReason:   FailoverFormat,\n\t\t\tProvider: provider,\n\t\t\tModel:    model,\n\t\t\tWrapped:  err,\n\t\t}\n\t}\n\n\t// Try HTTP status code extraction first.\n\tif status := extractHTTPStatus(msg); status > 0 {\n\t\tif reason := classifyByStatus(status); reason != \"\" {\n\t\t\treturn &FailoverError{\n\t\t\t\tReason:   reason,\n\t\t\t\tProvider: provider,\n\t\t\t\tModel:    model,\n\t\t\t\tStatus:   status,\n\t\t\t\tWrapped:  err,\n\t\t\t}\n\t\t}\n\t}\n\n\t// Message pattern matching (priority order from OpenClaw).\n\tif reason := classifyByMessage(msg); reason != \"\" {\n\t\treturn &FailoverError{\n\t\t\tReason:   reason,\n\t\t\tProvider: provider,\n\t\t\tModel:    model,\n\t\t\tWrapped:  err,\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// classifyByStatus maps HTTP status codes to FailoverReason.\nfunc classifyByStatus(status int) FailoverReason {\n\tswitch {\n\tcase status == 401 || status == 403:\n\t\treturn FailoverAuth\n\tcase status == 402:\n\t\treturn FailoverBilling\n\tcase status == 408:\n\t\treturn FailoverTimeout\n\tcase status == 429:\n\t\treturn FailoverRateLimit\n\tcase status == 400:\n\t\treturn FailoverFormat\n\tcase transientStatusCodes[status]:\n\t\treturn FailoverTimeout\n\t}\n\treturn \"\"\n}\n\n// classifyByMessage matches error messages against patterns.\n// Priority order matters (from OpenClaw classifyFailoverReason).\nfunc classifyByMessage(msg string) FailoverReason {\n\tif matchesAny(msg, rateLimitPatterns) {\n\t\treturn FailoverRateLimit\n\t}\n\tif matchesAny(msg, overloadedPatterns) {\n\t\treturn FailoverRateLimit // Overloaded treated as rate_limit\n\t}\n\tif matchesAny(msg, billingPatterns) {\n\t\treturn FailoverBilling\n\t}\n\tif matchesAny(msg, timeoutPatterns) {\n\t\treturn FailoverTimeout\n\t}\n\tif matchesAny(msg, authPatterns) {\n\t\treturn FailoverAuth\n\t}\n\tif matchesAny(msg, formatPatterns) {\n\t\treturn FailoverFormat\n\t}\n\treturn \"\"\n}\n\n// extractHTTPStatus extracts an HTTP status code from an error message.\n// Looks for patterns like \"status: 429\", \"status 429\", \"http/1.1 429\", \"http 429\", or standalone \"429\".\nfunc extractHTTPStatus(msg string) int {\n\tfor _, p := range httpStatusPatterns {\n\t\tif m := p.FindStringSubmatch(msg); len(m) > 1 {\n\t\t\treturn parseDigits(m[1])\n\t\t}\n\t}\n\treturn 0\n}\n\n// IsImageDimensionError returns true if the message indicates an image dimension error.\nfunc IsImageDimensionError(msg string) bool {\n\treturn matchesAny(msg, imageDimensionPatterns)\n}\n\n// IsImageSizeError returns true if the message indicates an image file size error.\nfunc IsImageSizeError(msg string) bool {\n\treturn matchesAny(msg, imageSizePatterns)\n}\n\n// matchesAny checks if msg matches any of the patterns.\nfunc matchesAny(msg string, patterns []errorPattern) bool {\n\tfor _, p := range patterns {\n\t\tif p.regex != nil {\n\t\t\tif p.regex.MatchString(msg) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t} else if p.substring != \"\" {\n\t\t\tif strings.Contains(msg, p.substring) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// parseDigits converts a string of digits to an int.\nfunc parseDigits(s string) int {\n\tn := 0\n\tfor _, c := range s {\n\t\tif c >= '0' && c <= '9' {\n\t\t\tn = n*10 + int(c-'0')\n\t\t}\n\t}\n\treturn n\n}\n"
  },
  {
    "path": "pkg/providers/error_classifier_test.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestClassifyError_Nil(t *testing.T) {\n\tresult := ClassifyError(nil, \"openai\", \"gpt-4\")\n\tif result != nil {\n\t\tt.Errorf(\"expected nil for nil error, got %+v\", result)\n\t}\n}\n\nfunc TestClassifyError_ContextCanceled(t *testing.T) {\n\tresult := ClassifyError(context.Canceled, \"openai\", \"gpt-4\")\n\tif result != nil {\n\t\tt.Errorf(\"expected nil for context.Canceled (user abort), got %+v\", result)\n\t}\n}\n\nfunc TestClassifyError_ContextDeadlineExceeded(t *testing.T) {\n\tresult := ClassifyError(context.DeadlineExceeded, \"openai\", \"gpt-4\")\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil for deadline exceeded\")\n\t}\n\tif result.Reason != FailoverTimeout {\n\t\tt.Errorf(\"reason = %q, want timeout\", result.Reason)\n\t}\n}\n\nfunc TestClassifyError_StatusCodes(t *testing.T) {\n\ttests := []struct {\n\t\tstatus int\n\t\treason FailoverReason\n\t}{\n\t\t{401, FailoverAuth},\n\t\t{403, FailoverAuth},\n\t\t{402, FailoverBilling},\n\t\t{408, FailoverTimeout},\n\t\t{429, FailoverRateLimit},\n\t\t{400, FailoverFormat},\n\t\t{500, FailoverTimeout},\n\t\t{502, FailoverTimeout},\n\t\t{503, FailoverTimeout},\n\t\t{521, FailoverTimeout},\n\t\t{522, FailoverTimeout},\n\t\t{523, FailoverTimeout},\n\t\t{524, FailoverTimeout},\n\t\t{529, FailoverTimeout},\n\t}\n\n\tfor _, tt := range tests {\n\t\terr := fmt.Errorf(\"API error: status: %d something went wrong\", tt.status)\n\t\tresult := ClassifyError(err, \"test\", \"model\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"status %d: expected non-nil\", tt.status)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != tt.reason {\n\t\t\tt.Errorf(\"status %d: reason = %q, want %q\", tt.status, result.Reason, tt.reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_RateLimitPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"rate limit exceeded\",\n\t\t\"rate_limit reached\",\n\t\t\"too many requests\",\n\t\t\"exceeded your current quota\",\n\t\t\"resource has been exhausted\",\n\t\t\"resource_exhausted\",\n\t\t\"quota exceeded\",\n\t\t\"usage limit reached\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"openai\", \"gpt-4\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != FailoverRateLimit {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want rate_limit\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_OverloadedPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"overloaded_error\",\n\t\t`{\"type\": \"overloaded_error\"}`,\n\t\t\"server is overloaded\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"anthropic\", \"claude\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\t// Overloaded is treated as rate_limit\n\t\tif result.Reason != FailoverRateLimit {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want rate_limit\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_BillingPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"payment required\",\n\t\t\"insufficient credits\",\n\t\t\"credit balance too low\",\n\t\t\"plans & billing page\",\n\t\t\"insufficient balance\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"openai\", \"gpt-4\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != FailoverBilling {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want billing\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_TimeoutPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"request timeout\",\n\t\t\"connection timed out\",\n\t\t\"deadline exceeded\",\n\t\t\"context deadline exceeded\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"openai\", \"gpt-4\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != FailoverTimeout {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want timeout\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_AuthPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"invalid api key\",\n\t\t\"invalid_api_key\",\n\t\t\"incorrect api key\",\n\t\t\"invalid token\",\n\t\t\"authentication failed\",\n\t\t\"re-authenticate\",\n\t\t\"oauth token refresh failed\",\n\t\t\"unauthorized access\",\n\t\t\"forbidden\",\n\t\t\"access denied\",\n\t\t\"expired\",\n\t\t\"token has expired\",\n\t\t\"no credentials found\",\n\t\t\"no api key found\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"openai\", \"gpt-4\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != FailoverAuth {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want auth\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_FormatPatterns(t *testing.T) {\n\tpatterns := []string{\n\t\t\"string should match pattern\",\n\t\t\"tool_use.id is required\",\n\t\t\"invalid tool_use_id\",\n\t\t\"messages.1.content.1.tool_use.id must be valid\",\n\t\t\"invalid request format\",\n\t}\n\n\tfor _, msg := range patterns {\n\t\terr := errors.New(msg)\n\t\tresult := ClassifyError(err, \"anthropic\", \"claude\")\n\t\tif result == nil {\n\t\t\tt.Errorf(\"pattern %q: expected non-nil\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tif result.Reason != FailoverFormat {\n\t\t\tt.Errorf(\"pattern %q: reason = %q, want format\", msg, result.Reason)\n\t\t}\n\t}\n}\n\nfunc TestClassifyError_ImageDimensionError(t *testing.T) {\n\terr := errors.New(\"image dimensions exceed max allowed 2048x2048\")\n\tresult := ClassifyError(err, \"openai\", \"gpt-4o\")\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil for image dimension error\")\n\t}\n\tif result.Reason != FailoverFormat {\n\t\tt.Errorf(\"reason = %q, want format\", result.Reason)\n\t}\n\tif result.IsRetriable() {\n\t\tt.Error(\"image dimension error should not be retriable\")\n\t}\n}\n\nfunc TestClassifyError_ImageSizeError(t *testing.T) {\n\terr := errors.New(\"image exceeds 20 mb limit\")\n\tresult := ClassifyError(err, \"openai\", \"gpt-4o\")\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil for image size error\")\n\t}\n\tif result.Reason != FailoverFormat {\n\t\tt.Errorf(\"reason = %q, want format\", result.Reason)\n\t}\n}\n\nfunc TestClassifyError_UnknownError(t *testing.T) {\n\terr := errors.New(\"some completely random error\")\n\tresult := ClassifyError(err, \"openai\", \"gpt-4\")\n\tif result != nil {\n\t\tt.Errorf(\"expected nil for unknown error, got %+v\", result)\n\t}\n}\n\nfunc TestClassifyError_ProviderModelPropagation(t *testing.T) {\n\terr := errors.New(\"rate limit exceeded\")\n\tresult := ClassifyError(err, \"my-provider\", \"my-model\")\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil\")\n\t}\n\tif result.Provider != \"my-provider\" {\n\t\tt.Errorf(\"provider = %q, want my-provider\", result.Provider)\n\t}\n\tif result.Model != \"my-model\" {\n\t\tt.Errorf(\"model = %q, want my-model\", result.Model)\n\t}\n}\n\nfunc TestFailoverError_IsRetriable(t *testing.T) {\n\ttests := []struct {\n\t\treason    FailoverReason\n\t\tretriable bool\n\t}{\n\t\t{FailoverAuth, true},\n\t\t{FailoverRateLimit, true},\n\t\t{FailoverBilling, true},\n\t\t{FailoverTimeout, true},\n\t\t{FailoverOverloaded, true},\n\t\t{FailoverFormat, false},\n\t\t{FailoverUnknown, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tfe := &FailoverError{Reason: tt.reason}\n\t\tif fe.IsRetriable() != tt.retriable {\n\t\t\tt.Errorf(\"IsRetriable(%q) = %v, want %v\", tt.reason, fe.IsRetriable(), tt.retriable)\n\t\t}\n\t}\n}\n\nfunc TestFailoverError_ErrorString(t *testing.T) {\n\tfe := &FailoverError{\n\t\tReason:   FailoverRateLimit,\n\t\tProvider: \"openai\",\n\t\tModel:    \"gpt-4\",\n\t\tStatus:   429,\n\t\tWrapped:  errors.New(\"too many requests\"),\n\t}\n\ts := fe.Error()\n\tif s == \"\" {\n\t\tt.Error(\"expected non-empty error string\")\n\t}\n}\n\nfunc TestFailoverError_Unwrap(t *testing.T) {\n\tinner := errors.New(\"inner error\")\n\tfe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner}\n\tif fe.Unwrap() != inner {\n\t\tt.Error(\"Unwrap should return wrapped error\")\n\t}\n}\n\nfunc TestExtractHTTPStatus(t *testing.T) {\n\ttests := []struct {\n\t\tmsg  string\n\t\twant int\n\t}{\n\t\t{\"status: 429 rate limited\", 429},\n\t\t{\"status 401 unauthorized\", 401},\n\t\t{\"http/1.1 502 bad gateway\", 502},\n\t\t{\"error 429\", 429},\n\t\t{\"no status code here\", 0},\n\t\t{\"random number 12345\", 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := extractHTTPStatus(tt.msg)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"extractHTTPStatus(%q) = %d, want %d\", tt.msg, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestIsImageDimensionError(t *testing.T) {\n\tif !IsImageDimensionError(\"image dimensions exceed max 4096x4096\") {\n\t\tt.Error(\"should match image dimensions exceed max\")\n\t}\n\tif IsImageDimensionError(\"normal error message\") {\n\t\tt.Error(\"should not match normal error\")\n\t}\n}\n\nfunc TestIsImageSizeError(t *testing.T) {\n\tif !IsImageSizeError(\"image exceeds 20 mb\") {\n\t\tt.Error(\"should match image exceeds mb\")\n\t}\n\tif IsImageSizeError(\"normal error message\") {\n\t\tt.Error(\"should not match normal error\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/factory.go",
    "content": "package providers\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nconst defaultAnthropicAPIBase = \"https://api.anthropic.com/v1\"\n\nvar getCredential = auth.GetCredential\n\ntype providerType int\n\nconst (\n\tproviderTypeHTTPCompat providerType = iota\n\tproviderTypeClaudeAuth\n\tproviderTypeCodexAuth\n\tproviderTypeCodexCLIToken\n\tproviderTypeClaudeCLI\n\tproviderTypeCodexCLI\n\tproviderTypeGitHubCopilot\n)\n\ntype providerSelection struct {\n\tproviderType    providerType\n\tapiKey          string\n\tapiBase         string\n\tproxy           string\n\tmodel           string\n\tworkspace       string\n\tconnectMode     string\n\tenableWebSearch bool\n}\n\nfunc resolveProviderSelection(cfg *config.Config) (providerSelection, error) {\n\tmodel := cfg.Agents.Defaults.GetModelName()\n\tproviderName := strings.ToLower(cfg.Agents.Defaults.Provider)\n\tlowerModel := strings.ToLower(model)\n\n\tif providerName == \"\" && model == \"\" {\n\t\treturn providerSelection{}, fmt.Errorf(\"no model configured: agents.defaults.model is empty\")\n\t}\n\n\tsel := providerSelection{\n\t\tproviderType: providerTypeHTTPCompat,\n\t\tmodel:        model,\n\t}\n\n\t// First, prefer explicit provider configuration.\n\tif providerName != \"\" {\n\t\tswitch providerName {\n\t\tcase \"groq\":\n\t\t\tif cfg.Providers.Groq.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Groq.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Groq.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Groq.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.groq.com/openai/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"openai\", \"gpt\":\n\t\t\tif cfg.Providers.OpenAI.APIKey != \"\" || cfg.Providers.OpenAI.AuthMethod != \"\" {\n\t\t\t\tsel.enableWebSearch = cfg.Providers.OpenAI.WebSearch\n\t\t\t\tif cfg.Providers.OpenAI.AuthMethod == \"codex-cli\" {\n\t\t\t\t\tsel.providerType = providerTypeCodexCLIToken\n\t\t\t\t\treturn sel, nil\n\t\t\t\t}\n\t\t\t\tif cfg.Providers.OpenAI.AuthMethod == \"oauth\" || cfg.Providers.OpenAI.AuthMethod == \"token\" {\n\t\t\t\t\tsel.providerType = providerTypeCodexAuth\n\t\t\t\t\treturn sel, nil\n\t\t\t\t}\n\t\t\t\tsel.apiKey = cfg.Providers.OpenAI.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.OpenAI.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.OpenAI.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.openai.com/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"anthropic\", \"claude\":\n\t\t\tif cfg.Providers.Anthropic.APIKey != \"\" || cfg.Providers.Anthropic.AuthMethod != \"\" {\n\t\t\t\tif cfg.Providers.Anthropic.AuthMethod == \"oauth\" || cfg.Providers.Anthropic.AuthMethod == \"token\" {\n\t\t\t\t\tsel.apiBase = cfg.Providers.Anthropic.APIBase\n\t\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\t\tsel.apiBase = defaultAnthropicAPIBase\n\t\t\t\t\t}\n\t\t\t\t\tsel.providerType = providerTypeClaudeAuth\n\t\t\t\t\treturn sel, nil\n\t\t\t\t}\n\t\t\t\tsel.apiKey = cfg.Providers.Anthropic.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Anthropic.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Anthropic.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = defaultAnthropicAPIBase\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"openrouter\":\n\t\t\tif cfg.Providers.OpenRouter.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.OpenRouter.APIKey\n\t\t\t\tsel.proxy = cfg.Providers.OpenRouter.Proxy\n\t\t\t\tif cfg.Providers.OpenRouter.APIBase != \"\" {\n\t\t\t\t\tsel.apiBase = cfg.Providers.OpenRouter.APIBase\n\t\t\t\t} else {\n\t\t\t\t\tsel.apiBase = \"https://openrouter.ai/api/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"litellm\":\n\t\t\tif cfg.Providers.LiteLLM.APIKey != \"\" || cfg.Providers.LiteLLM.APIBase != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.LiteLLM.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.LiteLLM.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.LiteLLM.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"http://localhost:4000/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"zhipu\", \"glm\":\n\t\t\tif cfg.Providers.Zhipu.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Zhipu.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Zhipu.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Zhipu.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://open.bigmodel.cn/api/paas/v4\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"gemini\", \"google\":\n\t\t\tif cfg.Providers.Gemini.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Gemini.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Gemini.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Gemini.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://generativelanguage.googleapis.com/v1beta\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"vllm\":\n\t\t\tif cfg.Providers.VLLM.APIBase != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.VLLM.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.VLLM.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.VLLM.Proxy\n\t\t\t}\n\t\tcase \"shengsuanyun\":\n\t\t\tif cfg.Providers.ShengSuanYun.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.ShengSuanYun.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.ShengSuanYun.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.ShengSuanYun.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://router.shengsuanyun.com/api/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"nvidia\":\n\t\t\tif cfg.Providers.Nvidia.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Nvidia.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Nvidia.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Nvidia.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://integrate.api.nvidia.com/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"vivgrid\":\n\t\t\tif cfg.Providers.Vivgrid.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Vivgrid.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Vivgrid.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Vivgrid.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.vivgrid.com/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"claude-cli\", \"claude-code\", \"claudecode\":\n\t\t\tworkspace := cfg.WorkspacePath()\n\t\t\tif workspace == \"\" {\n\t\t\t\tworkspace = \".\"\n\t\t\t}\n\t\t\tsel.providerType = providerTypeClaudeCLI\n\t\t\tsel.workspace = workspace\n\t\t\treturn sel, nil\n\t\tcase \"codex-cli\", \"codex-code\":\n\t\t\tworkspace := cfg.WorkspacePath()\n\t\t\tif workspace == \"\" {\n\t\t\t\tworkspace = \".\"\n\t\t\t}\n\t\t\tsel.providerType = providerTypeCodexCLI\n\t\t\tsel.workspace = workspace\n\t\t\treturn sel, nil\n\t\tcase \"deepseek\":\n\t\t\tif cfg.Providers.DeepSeek.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.DeepSeek.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.DeepSeek.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.DeepSeek.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.deepseek.com/v1\"\n\t\t\t\t}\n\t\t\t\tif model != \"deepseek-chat\" && model != \"deepseek-reasoner\" {\n\t\t\t\t\tsel.model = \"deepseek-chat\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"avian\":\n\t\t\tif cfg.Providers.Avian.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Avian.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Avian.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Avian.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.avian.io/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"mistral\":\n\t\t\tif cfg.Providers.Mistral.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Mistral.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Mistral.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Mistral.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.mistral.ai/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"minimax\":\n\t\t\tif cfg.Providers.Minimax.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.Minimax.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.Minimax.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.Minimax.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.minimaxi.com/v1\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"longcat\":\n\t\t\tif cfg.Providers.LongCat.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.LongCat.APIKey\n\t\t\t\tsel.apiBase = cfg.Providers.LongCat.APIBase\n\t\t\t\tsel.proxy = cfg.Providers.LongCat.Proxy\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = \"https://api.longcat.chat/openai\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"github_copilot\", \"copilot\":\n\t\t\tsel.providerType = providerTypeGitHubCopilot\n\t\t\tif cfg.Providers.GitHubCopilot.APIBase != \"\" {\n\t\t\t\tsel.apiBase = cfg.Providers.GitHubCopilot.APIBase\n\t\t\t} else {\n\t\t\t\tsel.apiBase = \"localhost:4321\"\n\t\t\t}\n\t\t\tsel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode\n\t\t\treturn sel, nil\n\t\t}\n\t}\n\n\t// Fallback: infer provider from model and configured keys.\n\tif sel.apiKey == \"\" && sel.apiBase == \"\" {\n\t\tswitch {\n\t\tcase (strings.Contains(lowerModel, \"kimi\") || strings.Contains(lowerModel, \"moonshot\") || strings.HasPrefix(model, \"moonshot/\")) && cfg.Providers.Moonshot.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Moonshot.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Moonshot.APIBase\n\t\t\tsel.proxy = cfg.Providers.Moonshot.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.moonshot.cn/v1\"\n\t\t\t}\n\t\tcase strings.HasPrefix(model, \"openrouter/\") ||\n\t\t\tstrings.HasPrefix(model, \"anthropic/\") ||\n\t\t\tstrings.HasPrefix(model, \"openai/\") ||\n\t\t\tstrings.HasPrefix(model, \"meta-llama/\") ||\n\t\t\tstrings.HasPrefix(model, \"deepseek/\") ||\n\t\t\tstrings.HasPrefix(model, \"google/\"):\n\t\t\tsel.apiKey = cfg.Providers.OpenRouter.APIKey\n\t\t\tsel.proxy = cfg.Providers.OpenRouter.Proxy\n\t\t\tif cfg.Providers.OpenRouter.APIBase != \"\" {\n\t\t\t\tsel.apiBase = cfg.Providers.OpenRouter.APIBase\n\t\t\t} else {\n\t\t\t\tsel.apiBase = \"https://openrouter.ai/api/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"claude\") || strings.HasPrefix(model, \"anthropic/\")) &&\n\t\t\t(cfg.Providers.Anthropic.APIKey != \"\" || cfg.Providers.Anthropic.AuthMethod != \"\"):\n\t\t\tif cfg.Providers.Anthropic.AuthMethod == \"oauth\" || cfg.Providers.Anthropic.AuthMethod == \"token\" {\n\t\t\t\tsel.apiBase = cfg.Providers.Anthropic.APIBase\n\t\t\t\tif sel.apiBase == \"\" {\n\t\t\t\t\tsel.apiBase = defaultAnthropicAPIBase\n\t\t\t\t}\n\t\t\t\tsel.providerType = providerTypeClaudeAuth\n\t\t\t\treturn sel, nil\n\t\t\t}\n\t\t\tsel.apiKey = cfg.Providers.Anthropic.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Anthropic.APIBase\n\t\t\tsel.proxy = cfg.Providers.Anthropic.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = defaultAnthropicAPIBase\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"gpt\") || strings.HasPrefix(model, \"openai/\")) &&\n\t\t\t(cfg.Providers.OpenAI.APIKey != \"\" || cfg.Providers.OpenAI.AuthMethod != \"\"):\n\t\t\tsel.enableWebSearch = cfg.Providers.OpenAI.WebSearch\n\t\t\tif cfg.Providers.OpenAI.AuthMethod == \"codex-cli\" {\n\t\t\t\tsel.providerType = providerTypeCodexCLIToken\n\t\t\t\treturn sel, nil\n\t\t\t}\n\t\t\tif cfg.Providers.OpenAI.AuthMethod == \"oauth\" || cfg.Providers.OpenAI.AuthMethod == \"token\" {\n\t\t\t\tsel.providerType = providerTypeCodexAuth\n\t\t\t\treturn sel, nil\n\t\t\t}\n\t\t\tsel.apiKey = cfg.Providers.OpenAI.APIKey\n\t\t\tsel.apiBase = cfg.Providers.OpenAI.APIBase\n\t\t\tsel.proxy = cfg.Providers.OpenAI.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.openai.com/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"gemini\") || strings.HasPrefix(model, \"google/\")) && cfg.Providers.Gemini.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Gemini.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Gemini.APIBase\n\t\t\tsel.proxy = cfg.Providers.Gemini.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://generativelanguage.googleapis.com/v1beta\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"glm\") || strings.Contains(lowerModel, \"zhipu\") || strings.Contains(lowerModel, \"zai\")) && cfg.Providers.Zhipu.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Zhipu.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Zhipu.APIBase\n\t\t\tsel.proxy = cfg.Providers.Zhipu.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://open.bigmodel.cn/api/paas/v4\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"groq\") || strings.HasPrefix(model, \"groq/\")) && cfg.Providers.Groq.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Groq.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Groq.APIBase\n\t\t\tsel.proxy = cfg.Providers.Groq.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.groq.com/openai/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"nvidia\") || strings.HasPrefix(model, \"nvidia/\")) && cfg.Providers.Nvidia.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Nvidia.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Nvidia.APIBase\n\t\t\tsel.proxy = cfg.Providers.Nvidia.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://integrate.api.nvidia.com/v1\"\n\t\t\t}\n\t\tcase strings.HasPrefix(model, \"vivgrid/\") && cfg.Providers.Vivgrid.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Vivgrid.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Vivgrid.APIBase\n\t\t\tsel.proxy = cfg.Providers.Vivgrid.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.vivgrid.com/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"ollama\") || strings.HasPrefix(model, \"ollama/\")) && cfg.Providers.Ollama.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Ollama.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Ollama.APIBase\n\t\t\tsel.proxy = cfg.Providers.Ollama.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"http://localhost:11434/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"mistral\") || strings.HasPrefix(model, \"mistral/\")) && cfg.Providers.Mistral.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Mistral.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Mistral.APIBase\n\t\t\tsel.proxy = cfg.Providers.Mistral.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.mistral.ai/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"minimax\") || strings.HasPrefix(model, \"minimax/\")) && cfg.Providers.Minimax.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Minimax.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Minimax.APIBase\n\t\t\tsel.proxy = cfg.Providers.Minimax.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.minimaxi.com/v1\"\n\t\t\t}\n\t\tcase strings.HasPrefix(model, \"avian/\") && cfg.Providers.Avian.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.Avian.APIKey\n\t\t\tsel.apiBase = cfg.Providers.Avian.APIBase\n\t\t\tsel.proxy = cfg.Providers.Avian.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.avian.io/v1\"\n\t\t\t}\n\t\tcase (strings.Contains(lowerModel, \"longcat\") || strings.HasPrefix(model, \"longcat/\")) && cfg.Providers.LongCat.APIKey != \"\":\n\t\t\tsel.apiKey = cfg.Providers.LongCat.APIKey\n\t\t\tsel.apiBase = cfg.Providers.LongCat.APIBase\n\t\t\tsel.proxy = cfg.Providers.LongCat.Proxy\n\t\t\tif sel.apiBase == \"\" {\n\t\t\t\tsel.apiBase = \"https://api.longcat.chat/openai\"\n\t\t\t}\n\t\tcase cfg.Providers.VLLM.APIBase != \"\":\n\t\t\tsel.apiKey = cfg.Providers.VLLM.APIKey\n\t\t\tsel.apiBase = cfg.Providers.VLLM.APIBase\n\t\t\tsel.proxy = cfg.Providers.VLLM.Proxy\n\t\tdefault:\n\t\t\tif cfg.Providers.OpenRouter.APIKey != \"\" {\n\t\t\t\tsel.apiKey = cfg.Providers.OpenRouter.APIKey\n\t\t\t\tsel.proxy = cfg.Providers.OpenRouter.Proxy\n\t\t\t\tif cfg.Providers.OpenRouter.APIBase != \"\" {\n\t\t\t\t\tsel.apiBase = cfg.Providers.OpenRouter.APIBase\n\t\t\t\t} else {\n\t\t\t\t\tsel.apiBase = \"https://openrouter.ai/api/v1\"\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn providerSelection{}, fmt.Errorf(\"no API key configured for model: %s\", model)\n\t\t\t}\n\t\t}\n\t}\n\n\tif sel.providerType == providerTypeHTTPCompat {\n\t\tif sel.apiKey == \"\" && !strings.HasPrefix(model, \"bedrock/\") {\n\t\t\treturn providerSelection{}, fmt.Errorf(\"no API key configured for provider (model: %s)\", model)\n\t\t}\n\t\tif sel.apiBase == \"\" {\n\t\t\treturn providerSelection{}, fmt.Errorf(\"no API base configured for provider (model: %s)\", model)\n\t\t}\n\t}\n\n\treturn sel, nil\n}\n"
  },
  {
    "path": "pkg/providers/factory_provider.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage providers\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\tanthropicmessages \"github.com/sipeed/picoclaw/pkg/providers/anthropic_messages\"\n\t\"github.com/sipeed/picoclaw/pkg/providers/azure\"\n)\n\n// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.\nfunc createClaudeAuthProvider() (LLMProvider, error) {\n\tcred, err := getCredential(\"anthropic\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading auth credentials: %w\", err)\n\t}\n\tif cred == nil {\n\t\treturn nil, fmt.Errorf(\"no credentials for anthropic. Run: picoclaw auth login --provider anthropic\")\n\t}\n\treturn NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil\n}\n\n// createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.\nfunc createCodexAuthProvider() (LLMProvider, error) {\n\tcred, err := getCredential(\"openai\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading auth credentials: %w\", err)\n\t}\n\tif cred == nil {\n\t\treturn nil, fmt.Errorf(\"no credentials for openai. Run: picoclaw auth login --provider openai\")\n\t}\n\treturn NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil\n}\n\n// ExtractProtocol extracts the protocol prefix and model identifier from a model string.\n// If no prefix is specified, it defaults to \"openai\".\n// Examples:\n//   - \"openai/gpt-4o\" -> (\"openai\", \"gpt-4o\")\n//   - \"anthropic/claude-sonnet-4.6\" -> (\"anthropic\", \"claude-sonnet-4.6\")\n//   - \"gpt-4o\" -> (\"openai\", \"gpt-4o\")  // default protocol\nfunc ExtractProtocol(model string) (protocol, modelID string) {\n\tmodel = strings.TrimSpace(model)\n\tprotocol, modelID, found := strings.Cut(model, \"/\")\n\tif !found {\n\t\treturn \"openai\", model\n\t}\n\treturn protocol, modelID\n}\n\n// CreateProviderFromConfig creates a provider based on the ModelConfig.\n// It uses the protocol prefix in the Model field to determine which provider to create.\n// Supported protocols: openai, litellm, novita, anthropic, anthropic-messages,\n// antigravity, claude-cli, codex-cli, github-copilot\n// Returns the provider, the model ID (without protocol prefix), and any error.\nfunc CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {\n\tif cfg == nil {\n\t\treturn nil, \"\", fmt.Errorf(\"config is nil\")\n\t}\n\n\tif cfg.Model == \"\" {\n\t\treturn nil, \"\", fmt.Errorf(\"model is required\")\n\t}\n\n\tprotocol, modelID := ExtractProtocol(cfg.Model)\n\n\tswitch protocol {\n\tcase \"openai\":\n\t\t// OpenAI with OAuth/token auth (Codex-style)\n\t\tif cfg.AuthMethod == \"oauth\" || cfg.AuthMethod == \"token\" {\n\t\t\tprovider, err := createCodexAuthProvider()\n\t\t\tif err != nil {\n\t\t\t\treturn nil, \"\", err\n\t\t\t}\n\t\t\treturn provider, modelID, nil\n\t\t}\n\t\t// OpenAI with API key\n\t\tif cfg.APIKey == \"\" && cfg.APIBase == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key or api_base is required for HTTP-based protocol %q\", protocol)\n\t\t}\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = getDefaultAPIBase(protocol)\n\t\t}\n\t\treturn NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tapiBase,\n\t\t\tcfg.Proxy,\n\t\t\tcfg.MaxTokensField,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"azure\", \"azure-openai\":\n\t\t// Azure OpenAI uses deployment-based URLs, api-key header auth,\n\t\t// and always sends max_completion_tokens.\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key is required for azure protocol\")\n\t\t}\n\t\tif cfg.APIBase == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\n\t\t\t\t\"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)\",\n\t\t\t)\n\t\t}\n\t\treturn azure.NewProviderWithTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tcfg.APIBase,\n\t\t\tcfg.Proxy,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"litellm\", \"openrouter\", \"groq\", \"zhipu\", \"gemini\", \"nvidia\",\n\t\t\"ollama\", \"moonshot\", \"shengsuanyun\", \"deepseek\", \"cerebras\",\n\t\t\"vivgrid\", \"volcengine\", \"vllm\", \"qwen\", \"qwen-intl\", \"qwen-international\", \"dashscope-intl\",\n\t\t\"qwen-us\", \"dashscope-us\", \"mistral\", \"avian\", \"minimax\", \"longcat\", \"modelscope\", \"novita\",\n\t\t\"coding-plan\", \"alibaba-coding\", \"qwen-coding\":\n\t\t// All other OpenAI-compatible HTTP providers\n\t\tif cfg.APIKey == \"\" && cfg.APIBase == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key or api_base is required for HTTP-based protocol %q\", protocol)\n\t\t}\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = getDefaultAPIBase(protocol)\n\t\t}\n\t\treturn NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tapiBase,\n\t\t\tcfg.Proxy,\n\t\t\tcfg.MaxTokensField,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"anthropic\":\n\t\tif cfg.AuthMethod == \"oauth\" || cfg.AuthMethod == \"token\" {\n\t\t\t// Use OAuth credentials from auth store\n\t\t\tprovider, err := createClaudeAuthProvider()\n\t\t\tif err != nil {\n\t\t\t\treturn nil, \"\", err\n\t\t\t}\n\t\t\treturn provider, modelID, nil\n\t\t}\n\t\t// Use API key with HTTP API\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = \"https://api.anthropic.com/v1\"\n\t\t}\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key is required for anthropic protocol (model: %s)\", cfg.Model)\n\t\t}\n\t\treturn NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tapiBase,\n\t\t\tcfg.Proxy,\n\t\t\tcfg.MaxTokensField,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"anthropic-messages\":\n\t\t// Anthropic Messages API with native format (HTTP-based, no SDK)\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = \"https://api.anthropic.com/v1\"\n\t\t}\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key is required for anthropic-messages protocol (model: %s)\", cfg.Model)\n\t\t}\n\t\treturn anthropicmessages.NewProviderWithTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tapiBase,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"coding-plan-anthropic\", \"alibaba-coding-anthropic\":\n\t\t// Alibaba Coding Plan with Anthropic-compatible API\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = getDefaultAPIBase(protocol)\n\t\t}\n\t\tif cfg.APIKey == \"\" {\n\t\t\treturn nil, \"\", fmt.Errorf(\"api_key is required for %q protocol (model: %s)\", protocol, cfg.Model)\n\t\t}\n\t\treturn anthropicmessages.NewProviderWithTimeout(\n\t\t\tcfg.APIKey,\n\t\t\tapiBase,\n\t\t\tcfg.RequestTimeout,\n\t\t), modelID, nil\n\n\tcase \"antigravity\":\n\t\treturn NewAntigravityProvider(), modelID, nil\n\n\tcase \"claude-cli\", \"claudecli\":\n\t\tworkspace := cfg.Workspace\n\t\tif workspace == \"\" {\n\t\t\tworkspace = \".\"\n\t\t}\n\t\treturn NewClaudeCliProvider(workspace), modelID, nil\n\n\tcase \"codex-cli\", \"codexcli\":\n\t\tworkspace := cfg.Workspace\n\t\tif workspace == \"\" {\n\t\t\tworkspace = \".\"\n\t\t}\n\t\treturn NewCodexCliProvider(workspace), modelID, nil\n\n\tcase \"github-copilot\", \"copilot\":\n\t\tapiBase := cfg.APIBase\n\t\tif apiBase == \"\" {\n\t\t\tapiBase = \"localhost:4321\"\n\t\t}\n\t\tconnectMode := cfg.ConnectMode\n\t\tif connectMode == \"\" {\n\t\t\tconnectMode = \"grpc\"\n\t\t}\n\t\tprovider, err := NewGitHubCopilotProvider(apiBase, connectMode, modelID)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", err\n\t\t}\n\t\treturn provider, modelID, nil\n\n\tdefault:\n\t\treturn nil, \"\", fmt.Errorf(\"unknown protocol %q in model %q\", protocol, cfg.Model)\n\t}\n}\n\n// getDefaultAPIBase returns the default API base URL for a given protocol.\nfunc getDefaultAPIBase(protocol string) string {\n\tswitch protocol {\n\tcase \"openai\":\n\t\treturn \"https://api.openai.com/v1\"\n\tcase \"openrouter\":\n\t\treturn \"https://openrouter.ai/api/v1\"\n\tcase \"litellm\":\n\t\treturn \"http://localhost:4000/v1\"\n\tcase \"novita\":\n\t\treturn \"https://api.novita.ai/openai\"\n\tcase \"groq\":\n\t\treturn \"https://api.groq.com/openai/v1\"\n\tcase \"zhipu\":\n\t\treturn \"https://open.bigmodel.cn/api/paas/v4\"\n\tcase \"gemini\":\n\t\treturn \"https://generativelanguage.googleapis.com/v1beta\"\n\tcase \"nvidia\":\n\t\treturn \"https://integrate.api.nvidia.com/v1\"\n\tcase \"ollama\":\n\t\treturn \"http://localhost:11434/v1\"\n\tcase \"moonshot\":\n\t\treturn \"https://api.moonshot.cn/v1\"\n\tcase \"shengsuanyun\":\n\t\treturn \"https://router.shengsuanyun.com/api/v1\"\n\tcase \"deepseek\":\n\t\treturn \"https://api.deepseek.com/v1\"\n\tcase \"cerebras\":\n\t\treturn \"https://api.cerebras.ai/v1\"\n\tcase \"vivgrid\":\n\t\treturn \"https://api.vivgrid.com/v1\"\n\tcase \"volcengine\":\n\t\treturn \"https://ark.cn-beijing.volces.com/api/v3\"\n\tcase \"qwen\":\n\t\treturn \"https://dashscope.aliyuncs.com/compatible-mode/v1\"\n\tcase \"qwen-intl\", \"qwen-international\", \"dashscope-intl\":\n\t\treturn \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\n\tcase \"qwen-us\", \"dashscope-us\":\n\t\treturn \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\"\n\tcase \"coding-plan\", \"alibaba-coding\", \"qwen-coding\":\n\t\treturn \"https://coding-intl.dashscope.aliyuncs.com/v1\"\n\tcase \"coding-plan-anthropic\", \"alibaba-coding-anthropic\":\n\t\treturn \"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic\"\n\tcase \"vllm\":\n\t\treturn \"http://localhost:8000/v1\"\n\tcase \"mistral\":\n\t\treturn \"https://api.mistral.ai/v1\"\n\tcase \"avian\":\n\t\treturn \"https://api.avian.io/v1\"\n\tcase \"minimax\":\n\t\treturn \"https://api.minimaxi.com/v1\"\n\tcase \"longcat\":\n\t\treturn \"https://api.longcat.chat/openai\"\n\tcase \"modelscope\":\n\t\treturn \"https://api-inference.modelscope.cn/v1\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/factory_provider_test.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage providers\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestExtractProtocol(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tmodel        string\n\t\twantProtocol string\n\t\twantModelID  string\n\t}{\n\t\t{\n\t\t\tname:         \"openai with prefix\",\n\t\t\tmodel:        \"openai/gpt-4o\",\n\t\t\twantProtocol: \"openai\",\n\t\t\twantModelID:  \"gpt-4o\",\n\t\t},\n\t\t{\n\t\t\tname:         \"anthropic with prefix\",\n\t\t\tmodel:        \"anthropic/claude-sonnet-4.6\",\n\t\t\twantProtocol: \"anthropic\",\n\t\t\twantModelID:  \"claude-sonnet-4.6\",\n\t\t},\n\t\t{\n\t\t\tname:         \"no prefix - defaults to openai\",\n\t\t\tmodel:        \"gpt-4o\",\n\t\t\twantProtocol: \"openai\",\n\t\t\twantModelID:  \"gpt-4o\",\n\t\t},\n\t\t{\n\t\t\tname:         \"groq with prefix\",\n\t\t\tmodel:        \"groq/llama-3.1-70b\",\n\t\t\twantProtocol: \"groq\",\n\t\t\twantModelID:  \"llama-3.1-70b\",\n\t\t},\n\t\t{\n\t\t\tname:         \"empty string\",\n\t\t\tmodel:        \"\",\n\t\t\twantProtocol: \"openai\",\n\t\t\twantModelID:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"with whitespace\",\n\t\t\tmodel:        \"  openai/gpt-4  \",\n\t\t\twantProtocol: \"openai\",\n\t\t\twantModelID:  \"gpt-4\",\n\t\t},\n\t\t{\n\t\t\tname:         \"multiple slashes\",\n\t\t\tmodel:        \"nvidia/meta/llama-3.1-8b\",\n\t\t\twantProtocol: \"nvidia\",\n\t\t\twantModelID:  \"meta/llama-3.1-8b\",\n\t\t},\n\t\t{\n\t\t\tname:         \"azure with prefix\",\n\t\t\tmodel:        \"azure/my-gpt5-deployment\",\n\t\t\twantProtocol: \"azure\",\n\t\t\twantModelID:  \"my-gpt5-deployment\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tprotocol, modelID := ExtractProtocol(tt.model)\n\t\t\tif protocol != tt.wantProtocol {\n\t\t\t\tt.Errorf(\"ExtractProtocol(%q) protocol = %q, want %q\", tt.model, protocol, tt.wantProtocol)\n\t\t\t}\n\t\t\tif modelID != tt.wantModelID {\n\t\t\t\tt.Errorf(\"ExtractProtocol(%q) modelID = %q, want %q\", tt.model, modelID, tt.wantModelID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateProviderFromConfig_OpenAI(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-openai\",\n\t\tModel:     \"openai/gpt-4o\",\n\t\tAPIKey:    \"test-key\",\n\t\tAPIBase:   \"https://api.example.com/v1\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"gpt-4o\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"gpt-4o\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tprotocol string\n\t}{\n\t\t{\"openai\", \"openai\"},\n\t\t{\"groq\", \"groq\"},\n\t\t{\"novita\", \"novita\"},\n\t\t{\"openrouter\", \"openrouter\"},\n\t\t{\"cerebras\", \"cerebras\"},\n\t\t{\"vivgrid\", \"vivgrid\"},\n\t\t{\"qwen\", \"qwen\"},\n\t\t{\"vllm\", \"vllm\"},\n\t\t{\"deepseek\", \"deepseek\"},\n\t\t{\"ollama\", \"ollama\"},\n\t\t{\"longcat\", \"longcat\"},\n\t\t{\"modelscope\", \"modelscope\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &config.ModelConfig{\n\t\t\t\tModelName: \"test-\" + tt.protocol,\n\t\t\t\tModel:     tt.protocol + \"/test-model\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t}\n\n\t\t\tprovider, _, err := CreateProviderFromConfig(cfg)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t\t\t}\n\n\t\t\t// Verify we got an HTTPProvider for all these protocols\n\t\t\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\t\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetDefaultAPIBase_LiteLLM(t *testing.T) {\n\tif got := getDefaultAPIBase(\"litellm\"); got != \"http://localhost:4000/v1\" {\n\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", \"litellm\", got, \"http://localhost:4000/v1\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_LiteLLM(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-litellm\",\n\t\tModel:     \"litellm/my-proxy-alias\",\n\t\tAPIKey:    \"test-key\",\n\t\tAPIBase:   \"http://localhost:4000/v1\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"my-proxy-alias\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"my-proxy-alias\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_LongCat(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-longcat\",\n\t\tModel:     \"longcat/LongCat-Flash-Thinking\",\n\t\tAPIKey:    \"test-key\",\n\t\tAPIBase:   \"https://api.longcat.chat/openai\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"LongCat-Flash-Thinking\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"LongCat-Flash-Thinking\")\n\t}\n\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t}\n}\n\nfunc TestCreateProviderFromConfig_ModelScope(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-modelscope\",\n\t\tModel:     \"modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507\",\n\t\tAPIKey:    \"test-key\",\n\t\tAPIBase:   \"https://api-inference.modelscope.cn/v1\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"Qwen/Qwen3-235B-A22B-Instruct-2507\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"Qwen/Qwen3-235B-A22B-Instruct-2507\")\n\t}\n\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t}\n}\n\nfunc TestGetDefaultAPIBase_ModelScope(t *testing.T) {\n\tif got := getDefaultAPIBase(\"modelscope\"); got != \"https://api-inference.modelscope.cn/v1\" {\n\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", \"modelscope\", got, \"https://api-inference.modelscope.cn/v1\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_Novita(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-novita\",\n\t\tModel:     \"novita/deepseek/deepseek-v3.2\",\n\t\tAPIKey:    \"test-key\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"deepseek/deepseek-v3.2\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"deepseek/deepseek-v3.2\")\n\t}\n\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t}\n}\n\nfunc TestGetDefaultAPIBase_Novita(t *testing.T) {\n\tif got := getDefaultAPIBase(\"novita\"); got != \"https://api.novita.ai/openai\" {\n\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", \"novita\", got, \"https://api.novita.ai/openai\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_Anthropic(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-anthropic\",\n\t\tModel:     \"anthropic/claude-sonnet-4.6\",\n\t\tAPIKey:    \"test-key\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"claude-sonnet-4.6\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"claude-sonnet-4.6\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_Antigravity(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-antigravity\",\n\t\tModel:     \"antigravity/gemini-2.0-flash\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"gemini-2.0-flash\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"gemini-2.0-flash\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-claude-cli\",\n\t\tModel:     \"claude-cli/claude-sonnet-4.6\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"claude-sonnet-4.6\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"claude-sonnet-4.6\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_CodexCLI(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-codex-cli\",\n\t\tModel:     \"codex-cli/codex\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"codex\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"codex\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-no-key\",\n\t\tModel:     \"openai/gpt-4o\",\n\t}\n\n\t_, _, err := CreateProviderFromConfig(cfg)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() expected error for missing API key\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-unknown\",\n\t\tModel:     \"unknown-protocol/model\",\n\t\tAPIKey:    \"test-key\",\n\t}\n\n\t_, _, err := CreateProviderFromConfig(cfg)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() expected error for unknown protocol\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_NilConfig(t *testing.T) {\n\t_, _, err := CreateProviderFromConfig(nil)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig(nil) expected error\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_EmptyModel(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"test-empty\",\n\t\tModel:     \"\",\n\t}\n\n\t_, _, err := CreateProviderFromConfig(cfg)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() expected error for empty model\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(1500 * time.Millisecond)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"choices\":[{\"message\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tcfg := &config.ModelConfig{\n\t\tModelName:      \"test-timeout\",\n\t\tModel:          \"openai/gpt-4o\",\n\t\tAPIBase:        server.URL,\n\t\tRequestTimeout: 1,\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif modelID != \"gpt-4o\" {\n\t\tt.Fatalf(\"modelID = %q, want %q\", modelID, \"gpt-4o\")\n\t}\n\n\t_, err = provider.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\tmodelID,\n\t\tnil,\n\t)\n\tif err == nil {\n\t\tt.Fatal(\"Chat() expected timeout error, got nil\")\n\t}\n\terrMsg := err.Error()\n\tif !strings.Contains(errMsg, \"context deadline exceeded\") && !strings.Contains(errMsg, \"Client.Timeout exceeded\") {\n\t\tt.Fatalf(\"Chat() error = %q, want timeout-related error\", errMsg)\n\t}\n}\n\nfunc TestCreateProviderFromConfig_Azure(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"azure-gpt5\",\n\t\tModel:     \"azure/my-gpt5-deployment\",\n\t\tAPIKey:    \"test-azure-key\",\n\t\tAPIBase:   \"https://my-resource.openai.azure.com\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"my-gpt5-deployment\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"my-gpt5-deployment\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"azure-gpt4\",\n\t\tModel:     \"azure-openai/my-deployment\",\n\t\tAPIKey:    \"test-azure-key\",\n\t\tAPIBase:   \"https://my-resource.openai.azure.com\",\n\t}\n\n\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t}\n\tif provider == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t}\n\tif modelID != \"my-deployment\" {\n\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"my-deployment\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"azure-gpt5\",\n\t\tModel:     \"azure/my-gpt5-deployment\",\n\t\tAPIBase:   \"https://my-resource.openai.azure.com\",\n\t}\n\n\t_, _, err := CreateProviderFromConfig(cfg)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() expected error for missing API key\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {\n\tcfg := &config.ModelConfig{\n\t\tModelName: \"azure-gpt5\",\n\t\tModel:     \"azure/my-gpt5-deployment\",\n\t\tAPIKey:    \"test-azure-key\",\n\t}\n\n\t_, _, err := CreateProviderFromConfig(cfg)\n\tif err == nil {\n\t\tt.Fatal(\"CreateProviderFromConfig() expected error for missing API base\")\n\t}\n}\n\nfunc TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tprotocol string\n\t}{\n\t\t{\"qwen-international\", \"qwen-international\"},\n\t\t{\"dashscope-intl\", \"dashscope-intl\"},\n\t\t{\"qwen-intl\", \"qwen-intl\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &config.ModelConfig{\n\t\t\t\tModelName: \"test-\" + tt.protocol,\n\t\t\t\tModel:     tt.protocol + \"/qwen-max\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t}\n\n\t\t\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t\t\t}\n\t\t\tif provider == nil {\n\t\t\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t\t\t}\n\t\t\tif modelID != \"qwen-max\" {\n\t\t\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"qwen-max\")\n\t\t\t}\n\t\t\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\t\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tprotocol string\n\t}{\n\t\t{\"qwen-us\", \"qwen-us\"},\n\t\t{\"dashscope-us\", \"dashscope-us\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &config.ModelConfig{\n\t\t\t\tModelName: \"test-\" + tt.protocol,\n\t\t\t\tModel:     tt.protocol + \"/qwen-max\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t}\n\n\t\t\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t\t\t}\n\t\t\tif provider == nil {\n\t\t\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t\t\t}\n\t\t\tif modelID != \"qwen-max\" {\n\t\t\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"qwen-max\")\n\t\t\t}\n\t\t\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\t\t\tt.Fatalf(\"expected *HTTPProvider, got %T\", provider)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tprotocol string\n\t}{\n\t\t{\"coding-plan-anthropic\", \"coding-plan-anthropic\"},\n\t\t{\"alibaba-coding-anthropic\", \"alibaba-coding-anthropic\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &config.ModelConfig{\n\t\t\t\tModelName: \"test-\" + tt.protocol,\n\t\t\t\tModel:     tt.protocol + \"/claude-sonnet-4-20250514\",\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t}\n\n\t\t\tprovider, modelID, err := CreateProviderFromConfig(cfg)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"CreateProviderFromConfig() error = %v\", err)\n\t\t\t}\n\t\t\tif provider == nil {\n\t\t\t\tt.Fatal(\"CreateProviderFromConfig() returned nil provider\")\n\t\t\t}\n\t\t\tif modelID != \"claude-sonnet-4-20250514\" {\n\t\t\t\tt.Errorf(\"modelID = %q, want %q\", modelID, \"claude-sonnet-4-20250514\")\n\t\t\t}\n\t\t\t// coding-plan-anthropic uses Anthropic Messages provider\n\t\t\t// Verify it's the anthropic messages provider by checking interface\n\t\t\tvar _ LLMProvider = provider\n\t\t})\n\t}\n}\n\nfunc TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) {\n\texpectedURL := \"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic\"\n\tif got := getDefaultAPIBase(\"coding-plan-anthropic\"); got != expectedURL {\n\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", \"coding-plan-anthropic\", got, expectedURL)\n\t}\n\tif got := getDefaultAPIBase(\"alibaba-coding-anthropic\"); got != expectedURL {\n\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", \"alibaba-coding-anthropic\", got, expectedURL)\n\t}\n}\n\nfunc TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) {\n\texpectedURL := \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\n\tfor _, protocol := range []string{\"qwen-intl\", \"qwen-international\", \"dashscope-intl\"} {\n\t\tif got := getDefaultAPIBase(protocol); got != expectedURL {\n\t\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", protocol, got, expectedURL)\n\t\t}\n\t}\n}\n\nfunc TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) {\n\texpectedURL := \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\"\n\tfor _, protocol := range []string{\"qwen-us\", \"dashscope-us\"} {\n\t\tif got := getDefaultAPIBase(protocol); got != expectedURL {\n\t\t\tt.Fatalf(\"getDefaultAPIBase(%q) = %q, want %q\", protocol, got, expectedURL)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/factory_test.go",
    "content": "package providers\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestResolveProviderSelection(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tsetup         func(*config.Config)\n\t\twantType      providerType\n\t\twantAPIBase   string\n\t\twantProxy     string\n\t\twantErrSubstr string\n\t}{\n\t\t{\n\t\t\tname: \"explicit litellm provider uses configured base\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"litellm\"\n\t\t\t\tcfg.Providers.LiteLLM.APIKey = \"litellm-key\"\n\t\t\t\tcfg.Providers.LiteLLM.APIBase = \"http://localhost:4000/v1\"\n\t\t\t\tcfg.Providers.LiteLLM.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"http://localhost:4000/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit litellm provider defaults base when only key is configured\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"litellm\"\n\t\t\t\tcfg.Providers.LiteLLM.APIKey = \"litellm-key\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"http://localhost:4000/v1\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit claude-cli provider routes to cli provider type\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"claude-cli\"\n\t\t\t\tcfg.Agents.Defaults.Workspace = \"/tmp/ws\"\n\t\t\t},\n\t\t\twantType: providerTypeClaudeCLI,\n\t\t},\n\t\t{\n\t\t\tname: \"explicit copilot provider routes to github copilot type\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"copilot\"\n\t\t\t},\n\t\t\twantType:    providerTypeGitHubCopilot,\n\t\t\twantAPIBase: \"localhost:4321\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit deepseek provider uses deepseek defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"deepseek\"\n\t\t\t\tcfg.Agents.Defaults.Model = \"deepseek/deepseek-chat\"\n\t\t\t\tcfg.Providers.DeepSeek.APIKey = \"deepseek-key\"\n\t\t\t\tcfg.Providers.DeepSeek.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.deepseek.com/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit shengsuanyun provider uses defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"shengsuanyun\"\n\t\t\t\tcfg.Providers.ShengSuanYun.APIKey = \"ssy-key\"\n\t\t\t\tcfg.Providers.ShengSuanYun.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://router.shengsuanyun.com/api/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit nvidia provider uses defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"nvidia\"\n\t\t\t\tcfg.Providers.Nvidia.APIKey = \"nvapi-test\"\n\t\t\t\tcfg.Providers.Nvidia.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://integrate.api.nvidia.com/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit vivgrid provider uses defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"vivgrid\"\n\t\t\t\tcfg.Providers.Vivgrid.APIKey = \"vivgrid-key\"\n\t\t\t\tcfg.Providers.Vivgrid.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.vivgrid.com/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"openrouter model uses openrouter defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"openrouter/auto\"\n\t\t\t\tcfg.Providers.OpenRouter.APIKey = \"sk-or-test\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://openrouter.ai/api/v1\",\n\t\t},\n\t\t{\n\t\t\tname: \"anthropic oauth routes to claude auth provider\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"claude-sonnet-4.6\"\n\t\t\t\tcfg.Providers.Anthropic.AuthMethod = \"oauth\"\n\t\t\t},\n\t\t\twantType: providerTypeClaudeAuth,\n\t\t},\n\t\t{\n\t\t\tname: \"openai oauth routes to codex auth provider\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"gpt-4o\"\n\t\t\t\tcfg.Providers.OpenAI.AuthMethod = \"oauth\"\n\t\t\t},\n\t\t\twantType: providerTypeCodexAuth,\n\t\t},\n\t\t{\n\t\t\tname: \"openai codex-cli auth routes to codex cli token provider\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"gpt-4o\"\n\t\t\t\tcfg.Providers.OpenAI.AuthMethod = \"codex-cli\"\n\t\t\t},\n\t\t\twantType: providerTypeCodexCLIToken,\n\t\t},\n\t\t{\n\t\t\tname: \"explicit codex-code provider routes to codex cli provider type\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"codex-code\"\n\t\t\t\tcfg.Agents.Defaults.Workspace = \"/tmp/ws\"\n\t\t\t},\n\t\t\twantType: providerTypeCodexCLI,\n\t\t},\n\t\t{\n\t\t\tname: \"zhipu model uses zhipu base default\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"glm-4.7\"\n\t\t\t\tcfg.Providers.Zhipu.APIKey = \"zhipu-key\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://open.bigmodel.cn/api/paas/v4\",\n\t\t},\n\t\t{\n\t\t\tname: \"groq model uses groq base default\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"groq/llama-3.3-70b\"\n\t\t\t\tcfg.Providers.Groq.APIKey = \"gsk-key\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.groq.com/openai/v1\",\n\t\t},\n\t\t{\n\t\t\tname: \"ollama model uses ollama base default\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"ollama/qwen2.5:14b\"\n\t\t\t\tcfg.Providers.Ollama.APIKey = \"ollama-key\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"http://localhost:11434/v1\",\n\t\t},\n\t\t{\n\t\t\tname: \"moonshot model keeps proxy and default base\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"moonshot/kimi-k2.5\"\n\t\t\t\tcfg.Providers.Moonshot.APIKey = \"moonshot-key\"\n\t\t\t\tcfg.Providers.Moonshot.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.moonshot.cn/v1\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"explicit longcat provider uses defaults\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Provider = \"longcat\"\n\t\t\t\tcfg.Providers.LongCat.APIKey = \"longcat-key\"\n\t\t\t\tcfg.Providers.LongCat.Proxy = \"http://127.0.0.1:7890\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.longcat.chat/openai\",\n\t\t\twantProxy:   \"http://127.0.0.1:7890\",\n\t\t},\n\t\t{\n\t\t\tname: \"longcat model fallback uses longcat base default\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"longcat/LongCat-Flash-Thinking\"\n\t\t\t\tcfg.Providers.LongCat.APIKey = \"longcat-key\"\n\t\t\t},\n\t\t\twantType:    providerTypeHTTPCompat,\n\t\t\twantAPIBase: \"https://api.longcat.chat/openai\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing keys returns model config error\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"custom-model\"\n\t\t\t},\n\t\t\twantErrSubstr: \"no API key configured for model\",\n\t\t},\n\t\t{\n\t\t\tname: \"openrouter prefix without key returns provider key error\",\n\t\t\tsetup: func(cfg *config.Config) {\n\t\t\t\tcfg.Agents.Defaults.Model = \"openrouter/auto\"\n\t\t\t},\n\t\t\twantErrSubstr: \"no API key configured for provider\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := config.DefaultConfig()\n\t\t\ttt.setup(cfg)\n\n\t\t\tgot, err := resolveProviderSelection(cfg)\n\t\t\tif tt.wantErrSubstr != \"\" {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"expected error containing %q, got nil\", tt.wantErrSubstr)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(err.Error(), tt.wantErrSubstr) {\n\t\t\t\t\tt.Fatalf(\"error = %q, want substring %q\", err.Error(), tt.wantErrSubstr)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"resolveProviderSelection() error = %v\", err)\n\t\t\t}\n\t\t\tif got.providerType != tt.wantType {\n\t\t\t\tt.Fatalf(\"providerType = %v, want %v\", got.providerType, tt.wantType)\n\t\t\t}\n\t\t\tif tt.wantAPIBase != \"\" && got.apiBase != tt.wantAPIBase {\n\t\t\t\tt.Fatalf(\"apiBase = %q, want %q\", got.apiBase, tt.wantAPIBase)\n\t\t\t}\n\t\t\tif tt.wantProxy != \"\" && got.proxy != tt.wantProxy {\n\t\t\t\tt.Fatalf(\"proxy = %q, want %q\", got.proxy, tt.wantProxy)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Model = \"test-openrouter\"\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName: \"test-openrouter\",\n\t\t\tModel:     \"openrouter/auto\",\n\t\t\tAPIKey:    \"sk-or-test\",\n\t\t\tAPIBase:   \"https://openrouter.ai/api/v1\",\n\t\t},\n\t}\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider() error = %v\", err)\n\t}\n\n\tif _, ok := provider.(*HTTPProvider); !ok {\n\t\tt.Fatalf(\"provider type = %T, want *HTTPProvider\", provider)\n\t}\n}\n\nfunc TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Model = \"test-codex\"\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName: \"test-codex\",\n\t\t\tModel:     \"codex-cli/codex-model\",\n\t\t\tWorkspace: \"/tmp/workspace\",\n\t\t},\n\t}\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider() error = %v\", err)\n\t}\n\n\tif _, ok := provider.(*CodexCliProvider); !ok {\n\t\tt.Fatalf(\"provider type = %T, want *CodexCliProvider\", provider)\n\t}\n}\n\nfunc TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Model = \"test-claude-cli\"\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName: \"test-claude-cli\",\n\t\t\tModel:     \"claude-cli/claude-sonnet\",\n\t\t\tWorkspace: \"/tmp/workspace\",\n\t\t},\n\t}\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider() error = %v\", err)\n\t}\n\n\tif _, ok := provider.(*ClaudeCliProvider); !ok {\n\t\tt.Fatalf(\"provider type = %T, want *ClaudeCliProvider\", provider)\n\t}\n}\n\nfunc TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {\n\toriginalGetCredential := getCredential\n\tt.Cleanup(func() { getCredential = originalGetCredential })\n\n\tgetCredential = func(provider string) (*auth.AuthCredential, error) {\n\t\tif provider != \"anthropic\" {\n\t\t\tt.Fatalf(\"provider = %q, want anthropic\", provider)\n\t\t}\n\t\treturn &auth.AuthCredential{\n\t\t\tAccessToken: \"anthropic-token\",\n\t\t}, nil\n\t}\n\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Model = \"test-claude-oauth\"\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName:  \"test-claude-oauth\",\n\t\t\tModel:      \"anthropic/claude-sonnet-4.6\",\n\t\t\tAuthMethod: \"oauth\",\n\t\t},\n\t}\n\n\tprovider, _, err := CreateProvider(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"CreateProvider() error = %v\", err)\n\t}\n\n\tif _, ok := provider.(*ClaudeProvider); !ok {\n\t\tt.Fatalf(\"provider type = %T, want *ClaudeProvider\", provider)\n\t}\n\t// TODO: Test custom APIBase when createClaudeAuthProvider supports it\n}\n\nfunc TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {\n\t// TODO: This test requires openai protocol to support auth_method: \"oauth\"\n\t// which is not yet implemented in the new factory_provider.go\n\tt.Skip(\"OpenAI OAuth via model_list not yet implemented\")\n}\n"
  },
  {
    "path": "pkg/providers/fallback.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\n// FallbackChain orchestrates model fallback across multiple candidates.\ntype FallbackChain struct {\n\tcooldown *CooldownTracker\n}\n\n// FallbackCandidate represents one model/provider to try.\ntype FallbackCandidate struct {\n\tProvider string\n\tModel    string\n}\n\n// FallbackResult contains the successful response and metadata about all attempts.\ntype FallbackResult struct {\n\tResponse *LLMResponse\n\tProvider string\n\tModel    string\n\tAttempts []FallbackAttempt\n}\n\n// FallbackAttempt records one attempt in the fallback chain.\ntype FallbackAttempt struct {\n\tProvider string\n\tModel    string\n\tError    error\n\tReason   FailoverReason\n\tDuration time.Duration\n\tSkipped  bool // true if skipped due to cooldown\n}\n\n// NewFallbackChain creates a new fallback chain with the given cooldown tracker.\nfunc NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {\n\treturn &FallbackChain{cooldown: cooldown}\n}\n\n// ResolveCandidates parses model config into a deduplicated candidate list.\nfunc ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {\n\treturn ResolveCandidatesWithLookup(cfg, defaultProvider, nil)\n}\n\nfunc ResolveCandidatesWithLookup(\n\tcfg ModelConfig,\n\tdefaultProvider string,\n\tlookup func(raw string) (resolved string, ok bool),\n) []FallbackCandidate {\n\tseen := make(map[string]bool)\n\tvar candidates []FallbackCandidate\n\n\taddCandidate := func(raw string) {\n\t\tcandidateRaw := strings.TrimSpace(raw)\n\t\tif lookup != nil {\n\t\t\tif resolved, ok := lookup(candidateRaw); ok {\n\t\t\t\tcandidateRaw = resolved\n\t\t\t}\n\t\t}\n\n\t\tref := ParseModelRef(candidateRaw, defaultProvider)\n\t\tif ref == nil {\n\t\t\treturn\n\t\t}\n\t\tkey := ModelKey(ref.Provider, ref.Model)\n\t\tif seen[key] {\n\t\t\treturn\n\t\t}\n\t\tseen[key] = true\n\t\tcandidates = append(candidates, FallbackCandidate{\n\t\t\tProvider: ref.Provider,\n\t\t\tModel:    ref.Model,\n\t\t})\n\t}\n\n\t// Primary first.\n\taddCandidate(cfg.Primary)\n\n\t// Then fallbacks.\n\tfor _, fb := range cfg.Fallbacks {\n\t\taddCandidate(fb)\n\t}\n\n\treturn candidates\n}\n\n// Execute runs the fallback chain for text/chat requests.\n// It tries each candidate in order, respecting cooldowns and error classification.\n//\n// Behavior:\n//   - Candidates in cooldown are skipped (logged as skipped attempt).\n//   - context.Canceled aborts immediately (user abort, no fallback).\n//   - Non-retriable errors (format) abort immediately.\n//   - Retriable errors trigger fallback to next candidate.\n//   - Success marks provider as good (resets cooldown).\n//   - If all fail, returns aggregate error with all attempts.\nfunc (fc *FallbackChain) Execute(\n\tctx context.Context,\n\tcandidates []FallbackCandidate,\n\trun func(ctx context.Context, provider, model string) (*LLMResponse, error),\n) (*FallbackResult, error) {\n\tif len(candidates) == 0 {\n\t\treturn nil, fmt.Errorf(\"fallback: no candidates configured\")\n\t}\n\n\tresult := &FallbackResult{\n\t\tAttempts: make([]FallbackAttempt, 0, len(candidates)),\n\t}\n\n\tfor i, candidate := range candidates {\n\t\t// Check context before each attempt.\n\t\tif ctx.Err() == context.Canceled {\n\t\t\treturn nil, context.Canceled\n\t\t}\n\n\t\t// Check cooldown (per provider/model, not just provider).\n\t\t// This allows multi-key failover where different keys use different model names.\n\t\tcooldownKey := ModelKey(candidate.Provider, candidate.Model)\n\t\tif !fc.cooldown.IsAvailable(cooldownKey) {\n\t\t\tremaining := fc.cooldown.CooldownRemaining(cooldownKey)\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tSkipped:  true,\n\t\t\t\tReason:   FailoverRateLimit,\n\t\t\t\tError: fmt.Errorf(\n\t\t\t\t\t\"%s in cooldown (%s remaining)\",\n\t\t\t\t\tcooldownKey,\n\t\t\t\t\tremaining.Round(time.Second),\n\t\t\t\t),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\t// Execute the run function.\n\t\tstart := time.Now()\n\t\tresp, err := run(ctx, candidate.Provider, candidate.Model)\n\t\telapsed := time.Since(start)\n\n\t\tif err == nil {\n\t\t\t// Success.\n\t\t\tfc.cooldown.MarkSuccess(cooldownKey)\n\t\t\tresult.Response = resp\n\t\t\tresult.Provider = candidate.Provider\n\t\t\tresult.Model = candidate.Model\n\t\t\treturn result, nil\n\t\t}\n\n\t\t// Context cancellation: abort immediately, no fallback.\n\t\tif ctx.Err() == context.Canceled {\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tError:    err,\n\t\t\t\tDuration: elapsed,\n\t\t\t})\n\t\t\treturn nil, context.Canceled\n\t\t}\n\n\t\t// Classify the error.\n\t\tfailErr := ClassifyError(err, candidate.Provider, candidate.Model)\n\n\t\tif failErr == nil {\n\t\t\t// Unclassifiable error: do not fallback, return immediately.\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tError:    err,\n\t\t\t\tDuration: elapsed,\n\t\t\t})\n\t\t\treturn nil, fmt.Errorf(\"fallback: unclassified error from %s/%s: %w\",\n\t\t\t\tcandidate.Provider, candidate.Model, err)\n\t\t}\n\n\t\t// Non-retriable error: abort immediately.\n\t\tif !failErr.IsRetriable() {\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tError:    failErr,\n\t\t\t\tReason:   failErr.Reason,\n\t\t\t\tDuration: elapsed,\n\t\t\t})\n\t\t\treturn nil, failErr\n\t\t}\n\n\t\t// Retriable error: mark failure and continue to next candidate.\n\t\tfc.cooldown.MarkFailure(cooldownKey, failErr.Reason)\n\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\tProvider: candidate.Provider,\n\t\t\tModel:    candidate.Model,\n\t\t\tError:    failErr,\n\t\t\tReason:   failErr.Reason,\n\t\t\tDuration: elapsed,\n\t\t})\n\n\t\t// If this was the last candidate, return aggregate error.\n\t\tif i == len(candidates)-1 {\n\t\t\treturn nil, &FallbackExhaustedError{Attempts: result.Attempts}\n\t\t}\n\t}\n\n\t// All candidates were skipped (all in cooldown).\n\treturn nil, &FallbackExhaustedError{Attempts: result.Attempts}\n}\n\n// ExecuteImage runs the fallback chain for image/vision requests.\n// Simpler than Execute: no cooldown checks (image endpoints have different rate limits).\n// Image dimension/size errors abort immediately (non-retriable).\nfunc (fc *FallbackChain) ExecuteImage(\n\tctx context.Context,\n\tcandidates []FallbackCandidate,\n\trun func(ctx context.Context, provider, model string) (*LLMResponse, error),\n) (*FallbackResult, error) {\n\tif len(candidates) == 0 {\n\t\treturn nil, fmt.Errorf(\"image fallback: no candidates configured\")\n\t}\n\n\tresult := &FallbackResult{\n\t\tAttempts: make([]FallbackAttempt, 0, len(candidates)),\n\t}\n\n\tfor i, candidate := range candidates {\n\t\tif ctx.Err() == context.Canceled {\n\t\t\treturn nil, context.Canceled\n\t\t}\n\n\t\tstart := time.Now()\n\t\tresp, err := run(ctx, candidate.Provider, candidate.Model)\n\t\telapsed := time.Since(start)\n\n\t\tif err == nil {\n\t\t\tresult.Response = resp\n\t\t\tresult.Provider = candidate.Provider\n\t\t\tresult.Model = candidate.Model\n\t\t\treturn result, nil\n\t\t}\n\n\t\tif ctx.Err() == context.Canceled {\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tError:    err,\n\t\t\t\tDuration: elapsed,\n\t\t\t})\n\t\t\treturn nil, context.Canceled\n\t\t}\n\n\t\t// Image dimension/size errors are non-retriable.\n\t\terrMsg := strings.ToLower(err.Error())\n\t\tif IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {\n\t\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tError:    err,\n\t\t\t\tReason:   FailoverFormat,\n\t\t\t\tDuration: elapsed,\n\t\t\t})\n\t\t\treturn nil, &FailoverError{\n\t\t\t\tReason:   FailoverFormat,\n\t\t\t\tProvider: candidate.Provider,\n\t\t\t\tModel:    candidate.Model,\n\t\t\t\tWrapped:  err,\n\t\t\t}\n\t\t}\n\n\t\t// Any other error: record and try next.\n\t\tresult.Attempts = append(result.Attempts, FallbackAttempt{\n\t\t\tProvider: candidate.Provider,\n\t\t\tModel:    candidate.Model,\n\t\t\tError:    err,\n\t\t\tDuration: elapsed,\n\t\t})\n\n\t\tif i == len(candidates)-1 {\n\t\t\treturn nil, &FallbackExhaustedError{Attempts: result.Attempts}\n\t\t}\n\t}\n\n\treturn nil, &FallbackExhaustedError{Attempts: result.Attempts}\n}\n\n// FallbackExhaustedError indicates all fallback candidates were tried and failed.\ntype FallbackExhaustedError struct {\n\tAttempts []FallbackAttempt\n}\n\nfunc (e *FallbackExhaustedError) Error() string {\n\tvar sb strings.Builder\n\tsb.WriteString(fmt.Sprintf(\"fallback: all %d candidates failed:\", len(e.Attempts)))\n\tfor i, a := range e.Attempts {\n\t\tif a.Skipped {\n\t\t\tsb.WriteString(fmt.Sprintf(\"\\n  [%d] %s/%s: skipped (cooldown)\", i+1, a.Provider, a.Model))\n\t\t} else {\n\t\t\tsb.WriteString(fmt.Sprintf(\"\\n  [%d] %s/%s: %v (reason=%s, %s)\",\n\t\t\t\ti+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond)))\n\t\t}\n\t}\n\treturn sb.String()\n}\n"
  },
  {
    "path": "pkg/providers/fallback_multikey_test.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n)\n\n// TestMultiKeyFailover tests the complete failover flow with multiple API keys.\n// This simulates the config expansion scenario where api_keys: [\"key1\", \"key2\", \"key3\"]\n// is expanded into primary + fallbacks.\nfunc TestMultiKeyFailover(t *testing.T) {\n\t// Simulate expanded config: primary with 2 fallbacks\n\t// This is what ExpandMultiKeyModels would produce for api_keys: [\"key1\", \"key2\", \"key3\"]\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\", \"glm-4.7__key_2\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\tif len(candidates) != 3 {\n\t\tt.Fatalf(\"expected 3 candidates, got %d: %v\", len(candidates), candidates)\n\t}\n\n\t// Create fallback chain\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Mock run function: first call fails with 429, second succeeds\n\tcallCount := 0\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\tif callCount == 1 {\n\t\t\t// First call: simulate rate limit\n\t\t\treturn nil, errors.New(\"http error: status 429 - rate limit exceeded\")\n\t\t}\n\t\t// Second call: success\n\t\treturn &LLMResponse{\n\t\t\tContent: \"Hello from key2!\",\n\t\t}, nil\n\t}\n\n\t// Execute fallback chain\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success after failover, got error: %v\", err)\n\t}\n\n\tif result == nil {\n\t\tt.Fatal(\"expected result, got nil\")\n\t}\n\n\tif result.Response.Content != \"Hello from key2!\" {\n\t\tt.Errorf(\"expected response from key2, got: %s\", result.Response.Content)\n\t}\n\n\tif callCount != 2 {\n\t\tt.Errorf(\"expected 2 calls (1 fail + 1 success), got %d\", callCount)\n\t}\n\n\t// Verify first attempt was recorded\n\tif len(result.Attempts) != 1 {\n\t\tt.Errorf(\"expected 1 failed attempt recorded, got %d\", len(result.Attempts))\n\t}\n\n\tif result.Attempts[0].Reason != FailoverRateLimit {\n\t\tt.Errorf(\n\t\t\t\"expected first attempt reason to be rate_limit, got: %s\",\n\t\t\tresult.Attempts[0].Reason,\n\t\t)\n\t}\n}\n\n// TestMultiKeyFailoverAllFail tests when all keys hit rate limit\nfunc TestMultiKeyFailoverAllFail(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\", \"glm-4.7__key_2\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Mock run function: all calls fail with rate limit\n\tcallCount := 0\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\treturn nil, errors.New(\"status: 429 - too many requests\")\n\t}\n\n\t// Execute fallback chain\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error when all keys fail, got nil\")\n\t}\n\n\tif result != nil {\n\t\tt.Errorf(\"expected nil result on failure, got: %v\", result)\n\t}\n\n\tif callCount != 3 {\n\t\tt.Errorf(\"expected 3 calls (all fail), got %d\", callCount)\n\t}\n\n\t// Verify error type\n\tvar exhausted *FallbackExhaustedError\n\tif !errors.As(err, &exhausted) {\n\t\tt.Errorf(\"expected FallbackExhaustedError, got: %T - %v\", err, err)\n\t}\n\n\tif len(exhausted.Attempts) != 3 {\n\t\tt.Errorf(\"expected 3 attempts in exhausted error, got %d\", len(exhausted.Attempts))\n\t}\n}\n\n// TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped\nfunc TestMultiKeyFailoverCooldown(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Put the first model in cooldown (using ModelKey now, not just provider)\n\tcooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model)\n\tcooldown.MarkFailure(cooldownKey, FailoverRateLimit)\n\n\t// Verify it's not available\n\tif cooldown.IsAvailable(cooldownKey) {\n\t\tt.Fatal(\"expected first model to be in cooldown\")\n\t}\n\n\t// Mock run function: only second should be called\n\tcallCount := 0\n\tcalledProviders := []string{}\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\tcalledProviders = append(calledProviders, provider+\"/\"+model)\n\t\treturn &LLMResponse{Content: \"success\"}, nil\n\t}\n\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success, got error: %v\", err)\n\t}\n\n\t// First provider should have been skipped\n\tif callCount != 1 {\n\t\tt.Errorf(\"expected 1 call (first skipped due to cooldown), got %d\", callCount)\n\t}\n\n\t// Should have called the second provider/model\n\tif len(calledProviders) != 1 ||\n\t\tcalledProviders[0] != candidates[1].Provider+\"/\"+candidates[1].Model {\n\t\tt.Errorf(\"expected second model to be called, got: %v\", calledProviders)\n\t}\n\n\t// Verify first attempt was recorded as skipped\n\tif len(result.Attempts) != 1 {\n\t\tt.Fatalf(\"expected 1 attempt (skipped), got %d\", len(result.Attempts))\n\t}\n\n\tif !result.Attempts[0].Skipped {\n\t\tt.Error(\"expected first attempt to be marked as skipped\")\n\t}\n}\n\n// TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable\nfunc TestMultiKeyFailoverWithFormatError(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Mock run function: first call fails with format error (bad request)\n\tcallCount := 0\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\treturn nil, errors.New(\"invalid request format: tool_use.id missing\")\n\t}\n\n\t// Execute fallback chain\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error for format failure, got nil\")\n\t}\n\n\t// Format errors should NOT trigger failover (non-retriable)\n\t// So we should only have 1 call\n\tif callCount != 1 {\n\t\tt.Errorf(\"expected 1 call (format error is non-retriable), got %d\", callCount)\n\t}\n\n\t// Verify the error is a FailoverError with format reason\n\tvar failoverErr *FailoverError\n\tif !errors.As(err, &failoverErr) {\n\t\tt.Errorf(\"expected FailoverError, got: %T - %v\", err, err)\n\t}\n\n\tif failoverErr.Reason != FailoverFormat {\n\t\tt.Errorf(\"expected FailoverFormat reason, got: %s\", failoverErr.Reason)\n\t}\n\n\t_ = result // result should be nil\n}\n\n// TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback.\n// This simulates the scenario: api_keys: [\"k1\", \"k2\"] + fallbacks: [\"minimax\"]\n// Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax\nfunc TestMultiKeyWithModelFallback(t *testing.T) {\n\t// Simulate expanded config from:\n\t// { \"model_name\": \"glm-4.7\", \"api_keys\": [\"k1\", \"k2\"], \"fallbacks\": [\"minimax\"] }\n\t// After ExpandMultiKeyModels, primaryEntry.Fallbacks = [\"glm-4.7__key_1\", \"minimax\"]\n\t// Note: In production, \"minimax\" would be resolved via model lookup to \"minimax/minimax\"\n\t// In this test, we use the full format to avoid needing a lookup function.\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\", \"minimax/minimax\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\t// Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax)\n\tif len(candidates) != 3 {\n\t\tt.Fatalf(\"expected 3 candidates, got %d: %v\", len(candidates), candidates)\n\t}\n\n\t// Verify candidate order\n\tif candidates[0].Model != \"glm-4.7\" || candidates[0].Provider != \"zhipu\" {\n\t\tt.Errorf(\n\t\t\t\"expected first candidate to be zhipu/glm-4.7, got: %s/%s\",\n\t\t\tcandidates[0].Provider,\n\t\t\tcandidates[0].Model,\n\t\t)\n\t}\n\tif candidates[1].Model != \"glm-4.7__key_1\" || candidates[1].Provider != \"zhipu\" {\n\t\tt.Errorf(\n\t\t\t\"expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s\",\n\t\t\tcandidates[1].Provider,\n\t\t\tcandidates[1].Model,\n\t\t)\n\t}\n\tif candidates[2].Model != \"minimax\" || candidates[2].Provider != \"minimax\" {\n\t\tt.Errorf(\n\t\t\t\"expected third candidate to be minimax/minimax, got: %s/%s\",\n\t\t\tcandidates[2].Provider,\n\t\t\tcandidates[2].Model,\n\t\t)\n\t}\n\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Mock run function: first two fail, third succeeds (model fallback)\n\tcallCount := 0\n\tcalledModels := []string{}\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\tcalledModels = append(calledModels, provider+\"/\"+model)\n\n\t\tswitch callCount {\n\t\tcase 1:\n\t\t\t// k1: rate limit\n\t\t\treturn nil, errors.New(\"status: 429 - rate limit\")\n\t\tcase 2:\n\t\t\t// k2: also rate limit (all zhipu keys exhausted)\n\t\t\treturn nil, errors.New(\"status: 429 - rate limit\")\n\t\tcase 3:\n\t\t\t// minimax: success\n\t\t\treturn &LLMResponse{Content: \"success from minimax\"}, nil\n\t\tdefault:\n\t\t\treturn nil, errors.New(\"unexpected call\")\n\t\t}\n\t}\n\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success after failover to model fallback, got error: %v\", err)\n\t}\n\n\tif callCount != 3 {\n\t\tt.Errorf(\"expected 3 calls (k1 fail + k2 fail + minimax success), got %d\", callCount)\n\t}\n\n\tif result.Response.Content != \"success from minimax\" {\n\t\tt.Errorf(\"expected response from minimax, got: %s\", result.Response.Content)\n\t}\n\n\t// Verify call order\n\tif len(calledModels) != 3 {\n\t\tt.Fatalf(\"expected 3 called models, got %d\", len(calledModels))\n\t}\n\tif calledModels[0] != \"zhipu/glm-4.7\" {\n\t\tt.Errorf(\"expected first call to zhipu/glm-4.7, got: %s\", calledModels[0])\n\t}\n\tif calledModels[1] != \"zhipu/glm-4.7__key_1\" {\n\t\tt.Errorf(\"expected second call to zhipu/glm-4.7__key_1, got: %s\", calledModels[1])\n\t}\n\tif calledModels[2] != \"minimax/minimax\" {\n\t\tt.Errorf(\"expected third call to minimax/minimax, got: %s\", calledModels[2])\n\t}\n\n\t// Verify 2 failed attempts recorded\n\tif len(result.Attempts) != 2 {\n\t\tt.Errorf(\"expected 2 failed attempts, got %d\", len(result.Attempts))\n\t}\n\n\t// Both should be rate limit\n\tfor i, attempt := range result.Attempts {\n\t\tif attempt.Reason != FailoverRateLimit {\n\t\t\tt.Errorf(\"expected attempt %d to be rate_limit, got: %s\", i, attempt.Reason)\n\t\t}\n\t}\n}\n\n// TestMultiKeyFailoverMixedErrors tests failover with different error types\nfunc TestMultiKeyFailoverMixedErrors(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-4.7\",\n\t\tFallbacks: []string{\"glm-4.7__key_1\", \"glm-4.7__key_2\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"zhipu\")\n\n\tcooldown := NewCooldownTracker()\n\tchain := NewFallbackChain(cooldown)\n\n\t// Mock run function: different errors for each key\n\tcallCount := 0\n\tmockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tcallCount++\n\t\tswitch callCount {\n\t\tcase 1:\n\t\t\t// First: rate limit (retriable)\n\t\t\treturn nil, errors.New(\"status: 429 - rate limit\")\n\t\tcase 2:\n\t\t\t// Second: timeout (retriable)\n\t\t\treturn nil, errors.New(\"context deadline exceeded\")\n\t\tcase 3:\n\t\t\t// Third: success\n\t\t\treturn &LLMResponse{Content: \"success from key3\"}, nil\n\t\tdefault:\n\t\t\treturn nil, errors.New(\"unexpected call\")\n\t\t}\n\t}\n\n\tresult, err := chain.Execute(context.Background(), candidates, mockRun)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success after 2 failovers, got error: %v\", err)\n\t}\n\n\tif callCount != 3 {\n\t\tt.Errorf(\"expected 3 calls, got %d\", callCount)\n\t}\n\n\t// Verify both failed attempts were recorded\n\tif len(result.Attempts) != 2 {\n\t\tt.Errorf(\"expected 2 failed attempts, got %d\", len(result.Attempts))\n\t}\n\n\t// First should be rate limit\n\tif result.Attempts[0].Reason != FailoverRateLimit {\n\t\tt.Errorf(\"expected first attempt to be rate_limit, got: %s\", result.Attempts[0].Reason)\n\t}\n\n\t// Second should be timeout\n\tif result.Attempts[1].Reason != FailoverTimeout {\n\t\tt.Errorf(\"expected second attempt to be timeout, got: %s\", result.Attempts[1].Reason)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/fallback_test.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc makeCandidate(provider, model string) FallbackCandidate {\n\treturn FallbackCandidate{Provider: provider, Model: model}\n}\n\nfunc successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\treturn func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\treturn &LLMResponse{Content: content, FinishReason: \"stop\"}, nil\n\t}\n}\n\nfunc TestFallback_SingleCandidate_Success(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{makeCandidate(\"openai\", \"gpt-4\")}\n\tresult, err := fc.Execute(context.Background(), candidates, successRun(\"hello\"))\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Response.Content != \"hello\" {\n\t\tt.Errorf(\"content = %q, want hello\", result.Response.Content)\n\t}\n\tif result.Provider != \"openai\" || result.Model != \"gpt-4\" {\n\t\tt.Errorf(\"provider/model = %s/%s, want openai/gpt-4\", result.Provider, result.Model)\n\t}\n}\n\nfunc TestFallback_SecondCandidateSuccess(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude-opus\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\tif attempt == 1 {\n\t\t\treturn nil, errors.New(\"rate limit exceeded\")\n\t\t}\n\t\treturn &LLMResponse{Content: \"from claude\", FinishReason: \"stop\"}, nil\n\t}\n\n\tresult, err := fc.Execute(context.Background(), candidates, run)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Provider != \"anthropic\" {\n\t\tt.Errorf(\"provider = %q, want anthropic\", result.Provider)\n\t}\n\tif result.Response.Content != \"from claude\" {\n\t\tt.Errorf(\"content = %q, want 'from claude'\", result.Response.Content)\n\t}\n\tif len(result.Attempts) != 1 {\n\t\tt.Errorf(\"attempts = %d, want 1 (failed attempt recorded)\", len(result.Attempts))\n\t}\n}\n\nfunc TestFallback_AllFail(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t\tmakeCandidate(\"groq\", \"llama\"),\n\t}\n\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\treturn nil, errors.New(\"rate limit exceeded\")\n\t}\n\n\t_, err := fc.Execute(context.Background(), candidates, run)\n\tif err == nil {\n\t\tt.Fatal(\"expected error when all candidates fail\")\n\t}\n\tvar exhausted *FallbackExhaustedError\n\tif !errors.As(err, &exhausted) {\n\t\tt.Errorf(\"expected FallbackExhaustedError, got %T: %v\", err, err)\n\t}\n\tif len(exhausted.Attempts) != 3 {\n\t\tt.Errorf(\"attempts = %d, want 3\", len(exhausted.Attempts))\n\t}\n}\n\nfunc TestFallback_ContextCanceled(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\tif attempt == 1 {\n\t\t\tcancel() // cancel context\n\t\t\treturn nil, context.Canceled\n\t\t}\n\t\tt.Error(\"should not reach second candidate after cancel\")\n\t\treturn nil, nil\n\t}\n\n\t_, err := fc.Execute(ctx, candidates, run)\n\tif err != context.Canceled {\n\t\tt.Errorf(\"expected context.Canceled, got %v\", err)\n\t}\n}\n\nfunc TestFallback_NonRetriableError(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\treturn nil, errors.New(\"string should match pattern\")\n\t}\n\n\t_, err := fc.Execute(context.Background(), candidates, run)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for non-retriable\")\n\t}\n\tvar fe *FailoverError\n\tif !errors.As(err, &fe) {\n\t\tt.Fatalf(\"expected FailoverError, got %T\", err)\n\t}\n\tif fe.Reason != FailoverFormat {\n\t\tt.Errorf(\"reason = %q, want format\", fe.Reason)\n\t}\n\tif attempt != 1 {\n\t\tt.Errorf(\"attempt = %d, want 1 (non-retriable should not try next)\", attempt)\n\t}\n}\n\nfunc TestFallback_CooldownSkip(t *testing.T) {\n\tnow := time.Now()\n\tct, _ := newTestTracker(now)\n\tfc := NewFallbackChain(ct)\n\n\t// Put openai/gpt-4 in cooldown (using ModelKey now)\n\tct.MarkFailure(ModelKey(\"openai\", \"gpt-4\"), FailoverRateLimit)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tif provider == \"openai\" {\n\t\t\tt.Error(\"should not call openai (in cooldown)\")\n\t\t}\n\t\treturn &LLMResponse{Content: \"claude response\", FinishReason: \"stop\"}, nil\n\t}\n\n\tresult, err := fc.Execute(context.Background(), candidates, run)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Provider != \"anthropic\" {\n\t\tt.Errorf(\"provider = %q, want anthropic\", result.Provider)\n\t}\n\t// Should have 1 skipped attempt\n\tskipped := 0\n\tfor _, a := range result.Attempts {\n\t\tif a.Skipped {\n\t\t\tskipped++\n\t\t}\n\t}\n\tif skipped != 1 {\n\t\tt.Errorf(\"skipped = %d, want 1\", skipped)\n\t}\n}\n\nfunc TestFallback_AllInCooldown(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\t// Put all models in cooldown (using ModelKey now)\n\tct.MarkFailure(ModelKey(\"openai\", \"gpt-4\"), FailoverRateLimit)\n\tct.MarkFailure(ModelKey(\"anthropic\", \"claude\"), FailoverBilling)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\t_, err := fc.Execute(context.Background(), candidates,\n\t\tfunc(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\t\tt.Error(\"should not call any provider (all in cooldown)\")\n\t\t\treturn nil, nil\n\t\t})\n\n\tif err == nil {\n\t\tt.Fatal(\"expected error when all in cooldown\")\n\t}\n\tvar exhausted *FallbackExhaustedError\n\tif !errors.As(err, &exhausted) {\n\t\tt.Fatalf(\"expected FallbackExhaustedError, got %T\", err)\n\t}\n}\n\nfunc TestFallback_NoCandidates(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\t_, err := fc.Execute(context.Background(), nil, successRun(\"ok\"))\n\tif err == nil {\n\t\tt.Error(\"expected error for empty candidates\")\n\t}\n}\n\nfunc TestFallback_EmptyFallbacks(t *testing.T) {\n\t// Single primary, no fallbacks: should work like direct call\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{makeCandidate(\"openai\", \"gpt-4\")}\n\tresult, err := fc.Execute(context.Background(), candidates, successRun(\"ok\"))\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Response.Content != \"ok\" {\n\t\tt.Error(\"expected success with single candidate\")\n\t}\n}\n\nfunc TestFallback_UnclassifiedError(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\treturn nil, errors.New(\"completely unknown internal error\")\n\t}\n\n\t_, err := fc.Execute(context.Background(), candidates, run)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for unclassified error\")\n\t}\n\tif attempt != 1 {\n\t\tt.Errorf(\"attempt = %d, want 1 (should not fallback on unclassified)\", attempt)\n\t}\n}\n\nfunc TestFallback_SuccessResetsCooldown(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{makeCandidate(\"openai\", \"gpt-4\")}\n\tmodelKey := ModelKey(\"openai\", \"gpt-4\")\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\tif attempt == 1 {\n\t\t\tct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere\n\t\t}\n\t\treturn &LLMResponse{Content: \"ok\", FinishReason: \"stop\"}, nil\n\t}\n\n\t_, err := fc.Execute(context.Background(), candidates, run)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif !ct.IsAvailable(modelKey) {\n\t\tt.Error(\"success should reset cooldown\")\n\t}\n}\n\n// --- Image Fallback Tests ---\n\nfunc TestImageFallback_Success(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{makeCandidate(\"openai\", \"gpt-4o\")}\n\tresult, err := fc.ExecuteImage(context.Background(), candidates, successRun(\"image result\"))\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Response.Content != \"image result\" {\n\t\tt.Error(\"expected image result\")\n\t}\n}\n\nfunc TestImageFallback_DimensionError(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4o\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\treturn nil, errors.New(\"image dimensions exceed max 4096x4096\")\n\t}\n\n\t_, err := fc.ExecuteImage(context.Background(), candidates, run)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for image dimension error\")\n\t}\n\tif attempt != 1 {\n\t\tt.Errorf(\"attempt = %d, want 1 (image dimension error should not retry)\", attempt)\n\t}\n}\n\nfunc TestImageFallback_SizeError(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4o\"),\n\t\tmakeCandidate(\"anthropic\", \"claude\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\treturn nil, errors.New(\"image exceeds 20 mb\")\n\t}\n\n\t_, err := fc.ExecuteImage(context.Background(), candidates, run)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for image size error\")\n\t}\n\tif attempt != 1 {\n\t\tt.Errorf(\"attempt = %d, want 1 (image size error should not retry)\", attempt)\n\t}\n}\n\nfunc TestImageFallback_RetryOnOtherErrors(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\tcandidates := []FallbackCandidate{\n\t\tmakeCandidate(\"openai\", \"gpt-4o\"),\n\t\tmakeCandidate(\"anthropic\", \"claude-sonnet\"),\n\t}\n\n\tattempt := 0\n\trun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {\n\t\tattempt++\n\t\tif attempt == 1 {\n\t\t\treturn nil, errors.New(\"rate limit exceeded\")\n\t\t}\n\t\treturn &LLMResponse{Content: \"image ok\", FinishReason: \"stop\"}, nil\n\t}\n\n\tresult, err := fc.ExecuteImage(context.Background(), candidates, run)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result.Provider != \"anthropic\" {\n\t\tt.Errorf(\"provider = %q, want anthropic\", result.Provider)\n\t}\n}\n\nfunc TestImageFallback_NoCandidates(t *testing.T) {\n\tct := NewCooldownTracker()\n\tfc := NewFallbackChain(ct)\n\n\t_, err := fc.ExecuteImage(context.Background(), nil, successRun(\"ok\"))\n\tif err == nil {\n\t\tt.Error(\"expected error for empty candidates\")\n\t}\n}\n\n// --- ResolveCandidates Tests ---\n\nfunc TestResolveCandidates_Simple(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"gpt-4\",\n\t\tFallbacks: []string{\"anthropic/claude-opus\", \"groq/llama-3\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"openai\")\n\tif len(candidates) != 3 {\n\t\tt.Fatalf(\"candidates = %d, want 3\", len(candidates))\n\t}\n\n\tif candidates[0].Provider != \"openai\" || candidates[0].Model != \"gpt-4\" {\n\t\tt.Errorf(\"candidate[0] = %s/%s, want openai/gpt-4\", candidates[0].Provider, candidates[0].Model)\n\t}\n\tif candidates[1].Provider != \"anthropic\" || candidates[1].Model != \"claude-opus\" {\n\t\tt.Errorf(\"candidate[1] = %s/%s, want anthropic/claude-opus\", candidates[1].Provider, candidates[1].Model)\n\t}\n\tif candidates[2].Provider != \"groq\" || candidates[2].Model != \"llama-3\" {\n\t\tt.Errorf(\"candidate[2] = %s/%s, want groq/llama-3\", candidates[2].Provider, candidates[2].Model)\n\t}\n}\n\nfunc TestResolveCandidates_Deduplication(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"openai/gpt-4\",\n\t\tFallbacks: []string{\"openai/gpt-4\", \"anthropic/claude\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"default\")\n\tif len(candidates) != 2 {\n\t\tt.Errorf(\"candidates = %d, want 2 (duplicate removed)\", len(candidates))\n\t}\n}\n\nfunc TestResolveCandidates_EmptyFallbacks(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"gpt-4\",\n\t\tFallbacks: nil,\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"openai\")\n\tif len(candidates) != 1 {\n\t\tt.Errorf(\"candidates = %d, want 1\", len(candidates))\n\t}\n}\n\nfunc TestResolveCandidates_EmptyPrimary(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"\",\n\t\tFallbacks: []string{\"anthropic/claude\"},\n\t}\n\n\tcandidates := ResolveCandidates(cfg, \"openai\")\n\tif len(candidates) != 1 {\n\t\tt.Errorf(\"candidates = %d, want 1\", len(candidates))\n\t}\n}\n\nfunc TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"step-3.5-flash\",\n\t\tFallbacks: nil,\n\t}\n\n\tlookup := func(raw string) (string, bool) {\n\t\tif raw == \"step-3.5-flash\" {\n\t\t\treturn \"openrouter/stepfun/step-3.5-flash:free\", true\n\t\t}\n\t\treturn \"\", false\n\t}\n\n\tcandidates := ResolveCandidatesWithLookup(cfg, \"\", lookup)\n\tif len(candidates) != 1 {\n\t\tt.Fatalf(\"candidates = %d, want 1\", len(candidates))\n\t}\n\tif candidates[0].Provider != \"openrouter\" {\n\t\tt.Fatalf(\"provider = %q, want openrouter\", candidates[0].Provider)\n\t}\n\tif candidates[0].Model != \"stepfun/step-3.5-flash:free\" {\n\t\tt.Fatalf(\"model = %q, want stepfun/step-3.5-flash:free\", candidates[0].Model)\n\t}\n}\n\nfunc TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"step-3.5-flash\",\n\t\tFallbacks: []string{\"openrouter/stepfun/step-3.5-flash:free\"},\n\t}\n\n\tlookup := func(raw string) (string, bool) {\n\t\tif raw == \"step-3.5-flash\" {\n\t\t\treturn \"openrouter/stepfun/step-3.5-flash:free\", true\n\t\t}\n\t\treturn \"\", false\n\t}\n\n\tcandidates := ResolveCandidatesWithLookup(cfg, \"\", lookup)\n\tif len(candidates) != 1 {\n\t\tt.Fatalf(\"candidates = %d, want 1\", len(candidates))\n\t}\n}\n\nfunc TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {\n\tcfg := ModelConfig{\n\t\tPrimary:   \"glm-5\",\n\t\tFallbacks: nil,\n\t}\n\n\tlookup := func(raw string) (string, bool) {\n\t\tif raw == \"glm-5\" {\n\t\t\treturn \"glm-5\", true\n\t\t}\n\t\treturn \"\", false\n\t}\n\n\tcandidates := ResolveCandidatesWithLookup(cfg, \"openai\", lookup)\n\tif len(candidates) != 1 {\n\t\tt.Fatalf(\"candidates = %d, want 1\", len(candidates))\n\t}\n\tif candidates[0].Provider != \"openai\" {\n\t\tt.Fatalf(\"provider = %q, want openai\", candidates[0].Provider)\n\t}\n\tif candidates[0].Model != \"glm-5\" {\n\t\tt.Fatalf(\"model = %q, want glm-5\", candidates[0].Model)\n\t}\n}\n\nfunc TestFallbackExhaustedError_Message(t *testing.T) {\n\te := &FallbackExhaustedError{\n\t\tAttempts: []FallbackAttempt{\n\t\t\t{\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tModel:    \"gpt-4\",\n\t\t\t\tError:    errors.New(\"rate limited\"),\n\t\t\t\tReason:   FailoverRateLimit,\n\t\t\t\tDuration: 500 * time.Millisecond,\n\t\t\t},\n\t\t\t{Provider: \"anthropic\", Model: \"claude\", Skipped: true},\n\t\t},\n\t}\n\tmsg := e.Error()\n\tif msg == \"\" {\n\t\tt.Error(\"expected non-empty error message\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/github_copilot_provider.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sync\"\n\n\tcopilot \"github.com/github/copilot-sdk/go\"\n)\n\ntype GitHubCopilotProvider struct {\n\turi         string\n\tconnectMode string // \"stdio\" or \"grpc\"\n\n\tclient  *copilot.Client\n\tsession *copilot.Session\n\n\tmu sync.Mutex\n}\n\nfunc NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {\n\tif connectMode == \"\" {\n\t\tconnectMode = \"grpc\"\n\t}\n\n\tswitch connectMode {\n\tcase \"stdio\":\n\t\t// TODO: Implement stdio mode for GitHub Copilot provider\n\t\t// See https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md for details\n\t\treturn nil, fmt.Errorf(\"stdio mode not implemented for GitHub Copilot provider; please use 'grpc' mode instead\")\n\tcase \"grpc\":\n\t\tclient := copilot.NewClient(&copilot.ClientOptions{\n\t\t\tCLIUrl: uri,\n\t\t})\n\t\tif err := client.Start(context.Background()); err != nil {\n\t\t\treturn nil, fmt.Errorf(\n\t\t\t\t\"can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\n\t\tsession, err := client.CreateSession(context.Background(), &copilot.SessionConfig{\n\t\t\tModel: model,\n\t\t\tHooks: &copilot.SessionHooks{},\n\t\t})\n\t\tif err != nil {\n\t\t\tclient.Stop()\n\t\t\treturn nil, fmt.Errorf(\"create session failed: %w\", err)\n\t\t}\n\n\t\treturn &GitHubCopilotProvider{\n\t\t\turi:         uri,\n\t\t\tconnectMode: connectMode,\n\t\t\tclient:      client,\n\t\t\tsession:     session,\n\t\t}, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown connect mode: %s\", connectMode)\n\t}\n}\n\nfunc (p *GitHubCopilotProvider) Close() {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tif p.client != nil {\n\t\tp.client.Stop()\n\t\tp.client = nil\n\t\tp.session = nil\n\t}\n}\n\nfunc (p *GitHubCopilotProvider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\ttype tempMessage struct {\n\t\tRole    string `json:\"role\"`\n\t\tContent string `json:\"content\"`\n\t}\n\tout := make([]tempMessage, 0, len(messages))\n\tfor _, msg := range messages {\n\t\tout = append(out, tempMessage{\n\t\t\tRole:    msg.Role,\n\t\t\tContent: msg.Content,\n\t\t})\n\t}\n\n\tfullcontent, err := json.Marshal(out)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal messages: %w\", err)\n\t}\n\tp.mu.Lock()\n\tsession := p.session\n\tp.mu.Unlock()\n\n\tif session == nil {\n\t\treturn nil, fmt.Errorf(\"provider closed\")\n\t}\n\n\tresp, err := session.SendAndWait(ctx, copilot.MessageOptions{\n\t\tPrompt: string(fullcontent),\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send message to copilot: %w\", err)\n\t}\n\n\tif resp == nil {\n\t\treturn nil, fmt.Errorf(\"empty response from copilot\")\n\t}\n\tif resp.Data.Content == nil {\n\t\treturn nil, fmt.Errorf(\"no content in copilot response\")\n\t}\n\tcontent := *resp.Data.Content\n\n\treturn &LLMResponse{\n\t\tFinishReason: \"stop\",\n\t\tContent:      content,\n\t}, nil\n}\n\nfunc (p *GitHubCopilotProvider) GetDefaultModel() string {\n\treturn \"gpt-4.1\"\n}\n"
  },
  {
    "path": "pkg/providers/http_provider.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage providers\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/openai_compat\"\n)\n\ntype HTTPProvider struct {\n\tdelegate *openai_compat.Provider\n}\n\nfunc NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {\n\treturn &HTTPProvider{\n\t\tdelegate: openai_compat.NewProvider(apiKey, apiBase, proxy),\n\t}\n}\n\nfunc NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {\n\treturn NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)\n}\n\nfunc NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(\n\tapiKey, apiBase, proxy, maxTokensField string,\n\trequestTimeoutSeconds int,\n) *HTTPProvider {\n\treturn &HTTPProvider{\n\t\tdelegate: openai_compat.NewProvider(\n\t\t\tapiKey,\n\t\t\tapiBase,\n\t\t\tproxy,\n\t\t\topenai_compat.WithMaxTokensField(maxTokensField),\n\t\t\topenai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),\n\t\t),\n\t}\n}\n\nfunc (p *HTTPProvider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\treturn p.delegate.Chat(ctx, messages, tools, model, options)\n}\n\nfunc (p *HTTPProvider) GetDefaultModel() string {\n\treturn \"\"\n}\n\nfunc (p *HTTPProvider) SupportsNativeSearch() bool {\n\treturn p.delegate.SupportsNativeSearch()\n}\n"
  },
  {
    "path": "pkg/providers/legacy_provider.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage providers\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// CreateProvider creates a provider based on the configuration.\n// It uses the model_list configuration (new format) to create providers.\n// The old providers config is automatically converted to model_list during config loading.\n// Returns the provider, the model ID to use, and any error.\nfunc CreateProvider(cfg *config.Config) (LLMProvider, string, error) {\n\tmodel := cfg.Agents.Defaults.GetModelName()\n\n\t// Ensure model_list is populated from providers config if needed\n\t// This handles two cases:\n\t// 1. ModelList is empty - convert all providers\n\t// 2. ModelList has some entries but not all providers - merge missing ones\n\tif cfg.HasProvidersConfig() {\n\t\tproviderModels := config.ConvertProvidersToModelList(cfg)\n\t\texistingModelNames := make(map[string]bool)\n\t\tfor _, m := range cfg.ModelList {\n\t\t\texistingModelNames[m.ModelName] = true\n\t\t}\n\t\tfor _, pm := range providerModels {\n\t\t\tif !existingModelNames[pm.ModelName] {\n\t\t\t\tcfg.ModelList = append(cfg.ModelList, pm)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Must have model_list at this point\n\tif len(cfg.ModelList) == 0 {\n\t\treturn nil, \"\", fmt.Errorf(\"no providers configured. Please add entries to model_list in your config\")\n\t}\n\n\t// Get model config from model_list\n\tmodelCfg, err := cfg.GetModelConfig(model)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"model %q not found in model_list: %w\", model, err)\n\t}\n\n\t// Inject global workspace if not set in model config\n\tif modelCfg.Workspace == \"\" {\n\t\tmodelCfg.Workspace = cfg.WorkspacePath()\n\t}\n\n\t// Use factory to create provider\n\tprovider, modelID, err := CreateProviderFromConfig(modelCfg)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"failed to create provider for model %q: %w\", model, err)\n\t}\n\n\treturn provider, modelID, nil\n}\n"
  },
  {
    "path": "pkg/providers/model_ref.go",
    "content": "package providers\n\nimport \"strings\"\n\n// ModelRef represents a parsed model reference with provider and model name.\ntype ModelRef struct {\n\tProvider string\n\tModel    string\n}\n\n// ParseModelRef parses \"anthropic/claude-opus\" into {Provider: \"anthropic\", Model: \"claude-opus\"}.\n// If no slash present, uses defaultProvider.\n// Returns nil for empty input.\nfunc ParseModelRef(raw string, defaultProvider string) *ModelRef {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn nil\n\t}\n\n\tif idx := strings.Index(raw, \"/\"); idx > 0 {\n\t\tprovider := NormalizeProvider(raw[:idx])\n\t\tmodel := strings.TrimSpace(raw[idx+1:])\n\t\tif model == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn &ModelRef{Provider: provider, Model: model}\n\t}\n\n\treturn &ModelRef{\n\t\tProvider: NormalizeProvider(defaultProvider),\n\t\tModel:    raw,\n\t}\n}\n\n// NormalizeProvider normalizes provider identifiers to canonical form.\nfunc NormalizeProvider(provider string) string {\n\tp := strings.ToLower(strings.TrimSpace(provider))\n\n\tswitch p {\n\tcase \"z.ai\", \"z-ai\":\n\t\treturn \"zai\"\n\tcase \"opencode-zen\":\n\t\treturn \"opencode\"\n\tcase \"qwen\":\n\t\treturn \"qwen-portal\"\n\tcase \"kimi-code\":\n\t\treturn \"kimi-coding\"\n\tcase \"gpt\":\n\t\treturn \"openai\"\n\tcase \"claude\":\n\t\treturn \"anthropic\"\n\tcase \"glm\":\n\t\treturn \"zhipu\"\n\tcase \"google\":\n\t\treturn \"gemini\"\n\tcase \"alibaba-coding\", \"qwen-coding\":\n\t\treturn \"coding-plan\"\n\tcase \"alibaba-coding-anthropic\":\n\t\treturn \"coding-plan-anthropic\"\n\tcase \"qwen-international\", \"dashscope-intl\":\n\t\treturn \"qwen-intl\"\n\tcase \"dashscope-us\":\n\t\treturn \"qwen-us\"\n\t}\n\n\treturn p\n}\n\n// ModelKey returns a canonical \"provider/model\" key for deduplication.\nfunc ModelKey(provider, model string) string {\n\treturn NormalizeProvider(provider) + \"/\" + strings.ToLower(strings.TrimSpace(model))\n}\n"
  },
  {
    "path": "pkg/providers/model_ref_test.go",
    "content": "package providers\n\nimport \"testing\"\n\nfunc TestParseModelRef_WithSlash(t *testing.T) {\n\tref := ParseModelRef(\"anthropic/claude-opus\", \"openai\")\n\tif ref == nil {\n\t\tt.Fatal(\"expected non-nil ref\")\n\t}\n\tif ref.Provider != \"anthropic\" {\n\t\tt.Errorf(\"provider = %q, want anthropic\", ref.Provider)\n\t}\n\tif ref.Model != \"claude-opus\" {\n\t\tt.Errorf(\"model = %q, want claude-opus\", ref.Model)\n\t}\n}\n\nfunc TestParseModelRef_WithoutSlash(t *testing.T) {\n\tref := ParseModelRef(\"gpt-4\", \"openai\")\n\tif ref == nil {\n\t\tt.Fatal(\"expected non-nil ref\")\n\t}\n\tif ref.Provider != \"openai\" {\n\t\tt.Errorf(\"provider = %q, want openai\", ref.Provider)\n\t}\n\tif ref.Model != \"gpt-4\" {\n\t\tt.Errorf(\"model = %q, want gpt-4\", ref.Model)\n\t}\n}\n\nfunc TestParseModelRef_Empty(t *testing.T) {\n\tref := ParseModelRef(\"\", \"openai\")\n\tif ref != nil {\n\t\tt.Errorf(\"expected nil for empty string, got %+v\", ref)\n\t}\n}\n\nfunc TestParseModelRef_EmptyModelAfterSlash(t *testing.T) {\n\tref := ParseModelRef(\"openai/\", \"default\")\n\tif ref != nil {\n\t\tt.Errorf(\"expected nil for empty model, got %+v\", ref)\n\t}\n}\n\nfunc TestParseModelRef_WhitespaceHandling(t *testing.T) {\n\tref := ParseModelRef(\"  anthropic / claude-opus  \", \"openai\")\n\tif ref == nil {\n\t\tt.Fatal(\"expected non-nil ref\")\n\t}\n\tif ref.Provider != \"anthropic\" {\n\t\tt.Errorf(\"provider = %q, want anthropic\", ref.Provider)\n\t}\n\tif ref.Model != \"claude-opus\" {\n\t\tt.Errorf(\"model = %q, want claude-opus\", ref.Model)\n\t}\n}\n\nfunc TestNormalizeProvider(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  string\n\t}{\n\t\t{\"OpenAI\", \"openai\"},\n\t\t{\"ANTHROPIC\", \"anthropic\"},\n\t\t{\"z.ai\", \"zai\"},\n\t\t{\"z-ai\", \"zai\"},\n\t\t{\"Z.AI\", \"zai\"},\n\t\t{\"opencode-zen\", \"opencode\"},\n\t\t{\"qwen\", \"qwen-portal\"},\n\t\t{\"kimi-code\", \"kimi-coding\"},\n\t\t{\"gpt\", \"openai\"},\n\t\t{\"claude\", \"anthropic\"},\n\t\t{\"glm\", \"zhipu\"},\n\t\t{\"google\", \"gemini\"},\n\t\t{\"groq\", \"groq\"},\n\t\t// Alibaba Coding Plan aliases\n\t\t{\"alibaba-coding\", \"coding-plan\"},\n\t\t{\"qwen-coding\", \"coding-plan\"},\n\t\t{\"alibaba-coding-anthropic\", \"coding-plan-anthropic\"},\n\t\t// Qwen international aliases\n\t\t{\"qwen-international\", \"qwen-intl\"},\n\t\t{\"dashscope-intl\", \"qwen-intl\"},\n\t\t{\"dashscope-us\", \"qwen-us\"},\n\t\t{\"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := NormalizeProvider(tt.input)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"NormalizeProvider(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestModelKey(t *testing.T) {\n\ttests := []struct {\n\t\tprovider string\n\t\tmodel    string\n\t\twant     string\n\t}{\n\t\t{\"openai\", \"gpt-4\", \"openai/gpt-4\"},\n\t\t{\"Anthropic\", \"Claude-Opus\", \"anthropic/claude-opus\"},\n\t\t{\"claude\", \"sonnet\", \"anthropic/sonnet\"},\n\t\t{\"z.ai\", \"Model-X\", \"zai/model-x\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := ModelKey(tt.provider, tt.model)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"ModelKey(%q, %q) = %q, want %q\", tt.provider, tt.model, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestParseModelRef_ProviderNormalization(t *testing.T) {\n\tref := ParseModelRef(\"Z.AI/model-x\", \"default\")\n\tif ref == nil {\n\t\tt.Fatal(\"expected non-nil ref\")\n\t}\n\tif ref.Provider != \"zai\" {\n\t\tt.Errorf(\"provider = %q, want zai\", ref.Provider)\n\t}\n}\n\nfunc TestParseModelRef_DefaultProviderNormalization(t *testing.T) {\n\tref := ParseModelRef(\"gpt-4o\", \"GPT\")\n\tif ref == nil {\n\t\tt.Fatal(\"expected non-nil ref\")\n\t}\n\tif ref.Provider != \"openai\" {\n\t\tt.Errorf(\"provider = %q, want openai (normalized from GPT)\", ref.Provider)\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/openai_compat/provider.go",
    "content": "package openai_compat\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/common\"\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\ntype (\n\tToolCall               = protocoltypes.ToolCall\n\tFunctionCall           = protocoltypes.FunctionCall\n\tLLMResponse            = protocoltypes.LLMResponse\n\tUsageInfo              = protocoltypes.UsageInfo\n\tMessage                = protocoltypes.Message\n\tToolDefinition         = protocoltypes.ToolDefinition\n\tToolFunctionDefinition = protocoltypes.ToolFunctionDefinition\n\tExtraContent           = protocoltypes.ExtraContent\n\tGoogleExtra            = protocoltypes.GoogleExtra\n\tReasoningDetail        = protocoltypes.ReasoningDetail\n)\n\ntype Provider struct {\n\tapiKey         string\n\tapiBase        string\n\tmaxTokensField string // Field name for max tokens (e.g., \"max_completion_tokens\" for o1/glm models)\n\thttpClient     *http.Client\n}\n\ntype Option func(*Provider)\n\nconst defaultRequestTimeout = common.DefaultRequestTimeout\n\nfunc WithMaxTokensField(maxTokensField string) Option {\n\treturn func(p *Provider) {\n\t\tp.maxTokensField = maxTokensField\n\t}\n}\n\nfunc WithRequestTimeout(timeout time.Duration) Option {\n\treturn func(p *Provider) {\n\t\tif timeout > 0 {\n\t\t\tp.httpClient.Timeout = timeout\n\t\t}\n\t}\n}\n\nfunc NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {\n\tp := &Provider{\n\t\tapiKey:     apiKey,\n\t\tapiBase:    strings.TrimRight(apiBase, \"/\"),\n\t\thttpClient: common.NewHTTPClient(proxy),\n\t}\n\n\tfor _, opt := range opts {\n\t\tif opt != nil {\n\t\t\topt(p)\n\t\t}\n\t}\n\n\treturn p\n}\n\nfunc NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {\n\treturn NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))\n}\n\nfunc NewProviderWithMaxTokensFieldAndTimeout(\n\tapiKey, apiBase, proxy, maxTokensField string,\n\trequestTimeoutSeconds int,\n) *Provider {\n\treturn NewProvider(\n\t\tapiKey,\n\t\tapiBase,\n\t\tproxy,\n\t\tWithMaxTokensField(maxTokensField),\n\t\tWithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),\n\t)\n}\n\nfunc (p *Provider) Chat(\n\tctx context.Context,\n\tmessages []Message,\n\ttools []ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*LLMResponse, error) {\n\tif p.apiBase == \"\" {\n\t\treturn nil, fmt.Errorf(\"API base not configured\")\n\t}\n\n\tmodel = normalizeModel(model, p.apiBase)\n\n\trequestBody := map[string]any{\n\t\t\"model\":    model,\n\t\t\"messages\": common.SerializeMessages(messages),\n\t}\n\n\t// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.\n\tnativeSearch, _ := options[\"native_search\"].(bool)\n\tnativeSearch = nativeSearch && isNativeSearchHost(p.apiBase)\n\tif len(tools) > 0 || nativeSearch {\n\t\trequestBody[\"tools\"] = buildToolsList(tools, nativeSearch)\n\t\trequestBody[\"tool_choice\"] = \"auto\"\n\t}\n\n\tif maxTokens, ok := common.AsInt(options[\"max_tokens\"]); ok {\n\t\t// Use configured maxTokensField if specified, otherwise fallback to model-based detection\n\t\tfieldName := p.maxTokensField\n\t\tif fieldName == \"\" {\n\t\t\t// Fallback: detect from model name for backward compatibility\n\t\t\tlowerModel := strings.ToLower(model)\n\t\t\tif strings.Contains(lowerModel, \"glm\") || strings.Contains(lowerModel, \"o1\") ||\n\t\t\t\tstrings.Contains(lowerModel, \"gpt-5\") {\n\t\t\t\tfieldName = \"max_completion_tokens\"\n\t\t\t} else {\n\t\t\t\tfieldName = \"max_tokens\"\n\t\t\t}\n\t\t}\n\t\trequestBody[fieldName] = maxTokens\n\t}\n\n\tif temperature, ok := common.AsFloat(options[\"temperature\"]); ok {\n\t\tlowerModel := strings.ToLower(model)\n\t\t// Kimi k2 models only support temperature=1.\n\t\tif strings.Contains(lowerModel, \"kimi\") && strings.Contains(lowerModel, \"k2\") {\n\t\t\trequestBody[\"temperature\"] = 1.0\n\t\t} else {\n\t\t\trequestBody[\"temperature\"] = temperature\n\t\t}\n\t}\n\n\t// Prompt caching: pass a stable cache key so OpenAI can bucket requests\n\t// with the same key and reuse prefix KV cache across calls.\n\t// The key is typically the agent ID — stable per agent, shared across requests.\n\t// See: https://platform.openai.com/docs/guides/prompt-caching\n\t// Prompt caching is only supported by OpenAI-native endpoints.\n\t// Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown\n\t// fields with 422 errors, so only include it for OpenAI APIs.\n\tif cacheKey, ok := options[\"prompt_cache_key\"].(string); ok && cacheKey != \"\" {\n\t\tif supportsPromptCacheKey(p.apiBase) {\n\t\t\trequestBody[\"prompt_cache_key\"] = cacheKey\n\t\t}\n\t}\n\n\tjsonData, err := json.Marshal(requestBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to marshal request: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", p.apiBase+\"/chat/completions\", bytes.NewReader(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tif p.apiKey != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+p.apiKey)\n\t}\n\n\tresp, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, common.HandleErrorResponse(resp, p.apiBase)\n\t}\n\n\treturn common.ReadAndParseResponse(resp, p.apiBase)\n}\n\nfunc normalizeModel(model, apiBase string) string {\n\tbefore, after, ok := strings.Cut(model, \"/\")\n\tif !ok {\n\t\treturn model\n\t}\n\n\tif strings.Contains(strings.ToLower(apiBase), \"openrouter.ai\") {\n\t\treturn model\n\t}\n\n\tprefix := strings.ToLower(before)\n\tswitch prefix {\n\tcase \"litellm\", \"moonshot\", \"nvidia\", \"groq\", \"ollama\", \"deepseek\", \"google\",\n\t\t\"openrouter\", \"zhipu\", \"mistral\", \"vivgrid\", \"minimax\", \"novita\":\n\t\treturn after\n\tdefault:\n\t\treturn model\n\t}\n}\n\nfunc buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {\n\tresult := make([]any, 0, len(tools)+1)\n\tfor _, t := range tools {\n\t\tif nativeSearch && strings.EqualFold(t.Function.Name, \"web_search\") {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, t)\n\t}\n\tif nativeSearch {\n\t\tresult = append(result, map[string]any{\"type\": \"web_search_preview\"})\n\t}\n\treturn result\n}\n\nfunc (p *Provider) SupportsNativeSearch() bool {\n\treturn isNativeSearchHost(p.apiBase)\n}\n\nfunc isNativeSearchHost(apiBase string) bool {\n\tu, err := url.Parse(apiBase)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := u.Hostname()\n\treturn host == \"api.openai.com\" || strings.HasSuffix(host, \".openai.azure.com\")\n}\n\n// supportsPromptCacheKey reports whether the given API base is known to\n// support the prompt_cache_key request field. Currently only OpenAI's own\n// API and Azure OpenAI support this. All other OpenAI-compatible providers\n// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.\nfunc supportsPromptCacheKey(apiBase string) bool {\n\tu, err := url.Parse(apiBase)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := u.Hostname()\n\treturn host == \"api.openai.com\" || strings.HasSuffix(host, \".openai.azure.com\")\n}\n"
  },
  {
    "path": "pkg/providers/openai_compat/provider_test.go",
    "content": "package openai_compat\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/common\"\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\nfunc TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/chat/completions\" {\n\t\t\thttp.Error(w, \"not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"glm-4.7\",\n\t\tmap[string]any{\"max_tokens\": 1234},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif _, ok := requestBody[\"max_completion_tokens\"]; !ok {\n\t\tt.Fatalf(\"expected max_completion_tokens in request body\")\n\t}\n\tif _, ok := requestBody[\"max_tokens\"]; ok {\n\t\tt.Fatalf(\"did not expect max_tokens key for glm model\")\n\t}\n}\n\nfunc TestProviderChat_ParsesToolCalls(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\": map[string]any{\n\t\t\t\t\t\t\"content\": \"\",\n\t\t\t\t\t\t\"tool_calls\": []map[string]any{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"id\":   \"call_1\",\n\t\t\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\t\t\"function\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"name\":      \"get_weather\",\n\t\t\t\t\t\t\t\t\t\"arguments\": \"{\\\"city\\\":\\\"SF\\\"}\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"finish_reason\": \"tool_calls\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"usage\": map[string]any{\n\t\t\t\t\"prompt_tokens\":     10,\n\t\t\t\t\"completion_tokens\": 5,\n\t\t\t\t\"total_tokens\":      15,\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\tout, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n\tif out.ToolCalls[0].Name != \"get_weather\" {\n\t\tt.Fatalf(\"ToolCalls[0].Name = %q, want %q\", out.ToolCalls[0].Name, \"get_weather\")\n\t}\n\tif out.ToolCalls[0].Arguments[\"city\"] != \"SF\" {\n\t\tt.Fatalf(\"ToolCalls[0].Arguments[city] = %v, want SF\", out.ToolCalls[0].Arguments[\"city\"])\n\t}\n}\n\nfunc TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\": map[string]any{\n\t\t\t\t\t\t\"content\": \"\",\n\t\t\t\t\t\t\"tool_calls\": []map[string]any{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"id\":   \"call_1\",\n\t\t\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\t\t\"function\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\t\t\"arguments\": map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"city\":   \"SF\",\n\t\t\t\t\t\t\t\t\t\t\"metric\": true,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"finish_reason\": \"tool_calls\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\tout, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n\tif out.ToolCalls[0].Name != \"get_weather\" {\n\t\tt.Fatalf(\"ToolCalls[0].Name = %q, want %q\", out.ToolCalls[0].Name, \"get_weather\")\n\t}\n\tif out.ToolCalls[0].Arguments[\"city\"] != \"SF\" {\n\t\tt.Fatalf(\"ToolCalls[0].Arguments[city] = %v, want SF\", out.ToolCalls[0].Arguments[\"city\"])\n\t}\n\tif out.ToolCalls[0].Arguments[\"metric\"] != true {\n\t\tt.Fatalf(\"ToolCalls[0].Arguments[metric] = %v, want true\", out.ToolCalls[0].Arguments[\"metric\"])\n\t}\n}\n\nfunc TestProviderChat_ParsesReasoningContent(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\": map[string]any{\n\t\t\t\t\t\t\"content\":           \"The answer is 2\",\n\t\t\t\t\t\t\"reasoning_content\": \"Let me think step by step... 1+1=2\",\n\t\t\t\t\t\t\"tool_calls\": []map[string]any{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"id\":   \"call_1\",\n\t\t\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\t\t\"function\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"name\":      \"calculator\",\n\t\t\t\t\t\t\t\t\t\"arguments\": \"{\\\"expr\\\":\\\"1+1\\\"}\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"finish_reason\": \"tool_calls\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\tout, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"1+1=?\"}}, nil, \"kimi-k2.5\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif out.ReasoningContent != \"Let me think step by step... 1+1=2\" {\n\t\tt.Fatalf(\"ReasoningContent = %q, want %q\", out.ReasoningContent, \"Let me think step by step... 1+1=2\")\n\t}\n\tif out.Content != \"The answer is 2\" {\n\t\tt.Fatalf(\"Content = %q, want %q\", out.Content, \"The answer is 2\")\n\t}\n\tif len(out.ToolCalls) != 1 {\n\t\tt.Fatalf(\"len(ToolCalls) = %d, want 1\", len(out.ToolCalls))\n\t}\n}\n\nfunc TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\n\t// Simulate a multi-turn conversation where the assistant's previous\n\t// reply included reasoning_content (e.g. from kimi-k2.5).\n\tmessages := []Message{\n\t\t{Role: \"user\", Content: \"What is 1+1?\"},\n\t\t{Role: \"assistant\", Content: \"2\", ReasoningContent: \"Let me think... 1+1=2\"},\n\t\t{Role: \"user\", Content: \"What about 2+2?\"},\n\t}\n\n\t_, err := p.Chat(t.Context(), messages, nil, \"kimi-k2.5\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\t// Verify reasoning_content is preserved in the serialized request.\n\treqMessages, ok := requestBody[\"messages\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"messages is not []any: %T\", requestBody[\"messages\"])\n\t}\n\tassistantMsg, ok := reqMessages[1].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"assistant message is not map[string]any: %T\", reqMessages[1])\n\t}\n\tif assistantMsg[\"reasoning_content\"] != \"Let me think... 1+1=2\" {\n\t\tt.Errorf(\"reasoning_content not preserved in request, got %v\", assistantMsg[\"reasoning_content\"])\n\t}\n}\n\nfunc TestProviderChat_HTTPError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Error(w, \"bad request\", http.StatusBadRequest)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n}\n\nfunc TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"bad request\"}`))\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"Status: 400\") {\n\t\tt.Fatalf(\"expected status code in error, got %v\", err)\n\t}\n\tif strings.Contains(err.Error(), \"returned HTML instead of JSON\") {\n\t\tt.Fatalf(\"expected non-HTML http error, got %v\", err)\n\t}\n}\n\nfunc TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcontentType string\n\t\tstatusCode  int\n\t\tbody        string\n\t}{\n\t\t{\n\t\t\tname:        \"html success response\",\n\t\t\tcontentType: \"text/html; charset=utf-8\",\n\t\t\tstatusCode:  http.StatusOK,\n\t\t\tbody:        \"<!DOCTYPE html><html><body>gateway login</body></html>\",\n\t\t},\n\t\t{\n\t\t\tname:        \"html error response\",\n\t\t\tcontentType: \"text/html; charset=utf-8\",\n\t\t\tstatusCode:  http.StatusBadGateway,\n\t\t\tbody:        \"<!DOCTYPE html><html><body>bad gateway</body></html>\",\n\t\t},\n\t\t{\n\t\t\tname:        \"mislabeled html success response\",\n\t\t\tcontentType: \"application/json\",\n\t\t\tstatusCode:  http.StatusOK,\n\t\t\tbody:        \"   \\r\\n\\t<!DOCTYPE html><html><body>gateway login</body></html>\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"Content-Type\", tt.contentType)\n\t\t\t\tw.WriteHeader(tt.statusCode)\n\t\t\t\t_, _ = w.Write([]byte(tt.body))\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tp := NewProvider(\"key\", server.URL, \"\")\n\t\t\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\t\t\tif err == nil {\n\t\t\t\tt.Fatal(\"expected error, got nil\")\n\t\t\t}\n\t\t\tif !strings.Contains(err.Error(), fmt.Sprintf(\"Status: %d\", tt.statusCode)) {\n\t\t\t\tt.Fatalf(\"expected status code in error, got %v\", err)\n\t\t\t}\n\t\t\tif !strings.Contains(err.Error(), \"returned HTML instead of JSON\") {\n\t\t\t\tt.Fatalf(\"expected helpful HTML error, got %v\", err)\n\t\t\t}\n\t\t\tif !strings.Contains(err.Error(), \"check api_base or proxy configuration\") {\n\t\t\t\tt.Fatalf(\"expected configuration hint, got %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) {\n\tcontent := strings.Repeat(\"a\", 1024)\n\tbody := `{\"choices\":[{\"message\":{\"content\":\"` + content + `\"},\"finish_reason\":\"stop\"}]}`\n\n\tp := NewProvider(\"key\", \"https://example.com/v1\", \"\")\n\tp.httpClient = &http.Client{\n\t\tTransport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {\n\t\t\treturn &http.Response{\n\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\tHeader:     http.Header{\"Content-Type\": []string{\"application/json\"}},\n\t\t\t\tBody: &errAfterDataReadCloser{\n\t\t\t\t\tdata:      []byte(body),\n\t\t\t\t\tchunkSize: 64,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t}),\n\t}\n\n\tout, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\tif out.Content != content {\n\t\tt.Fatalf(\"Content = %q, want %q\", out.Content, content)\n\t}\n}\n\nfunc TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) {\n\tbody := append([]byte(\"<!DOCTYPE html><html><body>\"), bytes.Repeat([]byte(\"A\"), 2048)...)\n\tbody = append(body, []byte(\"</body></html>\")...)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\tw.WriteHeader(http.StatusBadGateway)\n\t\t_, _ = w.Write(body)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, \"gpt-4o\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"Body:   <!DOCTYPE html><html><body>\") {\n\t\tt.Fatalf(\"expected html preview in error, got %v\", err)\n\t}\n\tif !strings.Contains(err.Error(), \"...\") {\n\t\tt.Fatalf(\"expected truncated preview, got %v\", err)\n\t}\n}\n\nfunc TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"moonshot/kimi-k2.5\",\n\t\tmap[string]any{\"temperature\": 0.3},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif requestBody[\"model\"] != \"kimi-k2.5\" {\n\t\tt.Fatalf(\"model = %v, want kimi-k2.5\", requestBody[\"model\"])\n\t}\n\tif requestBody[\"temperature\"] != 1.0 {\n\t\tt.Fatalf(\"temperature = %v, want 1.0\", requestBody[\"temperature\"])\n\t}\n}\n\nfunc TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\ttests := []struct {\n\t\tname      string\n\t\tinput     string\n\t\twantModel string\n\t}{\n\t\t{\n\t\t\tname:      \"strips litellm prefix and preserves proxy model name\",\n\t\t\tinput:     \"litellm/my-proxy-alias\",\n\t\t\twantModel: \"my-proxy-alias\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips groq prefix and keeps nested model\",\n\t\t\tinput:     \"groq/openai/gpt-oss-120b\",\n\t\t\twantModel: \"openai/gpt-oss-120b\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips ollama prefix\",\n\t\t\tinput:     \"ollama/qwen2.5:14b\",\n\t\t\twantModel: \"qwen2.5:14b\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips deepseek prefix\",\n\t\t\tinput:     \"deepseek/deepseek-chat\",\n\t\t\twantModel: \"deepseek-chat\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips vivgrid prefix\",\n\t\t\tinput:     \"vivgrid/auto\",\n\t\t\twantModel: \"auto\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips novita prefix deepseek model\",\n\t\t\tinput:     \"novita/deepseek/deepseek-v3.2\",\n\t\t\twantModel: \"deepseek/deepseek-v3.2\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips novita prefix zai model\",\n\t\t\tinput:     \"novita/zai-org/glm-5\",\n\t\t\twantModel: \"zai-org/glm-5\",\n\t\t},\n\t\t{\n\t\t\tname:      \"strips novita prefix minimax model\",\n\t\t\tinput:     \"novita/minimax/minimax-m2.5\",\n\t\t\twantModel: \"minimax/minimax-m2.5\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t_, err := p.Chat(t.Context(), []Message{{Role: \"user\", Content: \"hi\"}}, nil, tt.input, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t\t\t}\n\n\t\t\tif requestBody[\"model\"] != tt.wantModel {\n\t\t\t\tt.Fatalf(\"model = %v, want %s\", requestBody[\"model\"], tt.wantModel)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProvider_ProxyConfigured(t *testing.T) {\n\tproxyURL := \"http://127.0.0.1:8080\"\n\tp := NewProvider(\"key\", \"https://example.com\", proxyURL)\n\n\ttransport, ok := p.httpClient.Transport.(*http.Transport)\n\tif !ok || transport == nil {\n\t\tt.Fatalf(\"expected http transport with proxy, got %T\", p.httpClient.Transport)\n\t}\n\n\treq := &http.Request{URL: &url.URL{Scheme: \"https\", Host: \"api.example.com\"}}\n\tgotProxy, err := transport.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"proxy function returned error: %v\", err)\n\t}\n\tif gotProxy == nil || gotProxy.String() != proxyURL {\n\t\tt.Fatalf(\"proxy = %v, want %s\", gotProxy, proxyURL)\n\t}\n}\n\nfunc TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"gpt-4o\",\n\t\tmap[string]any{\"max_tokens\": float64(512), \"temperature\": 1},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\tif requestBody[\"max_tokens\"] != float64(512) {\n\t\tt.Fatalf(\"max_tokens = %v, want 512\", requestBody[\"max_tokens\"])\n\t}\n\tif requestBody[\"temperature\"] != float64(1) {\n\t\tt.Fatalf(\"temperature = %v, want 1\", requestBody[\"temperature\"])\n\t}\n}\n\nfunc TestNormalizeModel_UsesAPIBase(t *testing.T) {\n\tif got := normalizeModel(\"deepseek/deepseek-chat\", \"https://api.deepseek.com/v1\"); got != \"deepseek-chat\" {\n\t\tt.Fatalf(\"normalizeModel(deepseek) = %q, want %q\", got, \"deepseek-chat\")\n\t}\n\tif got := normalizeModel(\"openrouter/auto\", \"https://openrouter.ai/api/v1\"); got != \"openrouter/auto\" {\n\t\tt.Fatalf(\"normalizeModel(openrouter) = %q, want %q\", got, \"openrouter/auto\")\n\t}\n\tif got := normalizeModel(\"vivgrid/managed\", \"https://api.vivgrid.com/v1\"); got != \"managed\" {\n\t\tt.Fatalf(\"normalizeModel(vivgrid) = %q, want %q\", got, \"managed\")\n\t}\n\tif got := normalizeModel(\"vivgrid/auto\", \"https://api.vivgrid.com/v1\"); got != \"auto\" {\n\t\tt.Fatalf(\"normalizeModel(vivgrid auto) = %q, want %q\", got, \"auto\")\n\t}\n\tif got := normalizeModel(\n\t\t\"novita/deepseek/deepseek-v3.2\",\n\t\t\"https://api.novita.ai/openai\",\n\t); got != \"deepseek/deepseek-v3.2\" {\n\t\tt.Fatalf(\"normalizeModel(novita) = %q, want %q\", got, \"deepseek/deepseek-v3.2\")\n\t}\n}\n\nfunc TestProvider_RequestTimeoutDefault(t *testing.T) {\n\tp := NewProviderWithMaxTokensFieldAndTimeout(\"key\", \"https://example.com/v1\", \"\", \"\", 0)\n\tif p.httpClient.Timeout != defaultRequestTimeout {\n\t\tt.Fatalf(\"http timeout = %v, want %v\", p.httpClient.Timeout, defaultRequestTimeout)\n\t}\n}\n\nfunc TestProvider_RequestTimeoutOverride(t *testing.T) {\n\tp := NewProviderWithMaxTokensFieldAndTimeout(\"key\", \"https://example.com/v1\", \"\", \"\", 300)\n\tif p.httpClient.Timeout != 300*time.Second {\n\t\tt.Fatalf(\"http timeout = %v, want %v\", p.httpClient.Timeout, 300*time.Second)\n\t}\n}\n\ntype roundTripperFunc func(*http.Request) (*http.Response, error)\n\nfunc (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {\n\treturn f(r)\n}\n\ntype errAfterDataReadCloser struct {\n\tdata      []byte\n\tchunkSize int\n\toffset    int\n}\n\nfunc (r *errAfterDataReadCloser) Read(p []byte) (int, error) {\n\tif r.offset >= len(r.data) {\n\t\treturn 0, io.ErrUnexpectedEOF\n\t}\n\n\tn := r.chunkSize\n\tif n <= 0 || n > len(p) {\n\t\tn = len(p)\n\t}\n\tremaining := len(r.data) - r.offset\n\tif n > remaining {\n\t\tn = remaining\n\t}\n\tcopy(p, r.data[r.offset:r.offset+n])\n\tr.offset += n\n\treturn n, nil\n}\n\nfunc (r *errAfterDataReadCloser) Close() error {\n\treturn nil\n}\n\nfunc TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {\n\tp := NewProvider(\"key\", \"https://example.com/v1\", \"\", WithMaxTokensField(\"max_completion_tokens\"))\n\tif p.maxTokensField != \"max_completion_tokens\" {\n\t\tt.Fatalf(\"maxTokensField = %q, want %q\", p.maxTokensField, \"max_completion_tokens\")\n\t}\n}\n\nfunc TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {\n\tp := NewProvider(\"key\", \"https://example.com/v1\", \"\", WithRequestTimeout(45*time.Second))\n\tif p.httpClient.Timeout != 45*time.Second {\n\t\tt.Fatalf(\"http timeout = %v, want %v\", p.httpClient.Timeout, 45*time.Second)\n\t}\n}\n\nfunc TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {\n\tp := NewProvider(\"key\", \"https://example.com/v1\", \"\", WithRequestTimeout(-1*time.Second))\n\tif p.httpClient.Timeout != defaultRequestTimeout {\n\t\tt.Fatalf(\"http timeout = %v, want %v\", p.httpClient.Timeout, defaultRequestTimeout)\n\t}\n}\n\nfunc TestSerializeMessages_PlainText(t *testing.T) {\n\tmessages := []protocoltypes.Message{\n\t\t{Role: \"user\", Content: \"hello\"},\n\t\t{Role: \"assistant\", Content: \"hi\", ReasoningContent: \"thinking...\"},\n\t}\n\tresult := common.SerializeMessages(messages)\n\n\tdata, err := json.Marshal(result)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tif msgs[0][\"content\"] != \"hello\" {\n\t\tt.Fatalf(\"expected plain string content, got %v\", msgs[0][\"content\"])\n\t}\n\tif msgs[1][\"reasoning_content\"] != \"thinking...\" {\n\t\tt.Fatalf(\"reasoning_content not preserved, got %v\", msgs[1][\"reasoning_content\"])\n\t}\n}\n\nfunc TestSerializeMessages_WithMedia(t *testing.T) {\n\tmessages := []protocoltypes.Message{\n\t\t{Role: \"user\", Content: \"describe this\", Media: []string{\"data:image/png;base64,abc123\"}},\n\t}\n\tresult := common.SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tcontent, ok := msgs[0][\"content\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected array content for media message, got %T\", msgs[0][\"content\"])\n\t}\n\tif len(content) != 2 {\n\t\tt.Fatalf(\"expected 2 content parts, got %d\", len(content))\n\t}\n\n\ttextPart := content[0].(map[string]any)\n\tif textPart[\"type\"] != \"text\" || textPart[\"text\"] != \"describe this\" {\n\t\tt.Fatalf(\"text part mismatch: %v\", textPart)\n\t}\n\n\timgPart := content[1].(map[string]any)\n\tif imgPart[\"type\"] != \"image_url\" {\n\t\tt.Fatalf(\"expected image_url type, got %v\", imgPart[\"type\"])\n\t}\n\timgURL := imgPart[\"image_url\"].(map[string]any)\n\tif imgURL[\"url\"] != \"data:image/png;base64,abc123\" {\n\t\tt.Fatalf(\"image url mismatch: %v\", imgURL[\"url\"])\n\t}\n}\n\nfunc TestSerializeMessages_MediaWithToolCallID(t *testing.T) {\n\tmessages := []protocoltypes.Message{\n\t\t{Role: \"tool\", Content: \"image result\", Media: []string{\"data:image/png;base64,xyz\"}, ToolCallID: \"call_1\"},\n\t}\n\tresult := common.SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\tvar msgs []map[string]any\n\tjson.Unmarshal(data, &msgs)\n\n\tif msgs[0][\"tool_call_id\"] != \"call_1\" {\n\t\tt.Fatalf(\"tool_call_id not preserved with media, got %v\", msgs[0][\"tool_call_id\"])\n\t}\n\t// Content should be multipart array\n\tif _, ok := msgs[0][\"content\"].([]any); !ok {\n\t\tt.Fatalf(\"expected array content, got %T\", msgs[0][\"content\"])\n\t}\n}\n\n// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key,\n// and returns the decoded request body for assertion.\nfunc chatWithCacheKey(t *testing.T, apiBase string) map[string]any {\n\tt.Helper()\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\tp.apiBase = apiBase\n\tp.httpClient = &http.Client{\n\t\tTransport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {\n\t\t\tr.URL, _ = url.Parse(server.URL + r.URL.Path)\n\t\t\treturn http.DefaultTransport.RoundTrip(r)\n\t\t}),\n\t}\n\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"test-model\",\n\t\tmap[string]any{\"prompt_cache_key\": \"agent-main\"},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\treturn requestBody\n}\n\nfunc TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) {\n\tbody := chatWithCacheKey(t, \"https://api.openai.com/v1\")\n\tif body[\"prompt_cache_key\"] != \"agent-main\" {\n\t\tt.Fatalf(\"prompt_cache_key = %v, want %q\", body[\"prompt_cache_key\"], \"agent-main\")\n\t}\n}\n\nfunc TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tapiBase string\n\t}{\n\t\t{\"mistral\", \"https://api.mistral.ai/v1\"},\n\t\t{\"gemini\", \"https://generativelanguage.googleapis.com/v1beta\"},\n\t\t{\"deepseek\", \"https://api.deepseek.com/v1\"},\n\t\t{\"groq\", \"https://api.groq.com/openai/v1\"},\n\t\t{\"minimax\", \"https://api.minimaxi.com/v1\"},\n\t\t{\"ollama_local\", \"http://localhost:11434/v1\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbody := chatWithCacheKey(t, tt.apiBase)\n\t\t\tif _, exists := body[\"prompt_cache_key\"]; exists {\n\t\t\t\tt.Fatalf(\"prompt_cache_key should NOT be sent to %s, but was included in request\", tt.name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSupportsPromptCacheKey(t *testing.T) {\n\ttests := []struct {\n\t\tapiBase string\n\t\twant    bool\n\t}{\n\t\t{\"https://api.openai.com/v1\", true},\n\t\t{\"https://api.openai.com/v1/\", true},\n\t\t{\"https://myresource.openai.azure.com/openai/deployments/gpt-4\", true},\n\t\t{\"https://eastus.openai.azure.com/v1\", true},\n\t\t{\"https://api.mistral.ai/v1\", false},\n\t\t{\"https://generativelanguage.googleapis.com/v1beta\", false},\n\t\t{\"https://api.deepseek.com/v1\", false},\n\t\t{\"https://api.groq.com/openai/v1\", false},\n\t\t{\"http://localhost:11434/v1\", false},\n\t\t{\"https://openrouter.ai/api/v1\", false},\n\t\t// Edge cases: proxy URLs with openai.com in path should NOT match\n\t\t{\"https://my-proxy.com/api.openai.com/v1\", false},\n\t\t{\"https://proxy.example.com/openai.azure.com/v1\", false},\n\t\t// Malformed or empty\n\t\t{\"\", false},\n\t\t{\"not-a-url\", false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := supportsPromptCacheKey(tt.apiBase); got != tt.want {\n\t\t\tt.Errorf(\"supportsPromptCacheKey(%q) = %v, want %v\", tt.apiBase, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"read_file\", Description: \"read\"}},\n\t}\n\tresult := buildToolsList(tools, true)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n\twsEntry, ok := result[1].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"web search entry is %T, want map[string]any\", result[1])\n\t}\n\tif wsEntry[\"type\"] != \"web_search_preview\" {\n\t\tt.Fatalf(\"type = %v, want web_search_preview\", wsEntry[\"type\"])\n\t}\n}\n\nfunc TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"web_search\", Description: \"search\"}},\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"read_file\", Description: \"read\"}},\n\t}\n\tresult := buildToolsList(tools, true)\n\tfor _, entry := range result {\n\t\tif td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, \"web_search\") {\n\t\t\tt.Fatal(\"client-side web_search should be filtered out when native search is enabled\")\n\t\t}\n\t}\n\tif len(result) != 2 { // read_file + web_search_preview\n\t\tt.Fatalf(\"len(result) = %d, want 2 (read_file + web_search_preview)\", len(result))\n\t}\n}\n\nfunc TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) {\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"web_search\", Description: \"search\"}},\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"read_file\", Description: \"read\"}},\n\t}\n\tresult := buildToolsList(tools, false)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"len(result) = %d, want 2\", len(result))\n\t}\n}\n\nfunc TestIsNativeSearchHost(t *testing.T) {\n\ttests := []struct {\n\t\tapiBase string\n\t\twant    bool\n\t}{\n\t\t{\"https://api.openai.com/v1\", true},\n\t\t{\"https://myresource.openai.azure.com/openai/deployments/gpt-4\", true},\n\t\t{\"https://api.mistral.ai/v1\", false},\n\t\t{\"https://api.deepseek.com/v1\", false},\n\t\t{\"https://api.groq.com/openai/v1\", false},\n\t\t{\"http://localhost:11434/v1\", false},\n\t\t{\"\", false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := isNativeSearchHost(tt.apiBase); got != tt.want {\n\t\t\tt.Errorf(\"isNativeSearchHost(%q) = %v, want %v\", tt.apiBase, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestSupportsNativeSearch_OpenAI(t *testing.T) {\n\tp := NewProvider(\"key\", \"https://api.openai.com/v1\", \"\")\n\tif !p.SupportsNativeSearch() {\n\t\tt.Fatal(\"OpenAI provider should support native search\")\n\t}\n}\n\nfunc TestSupportsNativeSearch_NonOpenAI(t *testing.T) {\n\tp := NewProvider(\"key\", \"https://api.deepseek.com/v1\", \"\")\n\tif p.SupportsNativeSearch() {\n\t\tt.Fatal(\"DeepSeek provider should not support native search\")\n\t}\n}\n\nfunc TestProviderChat_NativeSearchToolInjected(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\tp.apiBase = \"https://api.openai.com/v1\"\n\tp.httpClient = &http.Client{\n\t\tTransport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {\n\t\t\tr.URL, _ = url.Parse(server.URL + r.URL.Path)\n\t\t\treturn http.DefaultTransport.RoundTrip(r)\n\t\t}),\n\t}\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"read_file\", Description: \"read\"}},\n\t}\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\ttools,\n\t\t\"gpt-5.4\",\n\t\tmap[string]any{\"native_search\": true},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\ttoolsRaw, ok := requestBody[\"tools\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"tools is %T, want []any\", requestBody[\"tools\"])\n\t}\n\tif len(toolsRaw) != 2 {\n\t\tt.Fatalf(\"len(tools) = %d, want 2 (read_file + web_search_preview)\", len(toolsRaw))\n\t}\n\n\tlastTool, ok := toolsRaw[1].(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"last tool is %T, want map[string]any\", toolsRaw[1])\n\t}\n\tif lastTool[\"type\"] != \"web_search_preview\" {\n\t\tt.Fatalf(\"last tool type = %v, want web_search_preview\", lastTool[\"type\"])\n\t}\n}\n\nfunc TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\tp := NewProvider(\"key\", server.URL, \"\")\n\ttools := []ToolDefinition{\n\t\t{Type: \"function\", Function: ToolFunctionDefinition{Name: \"web_search\", Description: \"search\"}},\n\t}\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\ttools,\n\t\t\"gpt-5.4\",\n\t\tmap[string]any{},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\ttoolsRaw, ok := requestBody[\"tools\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"tools is %T, want []any\", requestBody[\"tools\"])\n\t}\n\tif len(toolsRaw) != 1 {\n\t\tt.Fatalf(\"len(tools) = %d, want 1 (web_search only)\", len(toolsRaw))\n\t}\n}\n\n// TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search\n// is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek),\n// we do not inject web_search_preview to avoid API errors.\nfunc TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) {\n\tvar requestBody map[string]any\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tresp := map[string]any{\n\t\t\t\"choices\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"message\":       map[string]any{\"content\": \"ok\"},\n\t\t\t\t\t\"finish_reason\": \"stop\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(resp)\n\t}))\n\tdefer server.Close()\n\n\t// Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider\n\tp := NewProvider(\"key\", server.URL, \"\")\n\t_, err := p.Chat(\n\t\tt.Context(),\n\t\t[]Message{{Role: \"user\", Content: \"hi\"}},\n\t\tnil,\n\t\t\"deepseek-chat\",\n\t\tmap[string]any{\"native_search\": true},\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"Chat() error = %v\", err)\n\t}\n\n\t// Should not have tools at all (no tools passed, and we must not add web_search_preview)\n\tif toolsRaw, ok := requestBody[\"tools\"]; ok {\n\t\tt.Fatalf(\"tools should be omitted for non-OpenAI when only native_search was requested, got %v\", toolsRaw)\n\t}\n}\n\nfunc TestSerializeMessages_StripsSystemParts(t *testing.T) {\n\tmessages := []protocoltypes.Message{\n\t\t{\n\t\t\tRole:    \"system\",\n\t\t\tContent: \"you are helpful\",\n\t\t\tSystemParts: []protocoltypes.ContentBlock{\n\t\t\t\t{Type: \"text\", Text: \"you are helpful\"},\n\t\t\t},\n\t\t},\n\t}\n\tresult := common.SerializeMessages(messages)\n\n\tdata, _ := json.Marshal(result)\n\traw := string(data)\n\tif strings.Contains(raw, \"system_parts\") {\n\t\tt.Fatal(\"system_parts should not appear in serialized output\")\n\t}\n}\n"
  },
  {
    "path": "pkg/providers/protocoltypes/types.go",
    "content": "package protocoltypes\n\ntype ToolCall struct {\n\tID               string         `json:\"id\"`\n\tType             string         `json:\"type,omitempty\"`\n\tFunction         *FunctionCall  `json:\"function,omitempty\"`\n\tName             string         `json:\"-\"`\n\tArguments        map[string]any `json:\"-\"`\n\tThoughtSignature string         `json:\"-\"` // Internal use only\n\tExtraContent     *ExtraContent  `json:\"extra_content,omitempty\"`\n}\n\ntype ExtraContent struct {\n\tGoogle *GoogleExtra `json:\"google,omitempty\"`\n}\n\ntype GoogleExtra struct {\n\tThoughtSignature string `json:\"thought_signature,omitempty\"`\n}\n\ntype FunctionCall struct {\n\tName             string `json:\"name\"`\n\tArguments        string `json:\"arguments\"`\n\tThoughtSignature string `json:\"thought_signature,omitempty\"`\n}\n\ntype LLMResponse struct {\n\tContent          string            `json:\"content\"`\n\tReasoningContent string            `json:\"reasoning_content,omitempty\"`\n\tToolCalls        []ToolCall        `json:\"tool_calls,omitempty\"`\n\tFinishReason     string            `json:\"finish_reason\"`\n\tUsage            *UsageInfo        `json:\"usage,omitempty\"`\n\tReasoning        string            `json:\"reasoning\"`\n\tReasoningDetails []ReasoningDetail `json:\"reasoning_details\"`\n}\n\ntype ReasoningDetail struct {\n\tFormat string `json:\"format\"`\n\tIndex  int    `json:\"index\"`\n\tType   string `json:\"type\"`\n\tText   string `json:\"text\"`\n}\n\ntype UsageInfo struct {\n\tPromptTokens     int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens      int `json:\"total_tokens\"`\n}\n\n// CacheControl marks a content block for LLM-side prefix caching.\n// Currently only \"ephemeral\" is supported (used by Anthropic).\ntype CacheControl struct {\n\tType string `json:\"type\"` // \"ephemeral\"\n}\n\n// ContentBlock represents a structured segment of a system message.\n// Adapters that understand SystemParts can use these blocks to set\n// per-block cache control (e.g. Anthropic's cache_control: ephemeral).\ntype ContentBlock struct {\n\tType         string        `json:\"type\"` // \"text\"\n\tText         string        `json:\"text\"`\n\tCacheControl *CacheControl `json:\"cache_control,omitempty\"`\n}\n\ntype Message struct {\n\tRole             string         `json:\"role\"`\n\tContent          string         `json:\"content\"`\n\tMedia            []string       `json:\"media,omitempty\"`\n\tReasoningContent string         `json:\"reasoning_content,omitempty\"`\n\tSystemParts      []ContentBlock `json:\"system_parts,omitempty\"` // structured system blocks for cache-aware adapters\n\tToolCalls        []ToolCall     `json:\"tool_calls,omitempty\"`\n\tToolCallID       string         `json:\"tool_call_id,omitempty\"`\n}\n\ntype ToolDefinition struct {\n\tType     string                 `json:\"type\"`\n\tFunction ToolFunctionDefinition `json:\"function\"`\n}\n\ntype ToolFunctionDefinition struct {\n\tName        string         `json:\"name\"`\n\tDescription string         `json:\"description\"`\n\tParameters  map[string]any `json:\"parameters\"`\n}\n"
  },
  {
    "path": "pkg/providers/tool_call_extract.go",
    "content": "package providers\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n)\n\n// extractToolCallsFromText parses tool call JSON from response text.\n// Both ClaudeCliProvider and CodexCliProvider use this to extract\n// tool calls that the model outputs in its response text.\nfunc extractToolCallsFromText(text string) []ToolCall {\n\tstart := strings.Index(text, `{\"tool_calls\"`)\n\tif start == -1 {\n\t\treturn nil\n\t}\n\n\tend := findMatchingBrace(text, start)\n\tif end == start {\n\t\treturn nil\n\t}\n\n\tjsonStr := text[start:end]\n\n\tvar wrapper struct {\n\t\tToolCalls []struct {\n\t\t\tID       string `json:\"id\"`\n\t\t\tType     string `json:\"type\"`\n\t\t\tFunction struct {\n\t\t\t\tName      string `json:\"name\"`\n\t\t\t\tArguments string `json:\"arguments\"`\n\t\t\t} `json:\"function\"`\n\t\t} `json:\"tool_calls\"`\n\t}\n\n\tif err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {\n\t\treturn nil\n\t}\n\n\tvar result []ToolCall\n\tfor _, tc := range wrapper.ToolCalls {\n\t\tvar args map[string]any\n\t\tjson.Unmarshal([]byte(tc.Function.Arguments), &args)\n\n\t\tresult = append(result, ToolCall{\n\t\t\tID:        tc.ID,\n\t\t\tType:      tc.Type,\n\t\t\tName:      tc.Function.Name,\n\t\t\tArguments: args,\n\t\t\tFunction: &FunctionCall{\n\t\t\t\tName:      tc.Function.Name,\n\t\t\t\tArguments: tc.Function.Arguments,\n\t\t\t},\n\t\t})\n\t}\n\n\treturn result\n}\n\n// stripToolCallsFromText removes tool call JSON from response text.\nfunc stripToolCallsFromText(text string) string {\n\tstart := strings.Index(text, `{\"tool_calls\"`)\n\tif start == -1 {\n\t\treturn text\n\t}\n\n\tend := findMatchingBrace(text, start)\n\tif end == start {\n\t\treturn text\n\t}\n\n\treturn strings.TrimSpace(text[:start] + text[end:])\n}\n"
  },
  {
    "path": "pkg/providers/toolcall_utils.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage providers\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt.\nfunc buildCLIToolsPrompt(tools []ToolDefinition) string {\n\tvar sb strings.Builder\n\n\tsb.WriteString(\"## Available Tools\\n\\n\")\n\tsb.WriteString(\"When you need to use a tool, respond with ONLY a JSON object:\\n\\n\")\n\tsb.WriteString(\"```json\\n\")\n\tsb.WriteString(\n\t\t`{\"tool_calls\":[{\"id\":\"call_xxx\",\"type\":\"function\",\"function\":{\"name\":\"tool_name\",\"arguments\":\"{...}\"}}]}`,\n\t)\n\tsb.WriteString(\"\\n```\\n\\n\")\n\tsb.WriteString(\"CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\\n\\n\")\n\tsb.WriteString(\"### Tool Definitions:\\n\\n\")\n\n\tfor _, tool := range tools {\n\t\tif tool.Type != \"function\" {\n\t\t\tcontinue\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"#### %s\\n\", tool.Function.Name))\n\t\tif tool.Function.Description != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\"Description: %s\\n\", tool.Function.Description))\n\t\t}\n\t\tif len(tool.Function.Parameters) > 0 {\n\t\t\tparamsJSON, _ := json.Marshal(tool.Function.Parameters)\n\t\t\tsb.WriteString(fmt.Sprintf(\"Parameters:\\n```json\\n%s\\n```\\n\", string(paramsJSON)))\n\t\t}\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\n// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.\n// It handles cases where Name/Arguments might be in different locations (top-level vs Function)\n// and ensures both are populated consistently.\nfunc NormalizeToolCall(tc ToolCall) ToolCall {\n\tnormalized := tc\n\n\t// Ensure Name is populated from Function if not set\n\tif normalized.Name == \"\" && normalized.Function != nil {\n\t\tnormalized.Name = normalized.Function.Name\n\t}\n\n\t// Ensure Arguments is not nil\n\tif normalized.Arguments == nil {\n\t\tnormalized.Arguments = map[string]any{}\n\t}\n\n\t// Parse Arguments from Function.Arguments if not already set\n\tif len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != \"\" {\n\t\tvar parsed map[string]any\n\t\tif err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {\n\t\t\tnormalized.Arguments = parsed\n\t\t}\n\t}\n\n\t// Ensure Function is populated with consistent values\n\targsJSON, _ := json.Marshal(normalized.Arguments)\n\tif normalized.Function == nil {\n\t\tnormalized.Function = &FunctionCall{\n\t\t\tName:      normalized.Name,\n\t\t\tArguments: string(argsJSON),\n\t\t}\n\t} else {\n\t\tif normalized.Function.Name == \"\" {\n\t\t\tnormalized.Function.Name = normalized.Name\n\t\t}\n\t\tif normalized.Name == \"\" {\n\t\t\tnormalized.Name = normalized.Function.Name\n\t\t}\n\t\tif normalized.Function.Arguments == \"\" {\n\t\t\tnormalized.Function.Arguments = string(argsJSON)\n\t\t}\n\t}\n\n\treturn normalized\n}\n"
  },
  {
    "path": "pkg/providers/types.go",
    "content": "package providers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers/protocoltypes\"\n)\n\ntype (\n\tToolCall               = protocoltypes.ToolCall\n\tFunctionCall           = protocoltypes.FunctionCall\n\tLLMResponse            = protocoltypes.LLMResponse\n\tUsageInfo              = protocoltypes.UsageInfo\n\tMessage                = protocoltypes.Message\n\tToolDefinition         = protocoltypes.ToolDefinition\n\tToolFunctionDefinition = protocoltypes.ToolFunctionDefinition\n\tExtraContent           = protocoltypes.ExtraContent\n\tGoogleExtra            = protocoltypes.GoogleExtra\n\tContentBlock           = protocoltypes.ContentBlock\n\tCacheControl           = protocoltypes.CacheControl\n)\n\ntype LLMProvider interface {\n\tChat(\n\t\tctx context.Context,\n\t\tmessages []Message,\n\t\ttools []ToolDefinition,\n\t\tmodel string,\n\t\toptions map[string]any,\n\t) (*LLMResponse, error)\n\tGetDefaultModel() string\n}\n\ntype StatefulProvider interface {\n\tLLMProvider\n\tClose()\n}\n\n// ThinkingCapable is an optional interface for providers that support\n// extended thinking (e.g. Anthropic). Used by the agent loop to warn\n// when thinking_level is configured but the active provider cannot use it.\ntype ThinkingCapable interface {\n\tSupportsThinking() bool\n}\n\n// NativeSearchCapable is an optional interface for providers that support\n// built-in web search during LLM inference (e.g. OpenAI web_search_preview,\n// xAI Grok search). When the active provider implements this interface and\n// returns true, the agent loop can hide the client-side web_search tool to\n// avoid duplicate search surfaces and use the provider's native search instead.\ntype NativeSearchCapable interface {\n\tSupportsNativeSearch() bool\n}\n\n// FailoverReason classifies why an LLM request failed for fallback decisions.\ntype FailoverReason string\n\nconst (\n\tFailoverAuth       FailoverReason = \"auth\"\n\tFailoverRateLimit  FailoverReason = \"rate_limit\"\n\tFailoverBilling    FailoverReason = \"billing\"\n\tFailoverTimeout    FailoverReason = \"timeout\"\n\tFailoverFormat     FailoverReason = \"format\"\n\tFailoverOverloaded FailoverReason = \"overloaded\"\n\tFailoverUnknown    FailoverReason = \"unknown\"\n)\n\n// FailoverError wraps an LLM provider error with classification metadata.\ntype FailoverError struct {\n\tReason   FailoverReason\n\tProvider string\n\tModel    string\n\tStatus   int\n\tWrapped  error\n}\n\nfunc (e *FailoverError) Error() string {\n\treturn fmt.Sprintf(\"failover(%s): provider=%s model=%s status=%d: %v\",\n\t\te.Reason, e.Provider, e.Model, e.Status, e.Wrapped)\n}\n\nfunc (e *FailoverError) Unwrap() error {\n\treturn e.Wrapped\n}\n\n// IsRetriable returns true if this error should trigger fallback to next candidate.\n// Non-retriable: Format errors (bad request structure, image dimension/size).\nfunc (e *FailoverError) IsRetriable() bool {\n\treturn e.Reason != FailoverFormat\n}\n\n// ModelConfig holds primary model and fallback list.\ntype ModelConfig struct {\n\tPrimary   string\n\tFallbacks []string\n}\n"
  },
  {
    "path": "pkg/routing/agent_id.go",
    "content": "package routing\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n)\n\nconst (\n\tDefaultAgentID   = \"main\"\n\tDefaultMainKey   = \"main\"\n\tDefaultAccountID = \"default\"\n\tMaxAgentIDLength = 64\n)\n\nvar (\n\tvalidIDRe      = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)\n\tinvalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`)\n\tleadingDashRe  = regexp.MustCompile(`^-+`)\n\ttrailingDashRe = regexp.MustCompile(`-+$`)\n)\n\n// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}.\n// Invalid characters are collapsed to \"-\". Leading/trailing dashes stripped.\n// Empty input returns DefaultAgentID (\"main\").\nfunc NormalizeAgentID(id string) string {\n\ttrimmed := strings.TrimSpace(id)\n\tif trimmed == \"\" {\n\t\treturn DefaultAgentID\n\t}\n\tlower := strings.ToLower(trimmed)\n\tif validIDRe.MatchString(lower) {\n\t\treturn lower\n\t}\n\tresult := invalidCharsRe.ReplaceAllString(lower, \"-\")\n\tresult = leadingDashRe.ReplaceAllString(result, \"\")\n\tresult = trailingDashRe.ReplaceAllString(result, \"\")\n\tif len(result) > MaxAgentIDLength {\n\t\tresult = result[:MaxAgentIDLength]\n\t}\n\tif result == \"\" {\n\t\treturn DefaultAgentID\n\t}\n\treturn result\n}\n\n// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID.\nfunc NormalizeAccountID(id string) string {\n\ttrimmed := strings.TrimSpace(id)\n\tif trimmed == \"\" {\n\t\treturn DefaultAccountID\n\t}\n\tlower := strings.ToLower(trimmed)\n\tif validIDRe.MatchString(lower) {\n\t\treturn lower\n\t}\n\tresult := invalidCharsRe.ReplaceAllString(lower, \"-\")\n\tresult = leadingDashRe.ReplaceAllString(result, \"\")\n\tresult = trailingDashRe.ReplaceAllString(result, \"\")\n\tif len(result) > MaxAgentIDLength {\n\t\tresult = result[:MaxAgentIDLength]\n\t}\n\tif result == \"\" {\n\t\treturn DefaultAccountID\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "pkg/routing/agent_id_test.go",
    "content": "package routing\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNormalizeAgentID_Empty(t *testing.T) {\n\tif got := NormalizeAgentID(\"\"); got != DefaultAgentID {\n\t\tt.Errorf(\"NormalizeAgentID('') = %q, want %q\", got, DefaultAgentID)\n\t}\n}\n\nfunc TestNormalizeAgentID_Whitespace(t *testing.T) {\n\tif got := NormalizeAgentID(\"  \"); got != DefaultAgentID {\n\t\tt.Errorf(\"NormalizeAgentID('  ') = %q, want %q\", got, DefaultAgentID)\n\t}\n}\n\nfunc TestNormalizeAgentID_Valid(t *testing.T) {\n\ttests := []struct {\n\t\tinput, want string\n\t}{\n\t\t{\"main\", \"main\"},\n\t\t{\"Main\", \"main\"},\n\t\t{\"SALES\", \"sales\"},\n\t\t{\"support-bot\", \"support-bot\"},\n\t\t{\"agent_1\", \"agent_1\"},\n\t\t{\"a\", \"a\"},\n\t\t{\"0test\", \"0test\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := NormalizeAgentID(tt.input); got != tt.want {\n\t\t\tt.Errorf(\"NormalizeAgentID(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeAgentID_InvalidChars(t *testing.T) {\n\ttests := []struct {\n\t\tinput, want string\n\t}{\n\t\t{\"Hello World\", \"hello-world\"},\n\t\t{\"agent@123\", \"agent-123\"},\n\t\t{\"foo.bar.baz\", \"foo-bar-baz\"},\n\t\t{\"--leading\", \"leading\"},\n\t\t{\"--both--\", \"both\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := NormalizeAgentID(tt.input); got != tt.want {\n\t\t\tt.Errorf(\"NormalizeAgentID(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeAgentID_AllInvalid(t *testing.T) {\n\tif got := NormalizeAgentID(\"@@@\"); got != DefaultAgentID {\n\t\tt.Errorf(\"NormalizeAgentID('@@@') = %q, want %q\", got, DefaultAgentID)\n\t}\n}\n\nfunc TestNormalizeAgentID_TruncatesAt64(t *testing.T) {\n\tvar long strings.Builder\n\tfor range 100 {\n\t\tlong.WriteString(\"a\")\n\t}\n\tgot := NormalizeAgentID(long.String())\n\tif len(got) > MaxAgentIDLength {\n\t\tt.Errorf(\"length = %d, want <= %d\", len(got), MaxAgentIDLength)\n\t}\n}\n\nfunc TestNormalizeAccountID_Empty(t *testing.T) {\n\tif got := NormalizeAccountID(\"\"); got != DefaultAccountID {\n\t\tt.Errorf(\"NormalizeAccountID('') = %q, want %q\", got, DefaultAccountID)\n\t}\n}\n\nfunc TestNormalizeAccountID_Valid(t *testing.T) {\n\tif got := NormalizeAccountID(\"MyBot\"); got != \"mybot\" {\n\t\tt.Errorf(\"NormalizeAccountID('MyBot') = %q, want 'mybot'\", got)\n\t}\n}\n\nfunc TestNormalizeAccountID_InvalidChars(t *testing.T) {\n\tif got := NormalizeAccountID(\"bot@home\"); got != \"bot-home\" {\n\t\tt.Errorf(\"NormalizeAccountID('bot@home') = %q, want 'bot-home'\", got)\n\t}\n}\n"
  },
  {
    "path": "pkg/routing/classifier.go",
    "content": "package routing\n\n// Classifier evaluates a feature set and returns a complexity score in [0, 1].\n// A higher score indicates a more complex task that benefits from a heavy model.\n// The score is compared against the configured threshold: score >= threshold selects\n// the primary (heavy) model; score < threshold selects the light model.\n//\n// Classifier is an interface so that future implementations (ML-based, embedding-based,\n// or any other approach) can be swapped in without changing routing infrastructure.\ntype Classifier interface {\n\tScore(f Features) float64\n}\n\n// RuleClassifier is the v1 implementation.\n// It uses a weighted sum of structural signals with no external dependencies,\n// no API calls, and sub-microsecond latency. The raw sum is capped at 1.0 so\n// that the returned score always falls within the [0, 1] contract.\n//\n// Individual weights (multiple signals can fire simultaneously):\n//\n//\ttoken > 200 (≈600 chars): 0.35  — very long prompts are almost always complex\n//\ttoken 50-200:             0.15  — medium length; may or may not be complex\n//\tcode block present:       0.40  — coding tasks need the heavy model\n//\ttool calls > 3 (recent):  0.25  — dense tool usage signals an agentic workflow\n//\ttool calls 1-3 (recent):  0.10  — some tool activity\n//\tconversation depth > 10:  0.10  — long sessions carry implicit complexity\n//\tattachments present:      1.00  — hard gate; multi-modal always needs heavy model\n//\n// Default threshold is 0.35, so:\n//   - Pure greetings / trivial Q&A:                 0.00 → light  ✓\n//   - Medium prose message (50–200 tokens):          0.15 → light  ✓\n//   - Message with code block:                       0.40 → heavy  ✓\n//   - Long message (>200 tokens):                    0.35 → heavy  ✓\n//   - Active tool session + medium message:          0.25 → light  (acceptable)\n//   - Any message with an image/audio attachment:    1.00 → heavy  ✓\ntype RuleClassifier struct{}\n\n// Score computes the complexity score for the given feature set.\n// The returned value is in [0, 1]. Attachments short-circuit to 1.0.\nfunc (c *RuleClassifier) Score(f Features) float64 {\n\t// Hard gate: multi-modal inputs always require the heavy model.\n\tif f.HasAttachments {\n\t\treturn 1.0\n\t}\n\n\tvar score float64\n\n\t// Token estimate — primary verbosity signal\n\tswitch {\n\tcase f.TokenEstimate > 200:\n\t\tscore += 0.35\n\tcase f.TokenEstimate > 50:\n\t\tscore += 0.15\n\t}\n\n\t// Fenced code blocks — strongest indicator of a coding/technical task\n\tif f.CodeBlockCount > 0 {\n\t\tscore += 0.40\n\t}\n\n\t// Recent tool call density — indicates an ongoing agentic workflow\n\tswitch {\n\tcase f.RecentToolCalls > 3:\n\t\tscore += 0.25\n\tcase f.RecentToolCalls > 0:\n\t\tscore += 0.10\n\t}\n\n\t// Conversation depth — accumulated context implies compound task\n\tif f.ConversationDepth > 10 {\n\t\tscore += 0.10\n\t}\n\n\t// Cap at 1.0 to honor the [0, 1] contract even when multiple signals fire\n\t// simultaneously (e.g., long message + code block + tool chain = 1.10 raw).\n\tif score > 1.0 {\n\t\tscore = 1.0\n\t}\n\treturn score\n}\n"
  },
  {
    "path": "pkg/routing/features.go",
    "content": "package routing\n\nimport (\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// lookbackWindow is the number of recent history entries scanned for tool calls.\n// Six entries covers roughly one full tool-use round-trip (user → assistant+tool_call → tool_result → assistant).\nconst lookbackWindow = 6\n\n// Features holds the structural signals extracted from a message and its session context.\n// Every dimension is language-agnostic by construction — no keyword or pattern matching\n// against natural-language content. This ensures consistent routing for all locales.\ntype Features struct {\n\t// TokenEstimate is a proxy for token count.\n\t// CJK runes count as 1 token each; non-CJK runes as 0.25 tokens each.\n\t// This avoids API calls while giving accurate estimates for all scripts.\n\tTokenEstimate int\n\n\t// CodeBlockCount is the number of fenced code blocks (``` pairs) in the message.\n\t// Coding tasks almost always require the heavy model.\n\tCodeBlockCount int\n\n\t// RecentToolCalls is the count of tool_call messages in the last lookbackWindow\n\t// history entries. A high density indicates an active agentic workflow.\n\tRecentToolCalls int\n\n\t// ConversationDepth is the total number of messages in the session history.\n\t// Deep sessions tend to carry implicit complexity built up over many turns.\n\tConversationDepth int\n\n\t// HasAttachments is true when the message appears to contain media (images,\n\t// audio, video). Multi-modal inputs require vision-capable heavy models.\n\tHasAttachments bool\n}\n\n// ExtractFeatures computes the structural feature vector for a message.\n// It is a pure function with no side effects and zero allocations beyond\n// the returned struct.\nfunc ExtractFeatures(msg string, history []providers.Message) Features {\n\treturn Features{\n\t\tTokenEstimate:     estimateTokens(msg),\n\t\tCodeBlockCount:    countCodeBlocks(msg),\n\t\tRecentToolCalls:   countRecentToolCalls(history),\n\t\tConversationDepth: len(history),\n\t\tHasAttachments:    hasAttachments(msg),\n\t}\n}\n\n// estimateTokens returns a token count proxy that handles both CJK and Latin text.\n// CJK runes (U+2E80–U+9FFF, U+F900–U+FAFF, U+AC00–U+D7AF) map to roughly one\n// token each, while non-CJK runes average ~0.25 tokens/rune (≈4 chars per token\n// for English). Splitting the count this way avoids the 3x underestimation that a\n// flat rune_count/3 would produce for Chinese, Japanese, and Korean text.\nfunc estimateTokens(msg string) int {\n\ttotal := utf8.RuneCountInString(msg)\n\tif total == 0 {\n\t\treturn 0\n\t}\n\tcjk := 0\n\tfor _, r := range msg {\n\t\tif r >= 0x2E80 && r <= 0x9FFF || r >= 0xF900 && r <= 0xFAFF || r >= 0xAC00 && r <= 0xD7AF {\n\t\t\tcjk++\n\t\t}\n\t}\n\treturn cjk + (total-cjk)/4\n}\n\n// countCodeBlocks counts the number of complete fenced code blocks.\n// Each ``` delimiter increments a counter; pairs of delimiters form one block.\n// An unclosed opening fence (odd count) is treated as zero complete blocks\n// since it may just be an inline code span or a typo.\nfunc countCodeBlocks(msg string) int {\n\tn := strings.Count(msg, \"```\")\n\treturn n / 2\n}\n\n// countRecentToolCalls counts messages with tool calls in the last lookbackWindow\n// entries of history. It examines the ToolCalls field rather than parsing\n// the content string, so it is robust to any message format.\nfunc countRecentToolCalls(history []providers.Message) int {\n\tstart := len(history) - lookbackWindow\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\n\tcount := 0\n\tfor _, msg := range history[start:] {\n\t\tif len(msg.ToolCalls) > 0 {\n\t\t\tcount += len(msg.ToolCalls)\n\t\t}\n\t}\n\treturn count\n}\n\n// hasAttachments returns true when the message content contains embedded media.\n// It checks for base64 data URIs (data:image/, data:audio/, data:video/) and\n// common image/audio URL extensions. This is intentionally conservative —\n// false negatives (missing an attachment) just mean the routing falls back to\n// the primary model anyway.\nfunc hasAttachments(msg string) bool {\n\tlower := strings.ToLower(msg)\n\n\t// Base64 data URIs embedded directly in the message\n\tif strings.Contains(lower, \"data:image/\") ||\n\t\tstrings.Contains(lower, \"data:audio/\") ||\n\t\tstrings.Contains(lower, \"data:video/\") {\n\t\treturn true\n\t}\n\n\t// Common image/audio extensions in URLs or file references\n\tmediaExts := []string{\n\t\t\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\",\n\t\t\".mp3\", \".wav\", \".ogg\", \".m4a\", \".flac\",\n\t\t\".mp4\", \".avi\", \".mov\", \".webm\",\n\t}\n\tfor _, ext := range mediaExts {\n\t\tif strings.Contains(lower, ext) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pkg/routing/route.go",
    "content": "package routing\n\nimport (\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// RouteInput contains the routing context from an inbound message.\ntype RouteInput struct {\n\tChannel    string\n\tAccountID  string\n\tPeer       *RoutePeer\n\tParentPeer *RoutePeer\n\tGuildID    string\n\tTeamID     string\n}\n\n// ResolvedRoute is the result of agent routing.\ntype ResolvedRoute struct {\n\tAgentID        string\n\tChannel        string\n\tAccountID      string\n\tSessionKey     string\n\tMainSessionKey string\n\tMatchedBy      string // \"binding.peer\", \"binding.peer.parent\", \"binding.guild\", \"binding.team\", \"binding.account\", \"binding.channel\", \"default\"\n}\n\n// RouteResolver determines which agent handles a message based on config bindings.\ntype RouteResolver struct {\n\tcfg *config.Config\n}\n\n// NewRouteResolver creates a new route resolver.\nfunc NewRouteResolver(cfg *config.Config) *RouteResolver {\n\treturn &RouteResolver{cfg: cfg}\n}\n\n// ResolveRoute determines which agent handles the message and constructs session keys.\n// Implements the 7-level priority cascade:\n// peer > parent_peer > guild > team > account > channel_wildcard > default\nfunc (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {\n\tchannel := strings.ToLower(strings.TrimSpace(input.Channel))\n\taccountID := NormalizeAccountID(input.AccountID)\n\tpeer := input.Peer\n\n\tdmScope := DMScope(r.cfg.Session.DMScope)\n\tif dmScope == \"\" {\n\t\tdmScope = DMScopeMain\n\t}\n\tidentityLinks := r.cfg.Session.IdentityLinks\n\n\tbindings := r.filterBindings(channel, accountID)\n\n\tchoose := func(agentID string, matchedBy string) ResolvedRoute {\n\t\tresolvedAgentID := r.pickAgentID(agentID)\n\t\tsessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{\n\t\t\tAgentID:       resolvedAgentID,\n\t\t\tChannel:       channel,\n\t\t\tAccountID:     accountID,\n\t\t\tPeer:          peer,\n\t\t\tDMScope:       dmScope,\n\t\t\tIdentityLinks: identityLinks,\n\t\t}))\n\t\tmainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID))\n\t\treturn ResolvedRoute{\n\t\t\tAgentID:        resolvedAgentID,\n\t\t\tChannel:        channel,\n\t\t\tAccountID:      accountID,\n\t\t\tSessionKey:     sessionKey,\n\t\t\tMainSessionKey: mainSessionKey,\n\t\t\tMatchedBy:      matchedBy,\n\t\t}\n\t}\n\n\t// Priority 1: Peer binding\n\tif peer != nil && strings.TrimSpace(peer.ID) != \"\" {\n\t\tif match := r.findPeerMatch(bindings, peer); match != nil {\n\t\t\treturn choose(match.AgentID, \"binding.peer\")\n\t\t}\n\t}\n\n\t// Priority 2: Parent peer binding\n\tparentPeer := input.ParentPeer\n\tif parentPeer != nil && strings.TrimSpace(parentPeer.ID) != \"\" {\n\t\tif match := r.findPeerMatch(bindings, parentPeer); match != nil {\n\t\t\treturn choose(match.AgentID, \"binding.peer.parent\")\n\t\t}\n\t}\n\n\t// Priority 3: Guild binding\n\tguildID := strings.TrimSpace(input.GuildID)\n\tif guildID != \"\" {\n\t\tif match := r.findGuildMatch(bindings, guildID); match != nil {\n\t\t\treturn choose(match.AgentID, \"binding.guild\")\n\t\t}\n\t}\n\n\t// Priority 4: Team binding\n\tteamID := strings.TrimSpace(input.TeamID)\n\tif teamID != \"\" {\n\t\tif match := r.findTeamMatch(bindings, teamID); match != nil {\n\t\t\treturn choose(match.AgentID, \"binding.team\")\n\t\t}\n\t}\n\n\t// Priority 5: Account binding\n\tif match := r.findAccountMatch(bindings); match != nil {\n\t\treturn choose(match.AgentID, \"binding.account\")\n\t}\n\n\t// Priority 6: Channel wildcard binding\n\tif match := r.findChannelWildcardMatch(bindings); match != nil {\n\t\treturn choose(match.AgentID, \"binding.channel\")\n\t}\n\n\t// Priority 7: Default agent\n\treturn choose(r.resolveDefaultAgentID(), \"default\")\n}\n\nfunc (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {\n\tvar filtered []config.AgentBinding\n\tfor _, b := range r.cfg.Bindings {\n\t\tmatchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))\n\t\tif matchChannel == \"\" || matchChannel != channel {\n\t\t\tcontinue\n\t\t}\n\t\tif !matchesAccountID(b.Match.AccountID, accountID) {\n\t\t\tcontinue\n\t\t}\n\t\tfiltered = append(filtered, b)\n\t}\n\treturn filtered\n}\n\nfunc matchesAccountID(matchAccountID, actual string) bool {\n\ttrimmed := strings.TrimSpace(matchAccountID)\n\tif trimmed == \"\" {\n\t\treturn actual == DefaultAccountID\n\t}\n\tif trimmed == \"*\" {\n\t\treturn true\n\t}\n\treturn strings.ToLower(trimmed) == strings.ToLower(actual)\n}\n\nfunc (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {\n\tfor i := range bindings {\n\t\tb := &bindings[i]\n\t\tif b.Match.Peer == nil {\n\t\t\tcontinue\n\t\t}\n\t\tpeerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))\n\t\tpeerID := strings.TrimSpace(b.Match.Peer.ID)\n\t\tif peerKind == \"\" || peerID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {\n\t\t\treturn b\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {\n\tfor i := range bindings {\n\t\tb := &bindings[i]\n\t\tmatchGuild := strings.TrimSpace(b.Match.GuildID)\n\t\tif matchGuild != \"\" && matchGuild == guildID {\n\t\t\treturn &bindings[i]\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {\n\tfor i := range bindings {\n\t\tb := &bindings[i]\n\t\tmatchTeam := strings.TrimSpace(b.Match.TeamID)\n\t\tif matchTeam != \"\" && matchTeam == teamID {\n\t\t\treturn &bindings[i]\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {\n\tfor i := range bindings {\n\t\tb := &bindings[i]\n\t\taccountID := strings.TrimSpace(b.Match.AccountID)\n\t\tif accountID == \"*\" {\n\t\t\tcontinue\n\t\t}\n\t\tif b.Match.Peer != nil || b.Match.GuildID != \"\" || b.Match.TeamID != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn &bindings[i]\n\t}\n\treturn nil\n}\n\nfunc (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {\n\tfor i := range bindings {\n\t\tb := &bindings[i]\n\t\taccountID := strings.TrimSpace(b.Match.AccountID)\n\t\tif accountID != \"*\" {\n\t\t\tcontinue\n\t\t}\n\t\tif b.Match.Peer != nil || b.Match.GuildID != \"\" || b.Match.TeamID != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn &bindings[i]\n\t}\n\treturn nil\n}\n\nfunc (r *RouteResolver) pickAgentID(agentID string) string {\n\ttrimmed := strings.TrimSpace(agentID)\n\tif trimmed == \"\" {\n\t\treturn NormalizeAgentID(r.resolveDefaultAgentID())\n\t}\n\tnormalized := NormalizeAgentID(trimmed)\n\tagents := r.cfg.Agents.List\n\tif len(agents) == 0 {\n\t\treturn normalized\n\t}\n\tfor _, a := range agents {\n\t\tif NormalizeAgentID(a.ID) == normalized {\n\t\t\treturn normalized\n\t\t}\n\t}\n\treturn NormalizeAgentID(r.resolveDefaultAgentID())\n}\n\nfunc (r *RouteResolver) resolveDefaultAgentID() string {\n\tagents := r.cfg.Agents.List\n\tif len(agents) == 0 {\n\t\treturn DefaultAgentID\n\t}\n\tfor _, a := range agents {\n\t\tif a.Default {\n\t\t\tid := strings.TrimSpace(a.ID)\n\t\t\tif id != \"\" {\n\t\t\t\treturn NormalizeAgentID(id)\n\t\t\t}\n\t\t}\n\t}\n\tif id := strings.TrimSpace(agents[0].ID); id != \"\" {\n\t\treturn NormalizeAgentID(id)\n\t}\n\treturn DefaultAgentID\n}\n"
  },
  {
    "path": "pkg/routing/route_test.go",
    "content": "package routing\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config {\n\treturn &config.Config{\n\t\tAgents: config.AgentsConfig{\n\t\t\tDefaults: config.AgentDefaults{\n\t\t\t\tWorkspace: \"/tmp/picoclaw-test\",\n\t\t\t\tModel:     \"gpt-4\",\n\t\t\t},\n\t\t\tList: agents,\n\t\t},\n\t\tBindings: bindings,\n\t\tSession: config.SessionConfig{\n\t\t\tDMScope: \"per-peer\",\n\t\t},\n\t}\n}\n\nfunc TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {\n\tcfg := testConfig(nil, nil)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user1\"},\n\t})\n\n\tif route.AgentID != DefaultAgentID {\n\t\tt.Errorf(\"AgentID = %q, want %q\", route.AgentID, DefaultAgentID)\n\t}\n\tif route.MatchedBy != \"default\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'default'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_PeerBinding(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"sales\", Default: true},\n\t\t{ID: \"support\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"support\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"telegram\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t\tPeer:      &config.PeerMatch{Kind: \"direct\", ID: \"user123\"},\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user123\"},\n\t})\n\n\tif route.AgentID != \"support\" {\n\t\tt.Errorf(\"AgentID = %q, want 'support'\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.peer\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.peer'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_GuildBinding(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"general\", Default: true},\n\t\t{ID: \"gaming\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"gaming\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"discord\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t\tGuildID:   \"guild-abc\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"discord\",\n\t\tGuildID: \"guild-abc\",\n\t\tPeer:    &RoutePeer{Kind: \"channel\", ID: \"ch1\"},\n\t})\n\n\tif route.AgentID != \"gaming\" {\n\t\tt.Errorf(\"AgentID = %q, want 'gaming'\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.guild\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.guild'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_TeamBinding(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"general\", Default: true},\n\t\t{ID: \"work\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"work\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"slack\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t\tTeamID:    \"T12345\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"slack\",\n\t\tTeamID:  \"T12345\",\n\t\tPeer:    &RoutePeer{Kind: \"channel\", ID: \"C001\"},\n\t})\n\n\tif route.AgentID != \"work\" {\n\t\tt.Errorf(\"AgentID = %q, want 'work'\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.team\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.team'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_AccountBinding(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"default-agent\", Default: true},\n\t\t{ID: \"premium\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"premium\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"telegram\",\n\t\t\t\tAccountID: \"bot2\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel:   \"telegram\",\n\t\tAccountID: \"bot2\",\n\t\tPeer:      &RoutePeer{Kind: \"direct\", ID: \"user1\"},\n\t})\n\n\tif route.AgentID != \"premium\" {\n\t\tt.Errorf(\"AgentID = %q, want 'premium'\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.account\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.account'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_ChannelWildcard(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"main\", Default: true},\n\t\t{ID: \"telegram-bot\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"telegram-bot\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"telegram\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user1\"},\n\t})\n\n\tif route.AgentID != \"telegram-bot\" {\n\t\tt.Errorf(\"AgentID = %q, want 'telegram-bot'\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.channel\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.channel'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"general\", Default: true},\n\t\t{ID: \"vip\"},\n\t\t{ID: \"gaming\"},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"vip\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"discord\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t\tPeer:      &config.PeerMatch{Kind: \"direct\", ID: \"user-vip\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAgentID: \"gaming\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"discord\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t\tGuildID:   \"guild-1\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"discord\",\n\t\tGuildID: \"guild-1\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user-vip\"},\n\t})\n\n\tif route.AgentID != \"vip\" {\n\t\tt.Errorf(\"AgentID = %q, want 'vip' (peer should beat guild)\", route.AgentID)\n\t}\n\tif route.MatchedBy != \"binding.peer\" {\n\t\tt.Errorf(\"MatchedBy = %q, want 'binding.peer'\", route.MatchedBy)\n\t}\n}\n\nfunc TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"main\", Default: true},\n\t}\n\tbindings := []config.AgentBinding{\n\t\t{\n\t\t\tAgentID: \"nonexistent\",\n\t\t\tMatch: config.BindingMatch{\n\t\t\t\tChannel:   \"telegram\",\n\t\t\t\tAccountID: \"*\",\n\t\t\t},\n\t\t},\n\t}\n\tcfg := testConfig(agents, bindings)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"telegram\",\n\t})\n\n\tif route.AgentID != \"main\" {\n\t\tt.Errorf(\"AgentID = %q, want 'main' (invalid agent should fall to default)\", route.AgentID)\n\t}\n}\n\nfunc TestResolveRoute_DefaultAgentSelection(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"alpha\"},\n\t\t{ID: \"beta\", Default: true},\n\t\t{ID: \"gamma\"},\n\t}\n\tcfg := testConfig(agents, nil)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"cli\",\n\t})\n\n\tif route.AgentID != \"beta\" {\n\t\tt.Errorf(\"AgentID = %q, want 'beta' (marked as default)\", route.AgentID)\n\t}\n}\n\nfunc TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {\n\tagents := []config.AgentConfig{\n\t\t{ID: \"alpha\"},\n\t\t{ID: \"beta\"},\n\t}\n\tcfg := testConfig(agents, nil)\n\tr := NewRouteResolver(cfg)\n\n\troute := r.ResolveRoute(RouteInput{\n\t\tChannel: \"cli\",\n\t})\n\n\tif route.AgentID != \"alpha\" {\n\t\tt.Errorf(\"AgentID = %q, want 'alpha' (first in list)\", route.AgentID)\n\t}\n}\n"
  },
  {
    "path": "pkg/routing/router.go",
    "content": "package routing\n\nimport (\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// defaultThreshold is used when the config threshold is zero or negative.\n// At 0.35 a message needs at least one strong signal (code block, long text,\n// or an attachment) before the heavy model is chosen.\nconst defaultThreshold = 0.35\n\n// RouterConfig holds the validated model routing settings.\n// It mirrors config.RoutingConfig but lives in pkg/routing to keep the\n// dependency graph simple: pkg/agent resolves config → routing, not the reverse.\ntype RouterConfig struct {\n\t// LightModel is the model_name (from model_list) used for simple tasks.\n\tLightModel string\n\n\t// Threshold is the complexity score cutoff in [0, 1].\n\t// score >= Threshold → primary (heavy) model.\n\t// score <  Threshold → light model.\n\tThreshold float64\n}\n\n// Router selects the appropriate model tier for each incoming message.\n// It is safe for concurrent use from multiple goroutines.\ntype Router struct {\n\tcfg        RouterConfig\n\tclassifier Classifier\n}\n\n// New creates a Router with the given config and the default RuleClassifier.\n// If cfg.Threshold is zero or negative, defaultThreshold (0.35) is used.\nfunc New(cfg RouterConfig) *Router {\n\tif cfg.Threshold <= 0 {\n\t\tcfg.Threshold = defaultThreshold\n\t}\n\treturn &Router{\n\t\tcfg:        cfg,\n\t\tclassifier: &RuleClassifier{},\n\t}\n}\n\n// newWithClassifier creates a Router with a custom Classifier.\n// Intended for unit tests that need to inject a deterministic scorer.\nfunc newWithClassifier(cfg RouterConfig, c Classifier) *Router {\n\tif cfg.Threshold <= 0 {\n\t\tcfg.Threshold = defaultThreshold\n\t}\n\treturn &Router{cfg: cfg, classifier: c}\n}\n\n// SelectModel returns the model to use for this conversation turn along with\n// the computed complexity score (for logging and debugging).\n//\n//   - If score < cfg.Threshold: returns (cfg.LightModel, true, score)\n//   - Otherwise:               returns (primaryModel, false, score)\n//\n// The caller is responsible for resolving the returned model name into\n// provider candidates (see AgentInstance.LightCandidates).\nfunc (r *Router) SelectModel(\n\tmsg string,\n\thistory []providers.Message,\n\tprimaryModel string,\n) (model string, usedLight bool, score float64) {\n\tfeatures := ExtractFeatures(msg, history)\n\tscore = r.classifier.Score(features)\n\tif score < r.cfg.Threshold {\n\t\treturn r.cfg.LightModel, true, score\n\t}\n\treturn primaryModel, false, score\n}\n\n// LightModel returns the configured light model name.\nfunc (r *Router) LightModel() string {\n\treturn r.cfg.LightModel\n}\n\n// Threshold returns the complexity threshold in use.\nfunc (r *Router) Threshold() float64 {\n\treturn r.cfg.Threshold\n}\n"
  },
  {
    "path": "pkg/routing/router_test.go",
    "content": "package routing\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// ── ExtractFeatures ──────────────────────────────────────────────────────────\n\nfunc TestExtractFeatures_EmptyMessage(t *testing.T) {\n\tf := ExtractFeatures(\"\", nil)\n\tif f.TokenEstimate != 0 {\n\t\tt.Errorf(\"TokenEstimate: got %d, want 0\", f.TokenEstimate)\n\t}\n\tif f.CodeBlockCount != 0 {\n\t\tt.Errorf(\"CodeBlockCount: got %d, want 0\", f.CodeBlockCount)\n\t}\n\tif f.RecentToolCalls != 0 {\n\t\tt.Errorf(\"RecentToolCalls: got %d, want 0\", f.RecentToolCalls)\n\t}\n\tif f.ConversationDepth != 0 {\n\t\tt.Errorf(\"ConversationDepth: got %d, want 0\", f.ConversationDepth)\n\t}\n\tif f.HasAttachments {\n\t\tt.Error(\"HasAttachments: got true, want false\")\n\t}\n}\n\nfunc TestExtractFeatures_TokenEstimate(t *testing.T) {\n\t// 30 ASCII runes: 0 CJK + 30/4 = 7 tokens\n\tmsg := strings.Repeat(\"a\", 30)\n\tf := ExtractFeatures(msg, nil)\n\tif f.TokenEstimate != 7 {\n\t\tt.Errorf(\"TokenEstimate: got %d, want 7\", f.TokenEstimate)\n\t}\n}\n\nfunc TestExtractFeatures_TokenEstimate_CJK(t *testing.T) {\n\t// 9 CJK runes → 9 tokens (each CJK rune ≈ 1 token).\n\t// Using a rune slice literal avoids CJK string literals in source.\n\tmsg := string([]rune{\n\t\t0x4F60, 0x597D, 0x4E16, 0x754C,\n\t\t0x4F60, 0x597D, 0x4E16, 0x754C,\n\t\t0x4F60,\n\t})\n\tf := ExtractFeatures(msg, nil)\n\tif f.TokenEstimate != 9 {\n\t\tt.Errorf(\"CJK TokenEstimate: got %d, want 9\", f.TokenEstimate)\n\t}\n}\n\nfunc TestExtractFeatures_TokenEstimate_Mixed(t *testing.T) {\n\t// Mixed: 4 CJK runes + 8 ASCII runes → 4 + 8/4 = 6 tokens.\n\tmsg := string([]rune{0x4F60, 0x597D, 0x4E16, 0x754C}) + \"hello ok\"\n\tf := ExtractFeatures(msg, nil)\n\tif f.TokenEstimate != 6 {\n\t\tt.Errorf(\"Mixed TokenEstimate: got %d, want 6\", f.TokenEstimate)\n\t}\n}\n\nfunc TestExtractFeatures_CodeBlocks(t *testing.T) {\n\tcases := []struct {\n\t\tmsg  string\n\t\twant int\n\t}{\n\t\t{\"no code here\", 0},\n\t\t{\"```go\\nfmt.Println()\\n```\", 1},\n\t\t{\"```python\\npass\\n```\\n```js\\nconsole.log()\\n```\", 2},\n\t\t{\"```unclosed\", 0}, // odd number of fences = 0 complete blocks\n\t}\n\tfor _, tc := range cases {\n\t\tf := ExtractFeatures(tc.msg, nil)\n\t\tif f.CodeBlockCount != tc.want {\n\t\t\tt.Errorf(\"msg=%q: CodeBlockCount got %d, want %d\", tc.msg, f.CodeBlockCount, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestExtractFeatures_RecentToolCalls(t *testing.T) {\n\t// History longer than lookbackWindow — only last lookbackWindow entries count.\n\thistory := make([]providers.Message, 10)\n\t// Put 2 tool calls at positions 8 and 9 (within the last 6)\n\thistory[8] = providers.Message{Role: \"assistant\", ToolCalls: []providers.ToolCall{{Name: \"exec\"}}}\n\thistory[9] = providers.Message{\n\t\tRole:      \"assistant\",\n\t\tToolCalls: []providers.ToolCall{{Name: \"read_file\"}, {Name: \"write_file\"}},\n\t}\n\t// Position 3 is outside the lookback window and must NOT be counted\n\thistory[3] = providers.Message{Role: \"assistant\", ToolCalls: []providers.ToolCall{{Name: \"old_tool\"}}}\n\n\tf := ExtractFeatures(\"test\", history)\n\t// 1 (position 8) + 2 (position 9) = 3\n\tif f.RecentToolCalls != 3 {\n\t\tt.Errorf(\"RecentToolCalls: got %d, want 3\", f.RecentToolCalls)\n\t}\n}\n\nfunc TestExtractFeatures_ConversationDepth(t *testing.T) {\n\thistory := make([]providers.Message, 7)\n\tf := ExtractFeatures(\"msg\", history)\n\tif f.ConversationDepth != 7 {\n\t\tt.Errorf(\"ConversationDepth: got %d, want 7\", f.ConversationDepth)\n\t}\n}\n\nfunc TestExtractFeatures_HasAttachments_DataURI(t *testing.T) {\n\tcases := []struct {\n\t\tmsg  string\n\t\twant bool\n\t}{\n\t\t{\"plain text\", false},\n\t\t{\"here is an image: data:image/png;base64,abc123\", true},\n\t\t{\"audio: data:audio/mp3;base64,xyz\", true},\n\t\t{\"video: data:video/mp4;base64,xyz\", true},\n\t}\n\tfor _, tc := range cases {\n\t\tf := ExtractFeatures(tc.msg, nil)\n\t\tif f.HasAttachments != tc.want {\n\t\t\tt.Errorf(\"msg=%q: HasAttachments got %v, want %v\", tc.msg, f.HasAttachments, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestExtractFeatures_HasAttachments_Extension(t *testing.T) {\n\tcases := []struct {\n\t\tmsg  string\n\t\twant bool\n\t}{\n\t\t{\"check out photo.jpg\", true},\n\t\t{\"see screenshot.png\", true},\n\t\t{\"listen to audio.mp3\", true},\n\t\t{\"watch clip.mp4\", true},\n\t\t{\"just a .go file\", false},\n\t\t{\"document.pdf\", false}, // pdf is not in the media list\n\t}\n\tfor _, tc := range cases {\n\t\tf := ExtractFeatures(tc.msg, nil)\n\t\tif f.HasAttachments != tc.want {\n\t\t\tt.Errorf(\"msg=%q: HasAttachments got %v, want %v\", tc.msg, f.HasAttachments, tc.want)\n\t\t}\n\t}\n}\n\n// ── RuleClassifier ───────────────────────────────────────────────────────────\n\nfunc TestRuleClassifier_ZeroFeatures(t *testing.T) {\n\tc := &RuleClassifier{}\n\tscore := c.Score(Features{})\n\tif score != 0.0 {\n\t\tt.Errorf(\"zero features: got %f, want 0.0\", score)\n\t}\n}\n\nfunc TestRuleClassifier_AttachmentsHardGate(t *testing.T) {\n\tc := &RuleClassifier{}\n\tscore := c.Score(Features{HasAttachments: true})\n\tif score != 1.0 {\n\t\tt.Errorf(\"attachments: got %f, want 1.0\", score)\n\t}\n}\n\nfunc TestRuleClassifier_CodeBlockAlone(t *testing.T) {\n\tc := &RuleClassifier{}\n\t// Code block alone = 0.40, above default threshold 0.35\n\tscore := c.Score(Features{CodeBlockCount: 1})\n\tif score < 0.35 {\n\t\tt.Errorf(\"code block: score %f is below default threshold 0.35\", score)\n\t}\n}\n\nfunc TestRuleClassifier_LongMessage(t *testing.T) {\n\tc := &RuleClassifier{}\n\t// >200 tokens = 0.35, exactly at default threshold → heavy\n\tscore := c.Score(Features{TokenEstimate: 250})\n\tif score < 0.35 {\n\t\tt.Errorf(\"long message: score %f is below default threshold 0.35\", score)\n\t}\n}\n\nfunc TestRuleClassifier_MediumMessage(t *testing.T) {\n\tc := &RuleClassifier{}\n\t// 50-200 tokens = 0.15, below threshold → light\n\tscore := c.Score(Features{TokenEstimate: 100})\n\tif score >= 0.35 {\n\t\tt.Errorf(\"medium message: score %f should be below default threshold 0.35\", score)\n\t}\n}\n\nfunc TestRuleClassifier_ShortMessage(t *testing.T) {\n\tc := &RuleClassifier{}\n\t// <50 tokens, no other signals = 0.0 → light\n\tscore := c.Score(Features{TokenEstimate: 10})\n\tif score != 0.0 {\n\t\tt.Errorf(\"short message: got %f, want 0.0\", score)\n\t}\n}\n\nfunc TestRuleClassifier_ToolCallDensity(t *testing.T) {\n\tc := &RuleClassifier{}\n\n\tscoreNone := c.Score(Features{RecentToolCalls: 0})\n\tscoreLow := c.Score(Features{RecentToolCalls: 2})\n\tscoreHigh := c.Score(Features{RecentToolCalls: 5})\n\n\tif scoreNone != 0.0 {\n\t\tt.Errorf(\"no tools: got %f, want 0.0\", scoreNone)\n\t}\n\tif scoreLow <= scoreNone {\n\t\tt.Errorf(\"low tools should score higher than none: %f vs %f\", scoreLow, scoreNone)\n\t}\n\tif scoreHigh <= scoreLow {\n\t\tt.Errorf(\"high tools should score higher than low: %f vs %f\", scoreHigh, scoreLow)\n\t}\n}\n\nfunc TestRuleClassifier_DeepConversation(t *testing.T) {\n\tc := &RuleClassifier{}\n\tshallow := c.Score(Features{ConversationDepth: 5})\n\tdeep := c.Score(Features{ConversationDepth: 15})\n\tif deep <= shallow {\n\t\tt.Errorf(\"deep conversation should score higher: %f vs %f\", deep, shallow)\n\t}\n}\n\nfunc TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {\n\tc := &RuleClassifier{}\n\t// Max all signals simultaneously\n\tf := Features{\n\t\tTokenEstimate:     500,\n\t\tCodeBlockCount:    3,\n\t\tRecentToolCalls:   10,\n\t\tConversationDepth: 20,\n\t}\n\tscore := c.Score(f)\n\tif score > 1.0 {\n\t\tt.Errorf(\"score %f exceeds 1.0\", score)\n\t}\n}\n\n// ── Router ───────────────────────────────────────────────────────────────────\n\nfunc TestRouter_DefaultThreshold(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\"})\n\tif r.Threshold() != defaultThreshold {\n\t\tt.Errorf(\"default threshold: got %f, want %f\", r.Threshold(), defaultThreshold)\n\t}\n}\n\nfunc TestRouter_NegativeThresholdFallsBackToDefault(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: -0.1})\n\tif r.Threshold() != defaultThreshold {\n\t\tt.Errorf(\"negative threshold: got %f, want %f\", r.Threshold(), defaultThreshold)\n\t}\n}\n\nfunc TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\tmsg := \"hi\"\n\tmodel, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif !usedLight {\n\t\tt.Error(\"simple message: expected light model to be selected\")\n\t}\n\tif model != \"gemini-flash\" {\n\t\tt.Errorf(\"simple message: model got %q, want %q\", model, \"gemini-flash\")\n\t}\n}\n\nfunc TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\tmsg := \"```go\\nfmt.Println(\\\"hello\\\")\\n```\"\n\tmodel, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif usedLight {\n\t\tt.Error(\"code block: expected primary model to be selected\")\n\t}\n\tif model != \"claude-sonnet-4-6\" {\n\t\tt.Errorf(\"code block: model got %q, want %q\", model, \"claude-sonnet-4-6\")\n\t}\n}\n\nfunc TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\tmsg := \"can you analyze this? data:image/png;base64,abc123\"\n\tmodel, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif usedLight {\n\t\tt.Error(\"attachment: expected primary model to be selected\")\n\t}\n\tif model != \"claude-sonnet-4-6\" {\n\t\tt.Errorf(\"attachment: model got %q, want %q\", model, \"claude-sonnet-4-6\")\n\t}\n}\n\nfunc TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\t// >200 token estimate: 210 * 3 = 630 chars\n\tmsg := strings.Repeat(\"word \", 210)\n\tmodel, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif usedLight {\n\t\tt.Error(\"long message: expected primary model to be selected\")\n\t}\n\tif model != \"claude-sonnet-4-6\" {\n\t\tt.Errorf(\"long message: model got %q, want %q\", model, \"claude-sonnet-4-6\")\n\t}\n}\n\nfunc TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {\n\t// Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior.\n\t// Routing is conservative: only promote to heavy when the signal is unambiguous.\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\thistory := []providers.Message{\n\t\t{Role: \"assistant\", ToolCalls: []providers.ToolCall{{Name: \"read_file\"}, {Name: \"write_file\"}}},\n\t\t{Role: \"assistant\", ToolCalls: []providers.ToolCall{{Name: \"exec\"}, {Name: \"search\"}}},\n\t}\n\tmsg := \"ok\"\n\t_, usedLight, _ := r.SelectModel(msg, history, \"claude-sonnet-4-6\")\n\tif !usedLight {\n\t\tt.Error(\"short message + moderate tool calls: expected light model (score 0.20 < 0.35)\")\n\t}\n}\n\nfunc TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {\n\t// Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.35})\n\thistory := []providers.Message{\n\t\t{Role: \"assistant\", ToolCalls: []providers.ToolCall{\n\t\t\t{Name: \"a\"}, {Name: \"b\"}, {Name: \"c\"}, {Name: \"d\"},\n\t\t}},\n\t}\n\t// ~55 tokens * 3 = 165 chars\n\tmsg := strings.Repeat(\"word \", 55)\n\t_, usedLight, _ := r.SelectModel(msg, history, \"claude-sonnet-4-6\")\n\tif usedLight {\n\t\tt.Error(\"tool chain + medium message: expected primary model (score >= 0.35)\")\n\t}\n}\n\nfunc TestRouter_SelectModel_CustomThreshold(t *testing.T) {\n\t// Very low threshold: even a short message triggers heavy model\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.05})\n\tmsg := strings.Repeat(\"word \", 55) // medium message → 0.15 >= 0.05\n\t_, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif usedLight {\n\t\tt.Error(\"low threshold: medium message should use primary model\")\n\t}\n}\n\nfunc TestRouter_SelectModel_HighThreshold(t *testing.T) {\n\t// Very high threshold: even code blocks route to light\n\tr := New(RouterConfig{LightModel: \"gemini-flash\", Threshold: 0.99})\n\tmsg := \"```go\\nfmt.Println()\\n```\"\n\t_, usedLight, _ := r.SelectModel(msg, nil, \"claude-sonnet-4-6\")\n\tif !usedLight {\n\t\tt.Error(\"very high threshold: code block (0.40) should route to light model\")\n\t}\n}\n\nfunc TestRouter_LightModel(t *testing.T) {\n\tr := New(RouterConfig{LightModel: \"my-fast-model\", Threshold: 0.35})\n\tif r.LightModel() != \"my-fast-model\" {\n\t\tt.Errorf(\"LightModel: got %q, want %q\", r.LightModel(), \"my-fast-model\")\n\t}\n}\n\n// ── newWithClassifier (internal testing hook) ─────────────────────────────────\n\ntype fixedScoreClassifier struct{ score float64 }\n\nfunc (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score }\n\nfunc TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) {\n\tr := newWithClassifier(\n\t\tRouterConfig{LightModel: \"light\", Threshold: 0.5},\n\t\t&fixedScoreClassifier{score: 0.2},\n\t)\n\t_, usedLight, _ := r.SelectModel(\"anything\", nil, \"heavy\")\n\tif !usedLight {\n\t\tt.Error(\"low score with custom classifier: expected light model\")\n\t}\n}\n\nfunc TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) {\n\tr := newWithClassifier(\n\t\tRouterConfig{LightModel: \"light\", Threshold: 0.5},\n\t\t&fixedScoreClassifier{score: 0.8},\n\t)\n\t_, usedLight, _ := r.SelectModel(\"anything\", nil, \"heavy\")\n\tif usedLight {\n\t\tt.Error(\"high score with custom classifier: expected primary model\")\n\t}\n}\n\nfunc TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) {\n\t// score == threshold → primary (uses >= comparison)\n\tr := newWithClassifier(\n\t\tRouterConfig{LightModel: \"light\", Threshold: 0.5},\n\t\t&fixedScoreClassifier{score: 0.5},\n\t)\n\t_, usedLight, _ := r.SelectModel(\"anything\", nil, \"heavy\")\n\tif usedLight {\n\t\tt.Error(\"score == threshold: expected primary model (>= threshold → primary)\")\n\t}\n}\n\nfunc TestRouter_SelectModel_ReturnsScore(t *testing.T) {\n\tr := newWithClassifier(\n\t\tRouterConfig{LightModel: \"light\", Threshold: 0.5},\n\t\t&fixedScoreClassifier{score: 0.42},\n\t)\n\t_, _, score := r.SelectModel(\"anything\", nil, \"heavy\")\n\tif score != 0.42 {\n\t\tt.Errorf(\"score: got %f, want 0.42\", score)\n\t}\n}\n"
  },
  {
    "path": "pkg/routing/session_key.go",
    "content": "package routing\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// DMScope controls DM session isolation granularity.\ntype DMScope string\n\nconst (\n\tDMScopeMain                  DMScope = \"main\"\n\tDMScopePerPeer               DMScope = \"per-peer\"\n\tDMScopePerChannelPeer        DMScope = \"per-channel-peer\"\n\tDMScopePerAccountChannelPeer DMScope = \"per-account-channel-peer\"\n)\n\n// RoutePeer represents a chat peer with kind and ID.\ntype RoutePeer struct {\n\tKind string // \"direct\", \"group\", \"channel\"\n\tID   string\n}\n\n// SessionKeyParams holds all inputs for session key construction.\ntype SessionKeyParams struct {\n\tAgentID       string\n\tChannel       string\n\tAccountID     string\n\tPeer          *RoutePeer\n\tDMScope       DMScope\n\tIdentityLinks map[string][]string\n}\n\n// ParsedSessionKey is the result of parsing an agent-scoped session key.\ntype ParsedSessionKey struct {\n\tAgentID string\n\tRest    string\n}\n\n// BuildAgentMainSessionKey returns \"agent:<agentId>:main\".\nfunc BuildAgentMainSessionKey(agentID string) string {\n\treturn fmt.Sprintf(\"agent:%s:%s\", NormalizeAgentID(agentID), DefaultMainKey)\n}\n\n// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.\nfunc BuildAgentPeerSessionKey(params SessionKeyParams) string {\n\tagentID := NormalizeAgentID(params.AgentID)\n\n\tpeer := params.Peer\n\tif peer == nil {\n\t\tpeer = &RoutePeer{Kind: \"direct\"}\n\t}\n\tpeerKind := strings.TrimSpace(peer.Kind)\n\tif peerKind == \"\" {\n\t\tpeerKind = \"direct\"\n\t}\n\n\tif peerKind == \"direct\" {\n\t\tdmScope := params.DMScope\n\t\tif dmScope == \"\" {\n\t\t\tdmScope = DMScopeMain\n\t\t}\n\t\tpeerID := strings.TrimSpace(peer.ID)\n\n\t\t// Resolve identity links (cross-platform collapse)\n\t\tif dmScope != DMScopeMain && peerID != \"\" {\n\t\t\tif linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != \"\" {\n\t\t\t\tpeerID = linked\n\t\t\t}\n\t\t}\n\t\tpeerID = strings.ToLower(peerID)\n\n\t\tswitch dmScope {\n\t\tcase DMScopePerAccountChannelPeer:\n\t\t\tif peerID != \"\" {\n\t\t\t\tchannel := normalizeChannel(params.Channel)\n\t\t\t\taccountID := NormalizeAccountID(params.AccountID)\n\t\t\t\treturn fmt.Sprintf(\"agent:%s:%s:%s:direct:%s\", agentID, channel, accountID, peerID)\n\t\t\t}\n\t\tcase DMScopePerChannelPeer:\n\t\t\tif peerID != \"\" {\n\t\t\t\tchannel := normalizeChannel(params.Channel)\n\t\t\t\treturn fmt.Sprintf(\"agent:%s:%s:direct:%s\", agentID, channel, peerID)\n\t\t\t}\n\t\tcase DMScopePerPeer:\n\t\t\tif peerID != \"\" {\n\t\t\t\treturn fmt.Sprintf(\"agent:%s:direct:%s\", agentID, peerID)\n\t\t\t}\n\t\t}\n\t\treturn BuildAgentMainSessionKey(agentID)\n\t}\n\n\t// Group/channel peers always get per-peer sessions\n\tchannel := normalizeChannel(params.Channel)\n\tpeerID := strings.ToLower(strings.TrimSpace(peer.ID))\n\tif peerID == \"\" {\n\t\tpeerID = \"unknown\"\n\t}\n\treturn fmt.Sprintf(\"agent:%s:%s:%s:%s\", agentID, channel, peerKind, peerID)\n}\n\n// ParseAgentSessionKey extracts agentId and rest from \"agent:<agentId>:<rest>\".\nfunc ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {\n\traw := strings.TrimSpace(sessionKey)\n\tif raw == \"\" {\n\t\treturn nil\n\t}\n\tparts := strings.SplitN(raw, \":\", 3)\n\tif len(parts) < 3 {\n\t\treturn nil\n\t}\n\tif parts[0] != \"agent\" {\n\t\treturn nil\n\t}\n\tagentID := strings.TrimSpace(parts[1])\n\trest := parts[2]\n\tif agentID == \"\" || rest == \"\" {\n\t\treturn nil\n\t}\n\treturn &ParsedSessionKey{AgentID: agentID, Rest: rest}\n}\n\n// IsSubagentSessionKey returns true if the session key represents a subagent.\nfunc IsSubagentSessionKey(sessionKey string) bool {\n\traw := strings.TrimSpace(sessionKey)\n\tif raw == \"\" {\n\t\treturn false\n\t}\n\tif strings.HasPrefix(strings.ToLower(raw), \"subagent:\") {\n\t\treturn true\n\t}\n\tparsed := ParseAgentSessionKey(raw)\n\tif parsed == nil {\n\t\treturn false\n\t}\n\treturn strings.HasPrefix(strings.ToLower(parsed.Rest), \"subagent:\")\n}\n\nfunc normalizeChannel(channel string) string {\n\tc := strings.TrimSpace(strings.ToLower(channel))\n\tif c == \"\" {\n\t\treturn \"unknown\"\n\t}\n\treturn c\n}\n\nfunc resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {\n\tif len(identityLinks) == 0 {\n\t\treturn \"\"\n\t}\n\tpeerID = strings.TrimSpace(peerID)\n\tif peerID == \"\" {\n\t\treturn \"\"\n\t}\n\n\tcandidates := make(map[string]bool)\n\trawCandidate := strings.ToLower(peerID)\n\tif rawCandidate != \"\" {\n\t\tcandidates[rawCandidate] = true\n\t}\n\tchannel = strings.ToLower(strings.TrimSpace(channel))\n\tif channel != \"\" {\n\t\tscopedCandidate := fmt.Sprintf(\"%s:%s\", channel, strings.ToLower(peerID))\n\t\tcandidates[scopedCandidate] = true\n\t}\n\n\t// If peerID is already in canonical \"platform:id\" format, also add the\n\t// bare ID part as a candidate for backward compatibility with identity_links\n\t// that use raw IDs (e.g. \"123\" instead of \"telegram:123\").\n\tif idx := strings.Index(rawCandidate, \":\"); idx > 0 && idx < len(rawCandidate)-1 {\n\t\tbareID := rawCandidate[idx+1:]\n\t\tcandidates[bareID] = true\n\t}\n\n\tif len(candidates) == 0 {\n\t\treturn \"\"\n\t}\n\n\tfor canonical, ids := range identityLinks {\n\t\tcanonicalName := strings.TrimSpace(canonical)\n\t\tif canonicalName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, id := range ids {\n\t\t\tnormalized := strings.ToLower(strings.TrimSpace(id))\n\t\t\tif normalized != \"\" && candidates[normalized] {\n\t\t\t\treturn canonicalName\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "pkg/routing/session_key_test.go",
    "content": "package routing\n\nimport \"testing\"\n\nfunc TestBuildAgentMainSessionKey(t *testing.T) {\n\tgot := BuildAgentMainSessionKey(\"sales\")\n\twant := \"agent:sales:main\"\n\tif got != want {\n\t\tt.Errorf(\"BuildAgentMainSessionKey('sales') = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {\n\tgot := BuildAgentMainSessionKey(\"Sales Bot\")\n\twant := \"agent:sales-bot:main\"\n\tif got != want {\n\t\tt.Errorf(\"BuildAgentMainSessionKey('Sales Bot') = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID: \"main\",\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user123\"},\n\t\tDMScope: DMScopeMain,\n\t})\n\twant := \"agent:main:main\"\n\tif got != want {\n\t\tt.Errorf(\"DMScopeMain = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID: \"main\",\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user123\"},\n\t\tDMScope: DMScopePerPeer,\n\t})\n\twant := \"agent:main:direct:user123\"\n\tif got != want {\n\t\tt.Errorf(\"DMScopePerPeer = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID: \"main\",\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"direct\", ID: \"user123\"},\n\t\tDMScope: DMScopePerChannelPeer,\n\t})\n\twant := \"agent:main:telegram:direct:user123\"\n\tif got != want {\n\t\tt.Errorf(\"DMScopePerChannelPeer = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID:   \"main\",\n\t\tChannel:   \"telegram\",\n\t\tAccountID: \"bot1\",\n\t\tPeer:      &RoutePeer{Kind: \"direct\", ID: \"User123\"},\n\t\tDMScope:   DMScopePerAccountChannelPeer,\n\t})\n\twant := \"agent:main:telegram:bot1:direct:user123\"\n\tif got != want {\n\t\tt.Errorf(\"DMScopePerAccountChannelPeer = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID: \"main\",\n\t\tChannel: \"telegram\",\n\t\tPeer:    &RoutePeer{Kind: \"group\", ID: \"chat456\"},\n\t\tDMScope: DMScopePerPeer,\n\t})\n\twant := \"agent:main:telegram:group:chat456\"\n\tif got != want {\n\t\tt.Errorf(\"GroupPeer = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID: \"main\",\n\t\tChannel: \"telegram\",\n\t\tPeer:    nil,\n\t\tDMScope: DMScopePerPeer,\n\t})\n\t// nil peer defaults to direct with empty ID, falls to main\n\twant := \"agent:main:main\"\n\tif got != want {\n\t\tt.Errorf(\"NilPeer = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {\n\tlinks := map[string][]string{\n\t\t\"john\": {\"telegram:user123\", \"discord:john#1234\"},\n\t}\n\tgot := BuildAgentPeerSessionKey(SessionKeyParams{\n\t\tAgentID:       \"main\",\n\t\tChannel:       \"telegram\",\n\t\tPeer:          &RoutePeer{Kind: \"direct\", ID: \"user123\"},\n\t\tDMScope:       DMScopePerPeer,\n\t\tIdentityLinks: links,\n\t})\n\twant := \"agent:main:direct:john\"\n\tif got != want {\n\t\tt.Errorf(\"IdentityLink = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {\n\t// When peerID is already in canonical \"platform:id\" format,\n\t// it should match identity_links that use the bare ID.\n\tlinks := map[string][]string{\n\t\t\"john\": {\"123\"},\n\t}\n\tgot := resolveLinkedPeerID(links, \"telegram\", \"telegram:123\")\n\tif got != \"john\" {\n\t\tt.Errorf(\"resolveLinkedPeerID with canonical peerID = %q, want %q\", got, \"john\")\n\t}\n}\n\nfunc TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {\n\t// When identity_links contain canonical IDs and peerID is canonical too\n\tlinks := map[string][]string{\n\t\t\"john\": {\"telegram:123\", \"discord:456\"},\n\t}\n\tgot := resolveLinkedPeerID(links, \"telegram\", \"telegram:123\")\n\tif got != \"john\" {\n\t\tt.Errorf(\"resolveLinkedPeerID canonical in links = %q, want %q\", got, \"john\")\n\t}\n}\n\nfunc TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {\n\t// When peerID is bare \"123\" and links have \"telegram:123\",\n\t// the scoped candidate \"telegram:123\" should match.\n\tlinks := map[string][]string{\n\t\t\"john\": {\"telegram:123\"},\n\t}\n\tgot := resolveLinkedPeerID(links, \"telegram\", \"123\")\n\tif got != \"john\" {\n\t\tt.Errorf(\"resolveLinkedPeerID bare peer matches canonical link = %q, want %q\", got, \"john\")\n\t}\n}\n\nfunc TestResolveLinkedPeerID_NoMatch(t *testing.T) {\n\tlinks := map[string][]string{\n\t\t\"john\": {\"telegram:123\"},\n\t}\n\tgot := resolveLinkedPeerID(links, \"discord\", \"999\")\n\tif got != \"\" {\n\t\tt.Errorf(\"resolveLinkedPeerID no match = %q, want empty\", got)\n\t}\n}\n\nfunc TestParseAgentSessionKey_Valid(t *testing.T) {\n\tparsed := ParseAgentSessionKey(\"agent:sales:telegram:direct:user123\")\n\tif parsed == nil {\n\t\tt.Fatal(\"expected non-nil result\")\n\t}\n\tif parsed.AgentID != \"sales\" {\n\t\tt.Errorf(\"AgentID = %q, want 'sales'\", parsed.AgentID)\n\t}\n\tif parsed.Rest != \"telegram:direct:user123\" {\n\t\tt.Errorf(\"Rest = %q, want 'telegram:direct:user123'\", parsed.Rest)\n\t}\n}\n\nfunc TestParseAgentSessionKey_Invalid(t *testing.T) {\n\ttests := []string{\n\t\t\"\",\n\t\t\"foo:bar\",\n\t\t\"notprefix:sales:main\",\n\t\t\"agent::main\",\n\t\t\"agent:sales:\",\n\t}\n\tfor _, input := range tests {\n\t\tif got := ParseAgentSessionKey(input); got != nil {\n\t\t\tt.Errorf(\"ParseAgentSessionKey(%q) = %+v, want nil\", input, got)\n\t\t}\n\t}\n}\n\nfunc TestIsSubagentSessionKey(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  bool\n\t}{\n\t\t{\"subagent:task-1\", true},\n\t\t{\"agent:main:subagent:task-1\", true},\n\t\t{\"agent:main:main\", false},\n\t\t{\"agent:main:telegram:direct:user123\", false},\n\t\t{\"\", false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := IsSubagentSessionKey(tt.input); got != tt.want {\n\t\t\tt.Errorf(\"IsSubagentSessionKey(%q) = %v, want %v\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/session/jsonl_backend.go",
    "content": "package session\n\nimport (\n\t\"context\"\n\t\"log\"\n\n\t\"github.com/sipeed/picoclaw/pkg/memory\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// JSONLBackend adapts a memory.Store into the SessionStore interface.\n// Write errors are logged rather than returned, matching the fire-and-forget\n// contract of SessionManager that the agent loop relies on.\ntype JSONLBackend struct {\n\tstore memory.Store\n}\n\n// NewJSONLBackend wraps a memory.Store for use as a SessionStore.\nfunc NewJSONLBackend(store memory.Store) *JSONLBackend {\n\treturn &JSONLBackend{store: store}\n}\n\nfunc (b *JSONLBackend) AddMessage(sessionKey, role, content string) {\n\tif err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {\n\t\tlog.Printf(\"session: add message: %v\", err)\n\t}\n}\n\nfunc (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {\n\tif err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {\n\t\tlog.Printf(\"session: add full message: %v\", err)\n\t}\n}\n\nfunc (b *JSONLBackend) GetHistory(key string) []providers.Message {\n\tmsgs, err := b.store.GetHistory(context.Background(), key)\n\tif err != nil {\n\t\tlog.Printf(\"session: get history: %v\", err)\n\t\treturn []providers.Message{}\n\t}\n\treturn msgs\n}\n\nfunc (b *JSONLBackend) GetSummary(key string) string {\n\tsummary, err := b.store.GetSummary(context.Background(), key)\n\tif err != nil {\n\t\tlog.Printf(\"session: get summary: %v\", err)\n\t\treturn \"\"\n\t}\n\treturn summary\n}\n\nfunc (b *JSONLBackend) SetSummary(key, summary string) {\n\tif err := b.store.SetSummary(context.Background(), key, summary); err != nil {\n\t\tlog.Printf(\"session: set summary: %v\", err)\n\t}\n}\n\nfunc (b *JSONLBackend) SetHistory(key string, history []providers.Message) {\n\tif err := b.store.SetHistory(context.Background(), key, history); err != nil {\n\t\tlog.Printf(\"session: set history: %v\", err)\n\t}\n}\n\nfunc (b *JSONLBackend) TruncateHistory(key string, keepLast int) {\n\tif err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {\n\t\tlog.Printf(\"session: truncate history: %v\", err)\n\t}\n}\n\n// Save persists session state. Since the JSONL store fsyncs every write\n// immediately, the data is already durable. Save runs compaction to reclaim\n// space from logically truncated messages (no-op when there are none).\nfunc (b *JSONLBackend) Save(key string) error {\n\treturn b.store.Compact(context.Background(), key)\n}\n\n// Close releases resources held by the underlying store.\nfunc (b *JSONLBackend) Close() error {\n\treturn b.store.Close()\n}\n"
  },
  {
    "path": "pkg/session/jsonl_backend_test.go",
    "content": "package session_test\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/memory\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/session\"\n)\n\n// Compile-time interface satisfaction checks.\nvar (\n\t_ session.SessionStore = (*session.SessionManager)(nil)\n\t_ session.SessionStore = (*session.JSONLBackend)(nil)\n)\n\nfunc newBackend(t *testing.T) *session.JSONLBackend {\n\tt.Helper()\n\tstore, err := memory.NewJSONLStore(t.TempDir())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { store.Close() })\n\treturn session.NewJSONLBackend(store)\n}\n\nfunc TestJSONLBackend_AddAndGetHistory(t *testing.T) {\n\tb := newBackend(t)\n\n\tb.AddMessage(\"s1\", \"user\", \"hello\")\n\tb.AddMessage(\"s1\", \"assistant\", \"hi\")\n\n\thistory := b.GetHistory(\"s1\")\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"got %d messages, want 2\", len(history))\n\t}\n\tif history[0].Role != \"user\" || history[0].Content != \"hello\" {\n\t\tt.Errorf(\"msg[0] = %+v\", history[0])\n\t}\n\tif history[1].Role != \"assistant\" || history[1].Content != \"hi\" {\n\t\tt.Errorf(\"msg[1] = %+v\", history[1])\n\t}\n}\n\nfunc TestJSONLBackend_AddFullMessage(t *testing.T) {\n\tb := newBackend(t)\n\n\tmsg := providers.Message{\n\t\tRole:    \"assistant\",\n\t\tContent: \"done\",\n\t\tToolCalls: []providers.ToolCall{\n\t\t\t{ID: \"tc1\", Function: &providers.FunctionCall{Name: \"read_file\", Arguments: `{\"path\":\"x\"}`}},\n\t\t},\n\t}\n\tb.AddFullMessage(\"s1\", msg)\n\n\thistory := b.GetHistory(\"s1\")\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"got %d, want 1\", len(history))\n\t}\n\tif len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != \"tc1\" {\n\t\tt.Errorf(\"tool calls = %+v\", history[0].ToolCalls)\n\t}\n}\n\nfunc TestJSONLBackend_Summary(t *testing.T) {\n\tb := newBackend(t)\n\n\tif got := b.GetSummary(\"s1\"); got != \"\" {\n\t\tt.Errorf(\"got %q, want empty\", got)\n\t}\n\n\tb.SetSummary(\"s1\", \"test summary\")\n\tif got := b.GetSummary(\"s1\"); got != \"test summary\" {\n\t\tt.Errorf(\"got %q, want %q\", got, \"test summary\")\n\t}\n}\n\nfunc TestJSONLBackend_TruncateAndSave(t *testing.T) {\n\tb := newBackend(t)\n\n\tfor i := 0; i < 10; i++ {\n\t\tb.AddMessage(\"s1\", \"user\", fmt.Sprintf(\"msg %d\", i))\n\t}\n\tb.TruncateHistory(\"s1\", 3)\n\n\thistory := b.GetHistory(\"s1\")\n\tif len(history) != 3 {\n\t\tt.Fatalf(\"got %d, want 3\", len(history))\n\t}\n\tif history[0].Content != \"msg 7\" {\n\t\tt.Errorf(\"got %q, want %q\", history[0].Content, \"msg 7\")\n\t}\n\n\t// Save triggers compaction.\n\tif err := b.Save(\"s1\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Messages still accessible after compaction.\n\thistory = b.GetHistory(\"s1\")\n\tif len(history) != 3 {\n\t\tt.Fatalf(\"after save: got %d, want 3\", len(history))\n\t}\n}\n\nfunc TestJSONLBackend_SetHistory(t *testing.T) {\n\tb := newBackend(t)\n\tb.AddMessage(\"s1\", \"user\", \"old\")\n\n\tb.SetHistory(\"s1\", []providers.Message{\n\t\t{Role: \"user\", Content: \"new1\"},\n\t\t{Role: \"assistant\", Content: \"new2\"},\n\t})\n\n\thistory := b.GetHistory(\"s1\")\n\tif len(history) != 2 {\n\t\tt.Fatalf(\"got %d, want 2\", len(history))\n\t}\n\tif history[0].Content != \"new1\" {\n\t\tt.Errorf(\"got %q, want %q\", history[0].Content, \"new1\")\n\t}\n}\n\nfunc TestJSONLBackend_EmptySession(t *testing.T) {\n\tb := newBackend(t)\n\n\thistory := b.GetHistory(\"nonexistent\")\n\tif history == nil {\n\t\tt.Fatal(\"got nil, want empty slice\")\n\t}\n\tif len(history) != 0 {\n\t\tt.Errorf(\"got %d, want 0\", len(history))\n\t}\n}\n\nfunc TestJSONLBackend_SessionIsolation(t *testing.T) {\n\tb := newBackend(t)\n\tb.AddMessage(\"s1\", \"user\", \"session1\")\n\tb.AddMessage(\"s2\", \"user\", \"session2\")\n\n\th1 := b.GetHistory(\"s1\")\n\th2 := b.GetHistory(\"s2\")\n\n\tif len(h1) != 1 || h1[0].Content != \"session1\" {\n\t\tt.Errorf(\"s1: %+v\", h1)\n\t}\n\tif len(h2) != 1 || h2[0].Content != \"session2\" {\n\t\tt.Errorf(\"s2: %+v\", h2)\n\t}\n}\n\nfunc TestJSONLBackend_SummarizeFlow(t *testing.T) {\n\t// Simulates the real summarization flow in the agent loop:\n\t// SetSummary → TruncateHistory → Save\n\tb := newBackend(t)\n\n\tfor i := 0; i < 20; i++ {\n\t\tb.AddMessage(\"s1\", \"user\", fmt.Sprintf(\"msg %d\", i))\n\t}\n\n\tb.SetSummary(\"s1\", \"conversation about testing\")\n\tb.TruncateHistory(\"s1\", 4)\n\tif err := b.Save(\"s1\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif got := b.GetSummary(\"s1\"); got != \"conversation about testing\" {\n\t\tt.Errorf(\"summary = %q\", got)\n\t}\n\thistory := b.GetHistory(\"s1\")\n\tif len(history) != 4 {\n\t\tt.Fatalf(\"got %d messages, want 4\", len(history))\n\t}\n\tif history[0].Content != \"msg 16\" {\n\t\tt.Errorf(\"first message = %q, want %q\", history[0].Content, \"msg 16\")\n\t}\n}\n"
  },
  {
    "path": "pkg/session/manager.go",
    "content": "package session\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\ntype Session struct {\n\tKey      string              `json:\"key\"`\n\tMessages []providers.Message `json:\"messages\"`\n\tSummary  string              `json:\"summary,omitempty\"`\n\tCreated  time.Time           `json:\"created\"`\n\tUpdated  time.Time           `json:\"updated\"`\n}\n\ntype SessionManager struct {\n\tsessions map[string]*Session\n\tmu       sync.RWMutex\n\tstorage  string\n}\n\nfunc NewSessionManager(storage string) *SessionManager {\n\tsm := &SessionManager{\n\t\tsessions: make(map[string]*Session),\n\t\tstorage:  storage,\n\t}\n\n\tif storage != \"\" {\n\t\tos.MkdirAll(storage, 0o700)\n\t\tsm.loadSessions()\n\t}\n\n\treturn sm\n}\n\nfunc (sm *SessionManager) GetOrCreate(key string) *Session {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, ok := sm.sessions[key]\n\tif ok {\n\t\treturn session\n\t}\n\n\tsession = &Session{\n\t\tKey:      key,\n\t\tMessages: []providers.Message{},\n\t\tCreated:  time.Now(),\n\t\tUpdated:  time.Now(),\n\t}\n\tsm.sessions[key] = session\n\n\treturn session\n}\n\nfunc (sm *SessionManager) AddMessage(sessionKey, role, content string) {\n\tsm.AddFullMessage(sessionKey, providers.Message{\n\t\tRole:    role,\n\t\tContent: content,\n\t})\n}\n\n// AddFullMessage adds a complete message with tool calls and tool call ID to the session.\n// This is used to save the full conversation flow including tool calls and tool results.\nfunc (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, ok := sm.sessions[sessionKey]\n\tif !ok {\n\t\tsession = &Session{\n\t\t\tKey:      sessionKey,\n\t\t\tMessages: []providers.Message{},\n\t\t\tCreated:  time.Now(),\n\t\t}\n\t\tsm.sessions[sessionKey] = session\n\t}\n\n\tsession.Messages = append(session.Messages, msg)\n\tsession.Updated = time.Now()\n}\n\nfunc (sm *SessionManager) GetHistory(key string) []providers.Message {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\tsession, ok := sm.sessions[key]\n\tif !ok {\n\t\treturn []providers.Message{}\n\t}\n\n\thistory := make([]providers.Message, len(session.Messages))\n\tcopy(history, session.Messages)\n\treturn history\n}\n\nfunc (sm *SessionManager) GetSummary(key string) string {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\tsession, ok := sm.sessions[key]\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn session.Summary\n}\n\nfunc (sm *SessionManager) SetSummary(key string, summary string) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, ok := sm.sessions[key]\n\tif ok {\n\t\tsession.Summary = summary\n\t\tsession.Updated = time.Now()\n\t}\n}\n\nfunc (sm *SessionManager) TruncateHistory(key string, keepLast int) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, ok := sm.sessions[key]\n\tif !ok {\n\t\treturn\n\t}\n\n\tif keepLast <= 0 {\n\t\tsession.Messages = []providers.Message{}\n\t\tsession.Updated = time.Now()\n\t\treturn\n\t}\n\n\tif len(session.Messages) <= keepLast {\n\t\treturn\n\t}\n\n\tsession.Messages = session.Messages[len(session.Messages)-keepLast:]\n\tsession.Updated = time.Now()\n}\n\n// sanitizeFilename converts a session key into a cross-platform safe filename.\n// Replaces ':' with '_' (session key separator) and '/' and '\\' with '_' so\n// composite IDs (e.g. Telegram forum \"chatID/threadID\") do not create\n// subdirectories or break on Windows. The original key is preserved inside\n// the JSON file, so loadSessions still maps back to the right in-memory key.\nfunc sanitizeFilename(key string) string {\n\ts := strings.ReplaceAll(key, \":\", \"_\")\n\ts = strings.ReplaceAll(s, \"/\", \"_\")\n\ts = strings.ReplaceAll(s, \"\\\\\", \"_\")\n\treturn s\n}\n\nfunc (sm *SessionManager) Save(key string) error {\n\tif sm.storage == \"\" {\n\t\treturn nil\n\t}\n\n\tfilename := sanitizeFilename(key)\n\n\t// filepath.IsLocal rejects empty names, \"..\", absolute paths, and\n\t// OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename\n\t// already replaced '/' and '\\' with '_', so no subdirs are created.\n\tif filename == \".\" || !filepath.IsLocal(filename) {\n\t\treturn os.ErrInvalid\n\t}\n\n\t// Snapshot under read lock, then perform slow file I/O after unlock.\n\tsm.mu.RLock()\n\tstored, ok := sm.sessions[key]\n\tif !ok {\n\t\tsm.mu.RUnlock()\n\t\treturn nil\n\t}\n\n\tsnapshot := Session{\n\t\tKey:     stored.Key,\n\t\tSummary: stored.Summary,\n\t\tCreated: stored.Created,\n\t\tUpdated: stored.Updated,\n\t}\n\tif len(stored.Messages) > 0 {\n\t\tsnapshot.Messages = make([]providers.Message, len(stored.Messages))\n\t\tcopy(snapshot.Messages, stored.Messages)\n\t} else {\n\t\tsnapshot.Messages = []providers.Message{}\n\t}\n\tsm.mu.RUnlock()\n\n\tdata, err := json.MarshalIndent(snapshot, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsessionPath := filepath.Join(sm.storage, filename+\".json\")\n\ttmpFile, err := os.CreateTemp(sm.storage, \"session-*.tmp\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpPath := tmpFile.Name()\n\tcleanup := true\n\tdefer func() {\n\t\tif cleanup {\n\t\t\t_ = os.Remove(tmpPath)\n\t\t}\n\t}()\n\n\tif _, err := tmpFile.Write(data); err != nil {\n\t\t_ = tmpFile.Close()\n\t\treturn err\n\t}\n\tif err := tmpFile.Chmod(0o600); err != nil {\n\t\t_ = tmpFile.Close()\n\t\treturn err\n\t}\n\tif err := tmpFile.Sync(); err != nil {\n\t\t_ = tmpFile.Close()\n\t\treturn err\n\t}\n\tif err := tmpFile.Close(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.Rename(tmpPath, sessionPath); err != nil {\n\t\treturn err\n\t}\n\tcleanup = false\n\treturn nil\n}\n\nfunc (sm *SessionManager) loadSessions() error {\n\tfiles, err := os.ReadDir(sm.storage)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, file := range files {\n\t\tif file.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif filepath.Ext(file.Name()) != \".json\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tsessionPath := filepath.Join(sm.storage, file.Name())\n\t\tdata, err := os.ReadFile(sessionPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar session Session\n\t\tif err := json.Unmarshal(data, &session); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tsm.sessions[session.Key] = &session\n\t}\n\n\treturn nil\n}\n\n// Close is a no-op for the in-memory SessionManager; it satisfies the\n// SessionStore interface so callers can release resources uniformly.\nfunc (sm *SessionManager) Close() error {\n\treturn nil\n}\n\n// SetHistory updates the messages of a session.\nfunc (sm *SessionManager) SetHistory(key string, history []providers.Message) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, ok := sm.sessions[key]\n\tif ok {\n\t\t// Create a deep copy to strictly isolate internal state\n\t\t// from the caller's slice.\n\t\tmsgs := make([]providers.Message, len(history))\n\t\tcopy(msgs, history)\n\t\tsession.Messages = msgs\n\t\tsession.Updated = time.Now()\n\t}\n}\n"
  },
  {
    "path": "pkg/session/manager_test.go",
    "content": "package session\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestSanitizeFilename(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"simple\", \"simple\"},\n\t\t{\"telegram:123456\", \"telegram_123456\"},\n\t\t{\"discord:987654321\", \"discord_987654321\"},\n\t\t{\"slack:C01234\", \"slack_C01234\"},\n\t\t{\"no-colons-here\", \"no-colons-here\"},\n\t\t{\"multiple:colons:here\", \"multiple_colons_here\"},\n\t\t{\"agent:main:telegram:group:-1003822706455/12\", \"agent_main_telegram_group_-1003822706455_12\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := sanitizeFilename(tt.input)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"sanitizeFilename(%q) = %q, want %q\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSave_WithColonInKey(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsm := NewSessionManager(tmpDir)\n\n\t// Create a session with a key containing colon (typical channel session key).\n\tkey := \"telegram:123456\"\n\tsm.GetOrCreate(key)\n\tsm.AddMessage(key, \"user\", \"hello\")\n\n\t// Save should succeed even though the key contains ':'\n\tif err := sm.Save(key); err != nil {\n\t\tt.Fatalf(\"Save(%q) failed: %v\", key, err)\n\t}\n\n\t// The file on disk should use sanitized name.\n\texpectedFile := filepath.Join(tmpDir, \"telegram_123456.json\")\n\tif _, err := os.Stat(expectedFile); os.IsNotExist(err) {\n\t\tt.Fatalf(\"expected session file %s to exist\", expectedFile)\n\t}\n\n\t// Load into a fresh manager and verify the session round-trips.\n\tsm2 := NewSessionManager(tmpDir)\n\thistory := sm2.GetHistory(key)\n\tif len(history) != 1 {\n\t\tt.Fatalf(\"expected 1 message after reload, got %d\", len(history))\n\t}\n\tif history[0].Content != \"hello\" {\n\t\tt.Errorf(\"expected message content %q, got %q\", \"hello\", history[0].Content)\n\t}\n}\n\nfunc TestSave_RejectsPathTraversal(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsm := NewSessionManager(tmpDir)\n\n\t// Invalid names that must still be rejected.\n\tbadKeys := []string{\"\", \".\", \"..\"}\n\tfor _, key := range badKeys {\n\t\tsm.GetOrCreate(key)\n\t\tif err := sm.Save(key); err == nil {\n\t\t\tt.Errorf(\"Save(%q) should have failed but didn't\", key)\n\t\t}\n\t}\n\n\t// Keys containing path separators are sanitized (no subdirs created).\n\tsm.GetOrCreate(\"foo/bar\")\n\tif err := sm.Save(\"foo/bar\"); err != nil {\n\t\tt.Fatalf(\"Save(\\\"foo/bar\\\") after sanitize should succeed: %v\", err)\n\t}\n\tif _, err := os.Stat(filepath.Join(tmpDir, \"foo_bar.json\")); os.IsNotExist(err) {\n\t\tt.Errorf(\"expected foo_bar.json in storage (sanitized from foo/bar)\")\n\t}\n}\n"
  },
  {
    "path": "pkg/session/session_store.go",
    "content": "package session\n\nimport \"github.com/sipeed/picoclaw/pkg/providers\"\n\n// SessionStore defines the persistence operations used by the agent loop.\n// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this\n// interface, allowing the storage layer to be swapped without touching the\n// agent loop code.\n//\n// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not\n// return errors. Implementations should log failures internally. This\n// matches the original SessionManager contract that the agent loop relies on.\ntype SessionStore interface {\n\t// AddMessage appends a simple role/content message to the session.\n\tAddMessage(sessionKey, role, content string)\n\t// AddFullMessage appends a complete message including tool calls.\n\tAddFullMessage(sessionKey string, msg providers.Message)\n\t// GetHistory returns the full message history for the session.\n\tGetHistory(key string) []providers.Message\n\t// GetSummary returns the conversation summary, or \"\" if none.\n\tGetSummary(key string) string\n\t// SetSummary replaces the conversation summary.\n\tSetSummary(key, summary string)\n\t// SetHistory replaces the full message history.\n\tSetHistory(key string, history []providers.Message)\n\t// TruncateHistory keeps only the last keepLast messages.\n\tTruncateHistory(key string, keepLast int)\n\t// Save persists any pending state to durable storage.\n\tSave(key string) error\n\t// Close releases resources held by the store.\n\tClose() error\n}\n"
  },
  {
    "path": "pkg/skills/clawhub_registry.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tdefaultClawHubTimeout  = 30 * time.Second\n\tdefaultMaxZipSize      = 50 * 1024 * 1024 // 50 MB\n\tdefaultMaxResponseSize = 2 * 1024 * 1024  // 2 MB\n)\n\n// ClawHubRegistry implements SkillRegistry for the ClawHub platform.\ntype ClawHubRegistry struct {\n\tbaseURL         string\n\tauthToken       string // Optional - for elevated rate limits\n\tsearchPath      string // Search API\n\tskillsPath      string // For retrieving skill metadata\n\tdownloadPath    string // For fetching ZIP files for download\n\tmaxZipSize      int\n\tmaxResponseSize int\n\tclient          *http.Client\n}\n\n// NewClawHubRegistry creates a new ClawHub registry client from config.\nfunc NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {\n\tbaseURL := cfg.BaseURL\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://clawhub.ai\"\n\t}\n\tsearchPath := cfg.SearchPath\n\tif searchPath == \"\" {\n\t\tsearchPath = \"/api/v1/search\"\n\t}\n\tskillsPath := cfg.SkillsPath\n\tif skillsPath == \"\" {\n\t\tskillsPath = \"/api/v1/skills\"\n\t}\n\tdownloadPath := cfg.DownloadPath\n\tif downloadPath == \"\" {\n\t\tdownloadPath = \"/api/v1/download\"\n\t}\n\n\ttimeout := defaultClawHubTimeout\n\tif cfg.Timeout > 0 {\n\t\ttimeout = time.Duration(cfg.Timeout) * time.Second\n\t}\n\n\tmaxZip := defaultMaxZipSize\n\tif cfg.MaxZipSize > 0 {\n\t\tmaxZip = cfg.MaxZipSize\n\t}\n\n\tmaxResp := defaultMaxResponseSize\n\tif cfg.MaxResponseSize > 0 {\n\t\tmaxResp = cfg.MaxResponseSize\n\t}\n\n\treturn &ClawHubRegistry{\n\t\tbaseURL:         baseURL,\n\t\tauthToken:       cfg.AuthToken,\n\t\tsearchPath:      searchPath,\n\t\tskillsPath:      skillsPath,\n\t\tdownloadPath:    downloadPath,\n\t\tmaxZipSize:      maxZip,\n\t\tmaxResponseSize: maxResp,\n\t\tclient: &http.Client{\n\t\t\tTimeout: timeout,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tMaxIdleConns:        5,\n\t\t\t\tIdleConnTimeout:     30 * time.Second,\n\t\t\t\tTLSHandshakeTimeout: 10 * time.Second,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (c *ClawHubRegistry) Name() string {\n\treturn \"clawhub\"\n}\n\n// --- Search ---\n\ntype clawhubSearchResponse struct {\n\tResults []clawhubSearchResult `json:\"results\"`\n}\n\ntype clawhubSearchResult struct {\n\tScore       float64 `json:\"score\"`\n\tSlug        *string `json:\"slug\"`\n\tDisplayName *string `json:\"displayName\"`\n\tSummary     *string `json:\"summary\"`\n\tVersion     *string `json:\"version\"`\n}\n\nfunc (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {\n\tu, err := url.Parse(c.baseURL + c.searchPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid base URL: %w\", err)\n\t}\n\n\tq := u.Query()\n\tq.Set(\"q\", query)\n\tif limit > 0 {\n\t\tq.Set(\"limit\", fmt.Sprintf(\"%d\", limit))\n\t}\n\tu.RawQuery = q.Encode()\n\n\tbody, err := c.doGet(ctx, u.String())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"search request failed: %w\", err)\n\t}\n\n\tvar resp clawhubSearchResponse\n\tif err := json.Unmarshal(body, &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse search response: %w\", err)\n\t}\n\n\tresults := make([]SearchResult, 0, len(resp.Results))\n\tfor _, r := range resp.Results {\n\t\tslug := utils.DerefStr(r.Slug, \"\")\n\t\tif slug == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tsummary := utils.DerefStr(r.Summary, \"\")\n\t\tif summary == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tdisplayName := utils.DerefStr(r.DisplayName, \"\")\n\t\tif displayName == \"\" {\n\t\t\tdisplayName = slug\n\t\t}\n\n\t\tresults = append(results, SearchResult{\n\t\t\tScore:        r.Score,\n\t\t\tSlug:         slug,\n\t\t\tDisplayName:  displayName,\n\t\t\tSummary:      summary,\n\t\t\tVersion:      utils.DerefStr(r.Version, \"\"),\n\t\t\tRegistryName: c.Name(),\n\t\t})\n\t}\n\n\treturn results, nil\n}\n\n// --- GetSkillMeta ---\n\ntype clawhubSkillResponse struct {\n\tSlug          string                 `json:\"slug\"`\n\tDisplayName   string                 `json:\"displayName\"`\n\tSummary       string                 `json:\"summary\"`\n\tLatestVersion *clawhubVersionInfo    `json:\"latestVersion\"`\n\tModeration    *clawhubModerationInfo `json:\"moderation\"`\n}\n\ntype clawhubVersionInfo struct {\n\tVersion string `json:\"version\"`\n}\n\ntype clawhubModerationInfo struct {\n\tIsMalwareBlocked bool `json:\"isMalwareBlocked\"`\n\tIsSuspicious     bool `json:\"isSuspicious\"`\n}\n\nfunc (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) {\n\tif err := utils.ValidateSkillIdentifier(slug); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid slug %q: error: %s\", slug, err.Error())\n\t}\n\n\tu := c.baseURL + c.skillsPath + \"/\" + url.PathEscape(slug)\n\n\tbody, err := c.doGet(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"skill metadata request failed: %w\", err)\n\t}\n\n\tvar resp clawhubSkillResponse\n\tif err := json.Unmarshal(body, &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse skill metadata: %w\", err)\n\t}\n\n\tmeta := &SkillMeta{\n\t\tSlug:         resp.Slug,\n\t\tDisplayName:  resp.DisplayName,\n\t\tSummary:      resp.Summary,\n\t\tRegistryName: c.Name(),\n\t}\n\n\tif resp.LatestVersion != nil {\n\t\tmeta.LatestVersion = resp.LatestVersion.Version\n\t}\n\tif resp.Moderation != nil {\n\t\tmeta.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked\n\t\tmeta.IsSuspicious = resp.Moderation.IsSuspicious\n\t}\n\n\treturn meta, nil\n}\n\n// --- DownloadAndInstall ---\n\n// DownloadAndInstall fetches metadata (with fallback), resolves version,\n// downloads the skill ZIP, and extracts it to targetDir.\n// Returns an InstallResult for the caller to use for moderation decisions.\nfunc (c *ClawHubRegistry) DownloadAndInstall(\n\tctx context.Context,\n\tslug, version, targetDir string,\n) (*InstallResult, error) {\n\tif err := utils.ValidateSkillIdentifier(slug); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid slug %q: error: %s\", slug, err.Error())\n\t}\n\n\t// Step 1: Fetch metadata (with fallback).\n\tresult := &InstallResult{}\n\tmeta, err := c.GetSkillMeta(ctx, slug)\n\tif err != nil {\n\t\t// Fallback: proceed without metadata.\n\t\tmeta = nil\n\t}\n\n\tif meta != nil {\n\t\tresult.IsMalwareBlocked = meta.IsMalwareBlocked\n\t\tresult.IsSuspicious = meta.IsSuspicious\n\t\tresult.Summary = meta.Summary\n\t}\n\n\t// Step 2: Resolve version.\n\tinstallVersion := version\n\tif installVersion == \"\" && meta != nil {\n\t\tinstallVersion = meta.LatestVersion\n\t}\n\tif installVersion == \"\" {\n\t\tinstallVersion = \"latest\"\n\t}\n\tresult.Version = installVersion\n\n\t// Step 3: Download ZIP to temp file (streams in ~32KB chunks).\n\tu, err := url.Parse(c.baseURL + c.downloadPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid base URL: %w\", err)\n\t}\n\n\tq := u.Query()\n\tq.Set(\"slug\", slug)\n\tif installVersion != \"latest\" {\n\t\tq.Set(\"version\", installVersion)\n\t}\n\tu.RawQuery = q.Encode()\n\n\ttmpPath, err := c.downloadToTempFileWithRetry(ctx, u.String())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"download failed: %w\", err)\n\t}\n\tdefer os.Remove(tmpPath)\n\n\t// Step 4: Extract from file on disk.\n\tif err := utils.ExtractZipFile(tmpPath, targetDir); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn result, nil\n}\n\n// --- HTTP helper ---\n\nfunc (c *ClawHubRegistry) doGet(ctx context.Context, urlStr string) ([]byte, error) {\n\treq, err := c.newGetRequest(ctx, urlStr, \"application/json\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := utils.DoRequestWithRetry(c.client, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\t// Limit response body read to prevent memory issues.\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, int64(c.maxResponseSize)))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\treturn nil, fmt.Errorf(\"HTTP %d: %s\", resp.StatusCode, string(body))\n\t}\n\n\treturn body, nil\n}\n\nfunc (c *ClawHubRegistry) newGetRequest(ctx context.Context, urlStr, accept string) (*http.Request, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Accept\", accept)\n\tif c.authToken != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+c.authToken)\n\t}\n\treturn req, nil\n}\n\nfunc (c *ClawHubRegistry) downloadToTempFileWithRetry(ctx context.Context, urlStr string) (string, error) {\n\treq, err := c.newGetRequest(ctx, urlStr, \"application/zip\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tresp, err := utils.DoRequestWithRetry(c.client, req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\terrBody := make([]byte, 512)\n\t\tn, _ := io.ReadFull(resp.Body, errBody)\n\t\treturn \"\", fmt.Errorf(\"HTTP %d: %s\", resp.StatusCode, string(errBody[:n]))\n\t}\n\n\ttmpFile, err := os.CreateTemp(\"\", \"picoclaw-dl-*\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create temp file: %w\", err)\n\t}\n\ttmpPath := tmpFile.Name()\n\n\tcleanup := func() {\n\t\t_ = tmpFile.Close()\n\t\t_ = os.Remove(tmpPath)\n\t}\n\n\tsrc := io.LimitReader(resp.Body, int64(c.maxZipSize)+1)\n\twritten, err := io.Copy(tmpFile, src)\n\tif err != nil {\n\t\tcleanup()\n\t\treturn \"\", fmt.Errorf(\"download write failed: %w\", err)\n\t}\n\n\tif written > int64(c.maxZipSize) {\n\t\tcleanup()\n\t\treturn \"\", fmt.Errorf(\"download too large: %d bytes (max %d)\", written, c.maxZipSize)\n\t}\n\n\tif err := tmpFile.Close(); err != nil {\n\t\t_ = os.Remove(tmpPath)\n\t\treturn \"\", fmt.Errorf(\"failed to close temp file: %w\", err)\n\t}\n\n\treturn tmpPath, nil\n}\n"
  },
  {
    "path": "pkg/skills/clawhub_registry_test.go",
    "content": "package skills\n\nimport (\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nfunc newTestRegistry(serverURL, authToken string) *ClawHubRegistry {\n\treturn NewClawHubRegistry(ClawHubConfig{\n\t\tEnabled:   true,\n\t\tBaseURL:   serverURL,\n\t\tAuthToken: authToken,\n\t})\n}\n\nfunc TestClawHubRegistrySearch(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tassert.Equal(t, \"/api/v1/search\", r.URL.Path)\n\t\tassert.Equal(t, \"github\", r.URL.Query().Get(\"q\"))\n\n\t\tslug := \"github\"\n\t\tname := \"GitHub Integration\"\n\t\tsummary := \"Interact with GitHub repos\"\n\t\tversion := \"1.0.0\"\n\n\t\tjson.NewEncoder(w).Encode(clawhubSearchResponse{\n\t\t\tResults: []clawhubSearchResult{\n\t\t\t\t{Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},\n\t\t\t},\n\t\t})\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tresults, err := reg.Search(context.Background(), \"github\", 5)\n\n\trequire.NoError(t, err)\n\trequire.Len(t, results, 1)\n\tassert.Equal(t, \"github\", results[0].Slug)\n\tassert.Equal(t, \"GitHub Integration\", results[0].DisplayName)\n\tassert.InDelta(t, 0.95, results[0].Score, 0.001)\n\tassert.Equal(t, \"clawhub\", results[0].RegistryName)\n}\n\nfunc TestClawHubRegistrySearchRetries429(t *testing.T) {\n\tattempts := 0\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tattempts++\n\t\tif attempts == 1 {\n\t\t\tw.Header().Set(\"Retry-After\", \"0\")\n\t\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t\tw.Write([]byte(\"rate limited\"))\n\t\t\treturn\n\t\t}\n\n\t\tslug := \"github\"\n\t\tname := \"GitHub Integration\"\n\t\tsummary := \"Interact with GitHub repos\"\n\t\tversion := \"1.0.0\"\n\n\t\tjson.NewEncoder(w).Encode(clawhubSearchResponse{\n\t\t\tResults: []clawhubSearchResult{\n\t\t\t\t{Score: 0.95, Slug: &slug, DisplayName: &name, Summary: &summary, Version: &version},\n\t\t\t},\n\t\t})\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tresults, err := reg.Search(context.Background(), \"github\", 5)\n\n\trequire.NoError(t, err)\n\trequire.Len(t, results, 1)\n\tassert.Equal(t, 2, attempts)\n\tassert.Equal(t, \"github\", results[0].Slug)\n}\n\nfunc TestClawHubRegistryGetSkillMeta(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tassert.Equal(t, \"/api/v1/skills/github\", r.URL.Path)\n\n\t\tjson.NewEncoder(w).Encode(clawhubSkillResponse{\n\t\t\tSlug:        \"github\",\n\t\t\tDisplayName: \"GitHub Integration\",\n\t\t\tSummary:     \"Full GitHub API integration\",\n\t\t\tLatestVersion: &clawhubVersionInfo{\n\t\t\t\tVersion: \"2.1.0\",\n\t\t\t},\n\t\t\tModeration: &clawhubModerationInfo{\n\t\t\t\tIsMalwareBlocked: false,\n\t\t\t\tIsSuspicious:     true,\n\t\t\t},\n\t\t})\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tmeta, err := reg.GetSkillMeta(context.Background(), \"github\")\n\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"github\", meta.Slug)\n\tassert.Equal(t, \"2.1.0\", meta.LatestVersion)\n\tassert.False(t, meta.IsMalwareBlocked)\n\tassert.True(t, meta.IsSuspicious)\n}\n\nfunc TestClawHubRegistryGetSkillMetaUnsafeSlug(t *testing.T) {\n\treg := newTestRegistry(\"https://example.com\", \"\")\n\t_, err := reg.GetSkillMeta(context.Background(), \"../etc/passwd\")\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"invalid slug\")\n}\n\nfunc TestClawHubRegistryDownloadAndInstall(t *testing.T) {\n\t// Create a valid ZIP in memory.\n\tzipBuf := createTestZip(t, map[string]string{\n\t\t\"SKILL.md\":  \"---\\nname: test-skill\\ndescription: A test\\n---\\nHello skill\",\n\t\t\"README.md\": \"# Test Skill\\n\",\n\t})\n\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/skills/test-skill\":\n\t\t\t// Metadata endpoint.\n\t\t\tjson.NewEncoder(w).Encode(clawhubSkillResponse{\n\t\t\t\tSlug:          \"test-skill\",\n\t\t\t\tDisplayName:   \"Test Skill\",\n\t\t\t\tSummary:       \"A test skill\",\n\t\t\t\tLatestVersion: &clawhubVersionInfo{Version: \"1.0.0\"},\n\t\t\t})\n\t\tcase \"/api/v1/download\":\n\t\t\tassert.Equal(t, \"test-skill\", r.URL.Query().Get(\"slug\"))\n\t\t\tw.Header().Set(\"Content-Type\", \"application/zip\")\n\t\t\tw.Write(zipBuf)\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer srv.Close()\n\n\ttmpDir := t.TempDir()\n\ttargetDir := filepath.Join(tmpDir, \"test-skill\")\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tresult, err := reg.DownloadAndInstall(context.Background(), \"test-skill\", \"1.0.0\", targetDir)\n\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"1.0.0\", result.Version)\n\tassert.False(t, result.IsMalwareBlocked)\n\n\t// Verify extracted files.\n\tskillContent, err := os.ReadFile(filepath.Join(targetDir, \"SKILL.md\"))\n\trequire.NoError(t, err)\n\tassert.Contains(t, string(skillContent), \"Hello skill\")\n\n\treadmeContent, err := os.ReadFile(filepath.Join(targetDir, \"README.md\"))\n\trequire.NoError(t, err)\n\tassert.Contains(t, string(readmeContent), \"# Test Skill\")\n}\n\nfunc TestClawHubRegistryDownloadAndInstallRetries429(t *testing.T) {\n\tzipBuf := createTestZip(t, map[string]string{\n\t\t\"SKILL.md\": \"---\\nname: retry-skill\\ndescription: A test\\n---\\nHello skill\",\n\t})\n\n\tdownloadAttempts := 0\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/skills/retry-skill\":\n\t\t\tjson.NewEncoder(w).Encode(clawhubSkillResponse{\n\t\t\t\tSlug:          \"retry-skill\",\n\t\t\t\tDisplayName:   \"Retry Skill\",\n\t\t\t\tSummary:       \"A retry test skill\",\n\t\t\t\tLatestVersion: &clawhubVersionInfo{Version: \"1.0.0\"},\n\t\t\t})\n\t\tcase \"/api/v1/download\":\n\t\t\tdownloadAttempts++\n\t\t\tif downloadAttempts == 1 {\n\t\t\t\tw.Header().Set(\"Retry-After\", \"0\")\n\t\t\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t\t\tw.Write([]byte(\"rate limited\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equal(t, \"retry-skill\", r.URL.Query().Get(\"slug\"))\n\t\t\tw.Header().Set(\"Content-Type\", \"application/zip\")\n\t\t\tw.Write(zipBuf)\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer srv.Close()\n\n\ttmpDir := t.TempDir()\n\ttargetDir := filepath.Join(tmpDir, \"retry-skill\")\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tresult, err := reg.DownloadAndInstall(context.Background(), \"retry-skill\", \"\", targetDir)\n\n\trequire.NoError(t, err)\n\trequire.NotNil(t, result)\n\tassert.Equal(t, \"1.0.0\", result.Version)\n\tassert.Equal(t, 2, downloadAttempts)\n\n\tskillContent, err := os.ReadFile(filepath.Join(targetDir, \"SKILL.md\"))\n\trequire.NoError(t, err)\n\tassert.Contains(t, string(skillContent), \"Hello skill\")\n}\n\nfunc TestClawHubRegistryAuthToken(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tauthHeader := r.Header.Get(\"Authorization\")\n\t\tassert.Equal(t, \"Bearer test-token-123\", authHeader)\n\t\tjson.NewEncoder(w).Encode(clawhubSearchResponse{Results: nil})\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"test-token-123\")\n\t_, _ = reg.Search(context.Background(), \"test\", 5)\n}\n\nfunc TestExtractZipPathTraversal(t *testing.T) {\n\t// Create a ZIP with a path traversal entry.\n\tvar buf bytes.Buffer\n\tzw := zip.NewWriter(&buf)\n\n\t// Malicious entry trying to escape directory.\n\tw, err := zw.Create(\"../../etc/passwd\")\n\trequire.NoError(t, err)\n\tw.Write([]byte(\"malicious\"))\n\n\tzw.Close()\n\n\t// Write to temp file for extractZipFile.\n\ttmpZip := filepath.Join(t.TempDir(), \"bad.zip\")\n\trequire.NoError(t, os.WriteFile(tmpZip, buf.Bytes(), 0o644))\n\n\ttmpDir := t.TempDir()\n\terr = utils.ExtractZipFile(tmpZip, tmpDir)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"unsafe path\")\n}\n\nfunc TestExtractZipWithSubdirectories(t *testing.T) {\n\tzipBuf := createTestZip(t, map[string]string{\n\t\t\"SKILL.md\":           \"root file\",\n\t\t\"scripts/helper.sh\":  \"#!/bin/bash\\necho hello\",\n\t\t\"examples/demo.yaml\": \"key: value\",\n\t})\n\n\t// Write to temp file for extractZipFile.\n\ttmpZip := filepath.Join(t.TempDir(), \"test.zip\")\n\trequire.NoError(t, os.WriteFile(tmpZip, zipBuf, 0o644))\n\n\ttmpDir := t.TempDir()\n\ttargetDir := filepath.Join(tmpDir, \"my-skill\")\n\n\terr := utils.ExtractZipFile(tmpZip, targetDir)\n\trequire.NoError(t, err)\n\n\t// Verify nested file.\n\tdata, err := os.ReadFile(filepath.Join(targetDir, \"scripts\", \"helper.sh\"))\n\trequire.NoError(t, err)\n\tassert.Contains(t, string(data), \"#!/bin/bash\")\n}\n\nfunc TestClawHubRegistrySearchHTTPError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\tw.Write([]byte(\"Internal Server Error\"))\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"\")\n\t_, err := reg.Search(context.Background(), \"test\", 5)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"500\")\n}\n\nfunc TestClawHubRegistrySearchNullableFields(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvalidSlug := \"valid-slug\"\n\t\tvalidSummary := \"valid summary\"\n\n\t\t// Return results with various null/empty fields\n\t\tjson.NewEncoder(w).Encode(clawhubSearchResponse{\n\t\t\tResults: []clawhubSearchResult{\n\t\t\t\t// Case 1: Null Slug -> Skip\n\t\t\t\t{Score: 0.1, Slug: nil, DisplayName: nil, Summary: nil, Version: nil},\n\t\t\t\t// Case 2: Valid Slug, Null Summary -> Skip\n\t\t\t\t{Score: 0.2, Slug: &validSlug, DisplayName: nil, Summary: nil, Version: nil},\n\t\t\t\t// Case 3: Valid Slug, Valid Summary, Null Name -> Keep, Name=Slug\n\t\t\t\t{Score: 0.8, Slug: &validSlug, DisplayName: nil, Summary: &validSummary, Version: nil},\n\t\t\t},\n\t\t})\n\t}))\n\tdefer srv.Close()\n\n\treg := newTestRegistry(srv.URL, \"\")\n\tresults, err := reg.Search(context.Background(), \"test\", 5)\n\n\trequire.NoError(t, err)\n\trequire.Len(t, results, 1, \"should only return 1 valid result\")\n\n\tr := results[0]\n\tassert.Equal(t, \"valid-slug\", r.Slug)\n\tassert.Equal(t, \"valid-slug\", r.DisplayName, \"should fallback name to slug\")\n\tassert.Equal(t, \"valid summary\", r.Summary)\n}\n\n// --- helpers ---\n\nfunc createTestZip(t *testing.T, files map[string]string) []byte {\n\tt.Helper()\n\tvar buf bytes.Buffer\n\tzw := zip.NewWriter(&buf)\n\n\tfor name, content := range files {\n\t\tw, err := zw.Create(name)\n\t\trequire.NoError(t, err)\n\t\t_, err = w.Write([]byte(content))\n\t\trequire.NoError(t, err)\n\t}\n\n\trequire.NoError(t, zw.Close())\n\treturn buf.Bytes()\n}\n"
  },
  {
    "path": "pkg/skills/installer.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// GitHubContent represents a file or directory in GitHub API response\ntype GitHubContent struct {\n\tName        string `json:\"name\"`\n\tPath        string `json:\"path\"`\n\tType        string `json:\"type\"` // \"file\" or \"dir\"\n\tDownloadURL string `json:\"download_url\"`\n\tURL         string `json:\"url\"` // API URL for subdirectories\n}\n\n// GitHubRef represents a parsed GitHub reference\ntype GitHubRef struct {\n\tOwner    string // Repository owner\n\tRepoName string // Repository name\n\tRef      string // Git reference (branch, tag, or commit)\n\tSubPath  string // Path within the repository\n}\n\ntype SkillInstaller struct {\n\tworkspace   string\n\tclient      *http.Client\n\tgithubToken string\n\tproxy       string\n}\n\n// NewSkillInstaller creates a new skill installer.\n// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills.\nfunc NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) {\n\tclient, err := utils.CreateHTTPClient(proxy, 15*time.Second)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create HTTP client: %w\", err)\n\t}\n\n\treturn &SkillInstaller{\n\t\tworkspace:   workspace,\n\t\tclient:      client,\n\t\tgithubToken: githubToken,\n\t\tproxy:       proxy,\n\t}, nil\n}\n\n// parseGitHubRef parses a GitHub reference.\n// Supports: \"owner/repo\", \"owner/repo/path\", or full URL like \"https://github.com/owner/repo/tree/ref/path\"\nfunc parseGitHubRef(repo string) (GitHubRef, error) {\n\trepo = strings.TrimSpace(repo)\n\n\t// Handle full URL\n\tif strings.HasPrefix(repo, \"http://\") || strings.HasPrefix(repo, \"https://\") {\n\t\tu, err := url.Parse(repo)\n\t\tif err != nil {\n\t\t\treturn GitHubRef{}, fmt.Errorf(\"invalid URL: %w\", err)\n\t\t}\n\t\tparts := strings.Split(strings.Trim(u.Path, \"/\"), \"/\")\n\t\tif len(parts) < 2 {\n\t\t\treturn GitHubRef{}, fmt.Errorf(\"invalid GitHub URL\")\n\t\t}\n\t\tref := GitHubRef{\n\t\t\tOwner:    parts[0],\n\t\t\tRepoName: parts[1],\n\t\t\tRef:      \"main\",\n\t\t}\n\t\t// Look for /tree/ or /blob/ in the path\n\t\tfor i := 2; i < len(parts); i++ {\n\t\t\tif parts[i] == \"tree\" || parts[i] == \"blob\" {\n\t\t\t\tif i+1 < len(parts) {\n\t\t\t\t\tref.Ref = parts[i+1]\n\t\t\t\t\tref.SubPath = strings.Join(parts[i+2:], \"/\")\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn ref, nil\n\t}\n\n\t// Handle shorthand format\n\tparts := strings.Split(strings.Trim(repo, \"/\"), \"/\")\n\tif len(parts) < 2 {\n\t\treturn GitHubRef{}, fmt.Errorf(\"invalid format %q: expected 'owner/repo'\", repo)\n\t}\n\tref := GitHubRef{\n\t\tOwner:    parts[0],\n\t\tRepoName: parts[1],\n\t\tRef:      \"main\",\n\t}\n\tif len(parts) > 2 {\n\t\tref.SubPath = strings.Join(parts[2:], \"/\")\n\t}\n\treturn ref, nil\n}\n\nfunc (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {\n\tref, err := parseGitHubRef(repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tskillName := ref.RepoName\n\tif ref.SubPath != \"\" {\n\t\tskillName = filepath.Base(ref.SubPath)\n\t}\n\tskillDirectory := filepath.Join(si.workspace, \"skills\", skillName)\n\n\tif _, err := os.Stat(skillDirectory); err == nil {\n\t\treturn fmt.Errorf(\"skill '%s' already exists\", skillName)\n\t}\n\n\t// Build GitHub API URL\n\tapiPath := path.Join(ref.Owner, ref.RepoName, \"contents\")\n\tif ref.SubPath != \"\" {\n\t\tapiPath = path.Join(apiPath, ref.SubPath)\n\t}\n\tapiURL := fmt.Sprintf(\"https://api.github.com/repos/%s?ref=%s\", apiPath, ref.Ref)\n\n\tif err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil {\n\t\t// Fallback to raw download\n\t\treturn si.downloadRaw(ctx, ref.Owner, ref.RepoName, ref.Ref, ref.SubPath, skillDirectory)\n\t}\n\n\tif _, err := os.Stat(filepath.Join(skillDirectory, \"SKILL.md\")); err != nil {\n\t\treturn fmt.Errorf(\"SKILL.md not found in repository\")\n\t}\n\treturn nil\n}\n\n// downloadDir recursively downloads a directory from GitHub API\n// isRoot: true if this is the skill root directory (only download SKILL.md at root)\nfunc (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, localDir string, isRoot bool) error {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", apiURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif si.githubToken != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+si.githubToken)\n\t}\n\n\tresp, err := utils.DoRequestWithRetry(si.client, req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\treturn fmt.Errorf(\"HTTP %d\", resp.StatusCode)\n\t}\n\n\tvar items []GitHubContent\n\tif err := json.NewDecoder(resp.Body).Decode(&items); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, item := range items {\n\t\tlocalPath := filepath.Join(localDir, item.Name)\n\n\t\tswitch item.Type {\n\t\tcase \"file\":\n\t\t\tif !shouldDownload(item.Name, isRoot) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil {\n\t\t\t\treturn fmt.Errorf(\"download %s: %w\", item.Name, err)\n\t\t\t}\n\t\tcase \"dir\":\n\t\t\tif !isSkillDirectory(item.Name) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := si.getGithubDirAllFiles(ctx, item.URL, localPath, false); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com\nfunc (si *SkillInstaller) downloadRaw(ctx context.Context, owner, repo, ref, subPath, localDir string) error {\n\turlPath := path.Join(owner, repo, ref)\n\tif subPath != \"\" {\n\t\turlPath = path.Join(urlPath, subPath)\n\t}\n\turl := fmt.Sprintf(\"https://raw.githubusercontent.com/%s/SKILL.md\", urlPath)\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\t// Use chunked download to temporary file.\n\ttmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to fetch skill: %w\", err)\n\t}\n\tdefer os.Remove(tmpPath)\n\n\tif err := os.MkdirAll(localDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create skill directory: %w\", err)\n\t}\n\n\tlocalPath := filepath.Join(localDir, \"SKILL.md\")\n\n\t// Atomic move from temp to final location.\n\tif err := os.Rename(tmpPath, localPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to write skill file: %w\", err)\n\t}\n\n\treturn os.Chmod(localPath, 0o600)\n}\n\nfunc (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error {\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", url, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Use chunked download to temporary file, then move atomically to target.\n\ttmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer os.Remove(tmpPath)\n\n\tif err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {\n\t\treturn err\n\t}\n\n\t// Atomic move from temp to final location.\n\tif err := os.Rename(tmpPath, localPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to move downloaded file: %w\", err)\n\t}\n\n\treturn os.Chmod(localPath, 0o600)\n}\n\n// shouldDownload determines if a file should be downloaded\n// root: true if we're at the skill root directory\nfunc shouldDownload(name string, root bool) bool {\n\tif root {\n\t\treturn name == \"SKILL.md\"\n\t}\n\treturn true\n}\n\n// isSkillDir checks if a directory is a standard skill resource directory\nfunc isSkillDirectory(name string) bool {\n\tswitch name {\n\tcase \"scripts\", \"references\", \"assets\", \"templates\", \"docs\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (si *SkillInstaller) Uninstall(skillName string) error {\n\tparts := strings.Split(skillName, \"/\")\n\tvar finalSkillName string\n\tfor i := len(parts) - 1; i >= 0; i-- {\n\t\tif parts[i] != \"\" {\n\t\t\tfinalSkillName = parts[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif finalSkillName == \"\" {\n\t\tfinalSkillName = skillName\n\t}\n\n\tskillDir := filepath.Join(si.workspace, \"skills\", finalSkillName)\n\n\tif _, err := os.Stat(skillDir); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"skill '%s' not found (processed as '%s')\", skillName, finalSkillName)\n\t}\n\n\tif err := os.RemoveAll(skillDir); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove skill '%s': %w\", finalSkillName, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/skills/installer_test.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestParseGitHubRef(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\trepo           string\n\t\twantOwner      string\n\t\twantRepoName   string\n\t\twantRef        string\n\t\twantSubPath    string\n\t\twantErr        bool\n\t\twantErrContain string\n\t}{\n\t\t{\n\t\t\tname:         \"simple owner/repo\",\n\t\t\trepo:         \"sipeed/picoclaw\",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"main\",\n\t\t\twantSubPath:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"owner/repo with subpath\",\n\t\t\trepo:         \"sipeed/picoclaw/skills/test\",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"main\",\n\t\t\twantSubPath:  \"skills/test\",\n\t\t},\n\t\t{\n\t\t\tname:         \"full URL with tree\",\n\t\t\trepo:         \"https://github.com/sipeed/picoclaw/tree/dev/skills/test\",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"dev\",\n\t\t\twantSubPath:  \"skills/test\",\n\t\t},\n\t\t{\n\t\t\tname:         \"full URL with blob\",\n\t\t\trepo:         \"https://github.com/sipeed/picoclaw/blob/main/README.md\",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"main\",\n\t\t\twantSubPath:  \"README.md\",\n\t\t},\n\t\t{\n\t\t\tname:         \"full URL without ref\",\n\t\t\trepo:         \"https://github.com/sipeed/picoclaw\",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"main\",\n\t\t\twantSubPath:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid format - single part\",\n\t\t\trepo:           \"sipeed\",\n\t\t\twantErr:        true,\n\t\t\twantErrContain: \"expected 'owner/repo'\",\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid URL\",\n\t\t\trepo:           \"http://[invalid\",\n\t\t\twantErr:        true,\n\t\t\twantErrContain: \"invalid URL\",\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid GitHub URL - only one path part\",\n\t\t\trepo:           \"https://github.com/sipeed\",\n\t\t\twantErr:        true,\n\t\t\twantErrContain: \"invalid GitHub URL\",\n\t\t},\n\t\t{\n\t\t\tname:         \"with whitespace\",\n\t\t\trepo:         \"  sipeed/picoclaw  \",\n\t\t\twantOwner:    \"sipeed\",\n\t\t\twantRepoName: \"picoclaw\",\n\t\t\twantRef:      \"main\",\n\t\t\twantSubPath:  \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tref, err := parseGitHubRef(tt.repo)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"parseGitHubRef() error = nil, wantErr = true\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif tt.wantErrContain != \"\" && !strings.Contains(err.Error(), tt.wantErrContain) {\n\t\t\t\t\tt.Errorf(\"parseGitHubRef() error = %v, want error containing %v\", err, tt.wantErrContain)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"parseGitHubRef() unexpected error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ref.Owner != tt.wantOwner {\n\t\t\t\tt.Errorf(\"parseGitHubRef() owner = %v, want %v\", ref.Owner, tt.wantOwner)\n\t\t\t}\n\t\t\tif ref.RepoName != tt.wantRepoName {\n\t\t\t\tt.Errorf(\"parseGitHubRef() repoName = %v, want %v\", ref.RepoName, tt.wantRepoName)\n\t\t\t}\n\t\t\tif ref.Ref != tt.wantRef {\n\t\t\t\tt.Errorf(\"parseGitHubRef() ref = %v, want %v\", ref.Ref, tt.wantRef)\n\t\t\t}\n\t\t\tif ref.SubPath != tt.wantSubPath {\n\t\t\t\tt.Errorf(\"parseGitHubRef() subPath = %v, want %v\", ref.SubPath, tt.wantSubPath)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestShouldDownload(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tfile string\n\t\troot bool\n\t\twant bool\n\t}{\n\t\t{\"SKILL.md at root\", \"SKILL.md\", true, true},\n\t\t{\"other file at root\", \"README.md\", true, false},\n\t\t{\"script at root\", \"script.py\", true, false},\n\t\t{\"SKILL.md not at root\", \"SKILL.md\", false, true},\n\t\t{\"any file not at root\", \"any.txt\", false, true},\n\t\t{\"script not at root\", \"script.py\", false, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := shouldDownload(tt.file, tt.root)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"shouldDownload(%q, %v) = %v, want %v\", tt.file, tt.root, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsSkillDirectory(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tdir  string\n\t\twant bool\n\t}{\n\t\t{\"scripts dir\", \"scripts\", true},\n\t\t{\"references dir\", \"references\", true},\n\t\t{\"assets dir\", \"assets\", true},\n\t\t{\"templates dir\", \"templates\", true},\n\t\t{\"docs dir\", \"docs\", true},\n\t\t{\"other dir\", \"other\", false},\n\t\t{\"src dir\", \"src\", false},\n\t\t{\"empty string\", \"\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := isSkillDirectory(tt.dir)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"isSkillDirectory(%q) = %v, want %v\", tt.dir, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewSkillInstaller(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"test-token\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\tif installer == nil {\n\t\tt.Fatal(\"NewSkillInstaller() returned nil\")\n\t}\n\n\tif installer.workspace != tmpDir {\n\t\tt.Errorf(\"workspace = %v, want %v\", installer.workspace, tmpDir)\n\t}\n\n\tif installer.githubToken != \"test-token\" {\n\t\tt.Errorf(\"githubToken = %v, want 'test-token'\", installer.githubToken)\n\t}\n\n\tif installer.proxy != \"\" {\n\t\tt.Errorf(\"proxy = %v, want empty\", installer.proxy)\n\t}\n\n\tif installer.client == nil {\n\t\tt.Error(\"client is nil\")\n\t} else if installer.client.Timeout != 15*time.Second {\n\t\tt.Errorf(\"client.Timeout = %v, want 15s\", installer.client.Timeout)\n\t}\n}\n\nfunc TestNewSkillInstaller_WithProxy(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"test-token\", \"http://127.0.0.1:7890\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\tif installer.proxy != \"http://127.0.0.1:7890\" {\n\t\tt.Errorf(\"proxy = %v, want 'http://127.0.0.1:7890'\", installer.proxy)\n\t}\n\n\tif installer.client == nil {\n\t\tt.Fatal(\"client is nil\")\n\t}\n\n\t// Verify the transport has proxy configured\n\ttransport, ok := installer.client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatal(\"client.Transport is not *http.Transport\")\n\t}\n\n\tif transport.Proxy == nil {\n\t\tt.Error(\"transport.Proxy is nil, expected non-nil\")\n\t}\n}\n\nfunc TestNewSkillInstaller_InvalidProxy(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"test-token\", \"://invalid-proxy\")\n\tif err == nil {\n\t\tt.Error(\"NewSkillInstaller() expected error for invalid proxy, got nil\")\n\t}\n\tif installer != nil {\n\t\tt.Error(\"expected nil installer on error\")\n\t}\n}\n\nfunc TestSkillInstaller_DownloadFile(t *testing.T) {\n\t// Create a test server that serves files\n\tcontent := \"test file content for skill download\"\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET, got %s\", r.Method)\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(content))\n\t}))\n\tdefer server.Close()\n\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\tt.Run(\"successful download\", func(t *testing.T) {\n\t\tlocalPath := filepath.Join(tmpDir, \"test-skill\", \"SKILL.md\")\n\t\terr := installer.downloadFile(context.Background(), server.URL, localPath)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"downloadFile() error = %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\t// Verify file was downloaded\n\t\tdata, err := os.ReadFile(localPath)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to read downloaded file: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif string(data) != content {\n\t\t\tt.Errorf(\"downloaded content = %q, want %q\", string(data), content)\n\t\t}\n\n\t\t// Check file permissions\n\t\tinfo, err := os.Stat(localPath)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to stat file: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif info.Mode().Perm() != 0o600 {\n\t\t\tt.Errorf(\"file permissions = %o, want %o\", info.Mode().Perm(), 0o600)\n\t\t}\n\t})\n\n\tt.Run(\"http error\", func(t *testing.T) {\n\t\terrorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\tw.Write([]byte(\"not found\"))\n\t\t}))\n\t\tdefer errorServer.Close()\n\n\t\tlocalPath := filepath.Join(tmpDir, \"error-test\", \"SKILL.md\")\n\t\terr := installer.downloadFile(context.Background(), errorServer.URL, localPath)\n\t\tif err == nil {\n\t\t\tt.Error(\"downloadFile() expected error for 404, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestSkillInstaller_DownloadRaw(t *testing.T) {\n\tcontent := \"raw skill content\"\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(content))\n\t}))\n\tdefer server.Close()\n\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\t// Replace the client with one that points to our test server\n\t// We need to modify the URL in the function, so we'll test indirectly\n\n\tlocalDir := filepath.Join(tmpDir, \"raw-test\")\n\tctx := context.Background()\n\n\t// Create a simple test by calling downloadFile directly since downloadRaw\n\t// constructs its own URL\n\ttestFile := filepath.Join(localDir, \"SKILL.md\")\n\terr = installer.downloadFile(ctx, server.URL, testFile)\n\tif err != nil {\n\t\tt.Errorf(\"downloadFile() error = %v\", err)\n\t}\n\n\t// Verify file content\n\tdata, err := os.ReadFile(testFile)\n\tif err != nil {\n\t\tt.Errorf(\"failed to read file: %v\", err)\n\t\treturn\n\t}\n\n\tif string(data) != content {\n\t\tt.Errorf(\"content = %q, want %q\", string(data), content)\n\t}\n}\n\nfunc TestSkillInstaller_Uninstall(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tskillsDir := filepath.Join(tmpDir, \"skills\")\n\tos.MkdirAll(skillsDir, 0o755)\n\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\tt.Run(\"uninstall existing skill\", func(t *testing.T) {\n\t\tskillName := \"test-skill\"\n\t\tskillDir := filepath.Join(skillsDir, skillName)\n\n\t\t// Create skill directory with a file\n\t\tos.MkdirAll(skillDir, 0o755)\n\t\tos.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(\"test\"), 0o644)\n\n\t\tif err := installer.Uninstall(skillName); err != nil {\n\t\t\tt.Errorf(\"Uninstall() error = %v\", err)\n\t\t}\n\n\t\t// Verify directory was removed\n\t\tif _, err := os.Stat(skillDir); !os.IsNotExist(err) {\n\t\t\tt.Error(\"skill directory still exists after uninstall\")\n\t\t}\n\t})\n\n\tt.Run(\"uninstall non-existent skill\", func(t *testing.T) {\n\t\tif err := installer.Uninstall(\"non-existent-skill\"); err == nil {\n\t\t\tt.Error(\"Uninstall() expected error for non-existent skill, got nil\")\n\t\t} else if !strings.Contains(err.Error(), \"not found\") {\n\t\t\tt.Errorf(\"error message = %q, want 'not found'\", err.Error())\n\t\t}\n\t})\n\n\tt.Run(\"uninstall with path separator\", func(t *testing.T) {\n\t\tskillName := \"owner/repo/skill-name\"\n\t\tskillDir := filepath.Join(skillsDir, \"skill-name\")\n\n\t\t// Create skill directory\n\t\tos.MkdirAll(skillDir, 0o755)\n\t\tos.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(\"test\"), 0o644)\n\n\t\tif err := installer.Uninstall(skillName); err != nil {\n\t\t\tt.Errorf(\"Uninstall() error = %v\", err)\n\t\t}\n\n\t\tif _, err := os.Stat(skillDir); !os.IsNotExist(err) {\n\t\t\tt.Error(\"skill directory still exists after uninstall\")\n\t\t}\n\t})\n\n\tt.Run(\"uninstall with trailing slash\", func(t *testing.T) {\n\t\tskillName := \"skill-name/\"\n\t\tskillDir := filepath.Join(skillsDir, \"skill-name\")\n\n\t\t// Create skill directory\n\t\tos.MkdirAll(skillDir, 0o755)\n\t\tos.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(\"test\"), 0o644)\n\n\t\tif err := installer.Uninstall(skillName); err != nil {\n\t\t\tt.Errorf(\"Uninstall() error = %v\", err)\n\t\t}\n\n\t\tif _, err := os.Stat(skillDir); !os.IsNotExist(err) {\n\t\t\tt.Error(\"skill directory still exists after uninstall\")\n\t\t}\n\t})\n}\n\nfunc TestSkillInstaller_InstallFromGitHub_SkillAlreadyExists(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tskillsDir := filepath.Join(tmpDir, \"skills\")\n\tos.MkdirAll(skillsDir, 0o755)\n\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\t// Create an existing skill directory\n\texistingSkill := filepath.Join(skillsDir, \"picoclaw\")\n\tos.MkdirAll(existingSkill, 0o755)\n\tos.WriteFile(filepath.Join(existingSkill, \"SKILL.md\"), []byte(\"existing\"), 0o644)\n\n\t// Try to install the same skill - should fail\n\terr = installer.InstallFromGitHub(context.Background(), \"sipeed/picoclaw\")\n\tif err == nil {\n\t\tt.Error(\"InstallFromGitHub() expected error for existing skill, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"already exists\") {\n\t\tt.Errorf(\"error message = %q, want 'already exists'\", err.Error())\n\t}\n}\n\nfunc TestGitHubContent_Struct(t *testing.T) {\n\t// Test that GitHubContent struct can be properly unmarshaled\n\tjsonData := `{\n\t\t\"name\": \"test.md\",\n\t\t\"path\": \"skills/test.md\",\n\t\t\"type\": \"file\",\n\t\t\"download_url\": \"https://example.com/download\",\n\t\t\"url\": \"https://api.github.com/contents/skills/test.md\"\n\t}`\n\n\tvar content GitHubContent\n\terr := json.Unmarshal([]byte(jsonData), &content)\n\tif err != nil {\n\t\tt.Errorf(\"failed to unmarshal GitHubContent: %v\", err)\n\t}\n\n\tif content.Name != \"test.md\" {\n\t\tt.Errorf(\"Name = %q, want 'test.md'\", content.Name)\n\t}\n\tif content.Type != \"file\" {\n\t\tt.Errorf(\"Type = %q, want 'file'\", content.Type)\n\t}\n\tif content.DownloadURL != \"https://example.com/download\" {\n\t\tt.Errorf(\"DownloadURL = %q, want 'https://example.com/download'\", content.DownloadURL)\n\t}\n}\n\nfunc TestSkillInstaller_GetGithubDirAllFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\t// Create a test server that mimics GitHub API\n\tfileContent := \"skill file content\"\n\tvar serverURL string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Check for authorization header\n\t\tauthHeader := r.Header.Get(\"Authorization\")\n\t\tif authHeader != \"\" && !strings.HasPrefix(authHeader, \"Bearer \") {\n\t\t\tt.Errorf(\"expected Bearer token, got: %s\", authHeader)\n\t\t}\n\n\t\t// Return different responses based on path\n\t\tif strings.Contains(r.URL.Path, \"/contents\") {\n\t\t\t// API response for directory listing\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\n\t\t\titems := []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"name\":         \"SKILL.md\",\n\t\t\t\t\t\"path\":         \"SKILL.md\",\n\t\t\t\t\t\"type\":         \"file\",\n\t\t\t\t\t\"download_url\": serverURL + \"/download/SKILL.md\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"scripts\",\n\t\t\t\t\t\"path\": \"scripts\",\n\t\t\t\t\t\"type\": \"dir\",\n\t\t\t\t\t\"url\":  serverURL + \"/api/scripts\",\n\t\t\t\t},\n\t\t\t}\n\t\t\tjson.NewEncoder(w).Encode(items)\n\t\t} else if strings.Contains(r.URL.Path, \"/api/scripts\") {\n\t\t\t// API response for scripts subdirectory\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\n\t\t\titems := []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"name\":         \"test.py\",\n\t\t\t\t\t\"path\":         \"scripts/test.py\",\n\t\t\t\t\t\"type\":         \"file\",\n\t\t\t\t\t\"download_url\": serverURL + \"/download/test.py\",\n\t\t\t\t},\n\t\t\t}\n\t\t\tjson.NewEncoder(w).Encode(items)\n\t\t} else if strings.Contains(r.URL.Path, \"/download/\") {\n\t\t\t// Raw file download\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Write([]byte(fileContent))\n\t\t} else {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tserverURL = server.URL\n\tdefer server.Close()\n\n\tlocalDir := filepath.Join(tmpDir, \"test-skill\")\n\n\tt.Run(\"download from GitHub API\", func(t *testing.T) {\n\t\terr := installer.getGithubDirAllFiles(context.Background(), server.URL+\"/contents\", localDir, true)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"getGithubDirAllFiles() error = %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\t// Verify SKILL.md was downloaded\n\t\tskillMd := filepath.Join(localDir, \"SKILL.md\")\n\t\tdata, err := os.ReadFile(skillMd)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to read SKILL.md: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tif string(data) != fileContent {\n\t\t\tt.Errorf(\"SKILL.md content = %q, want %q\", string(data), fileContent)\n\t\t}\n\n\t\t// Verify scripts directory and file\n\t\tscriptFile := filepath.Join(localDir, \"scripts\", \"test.py\")\n\t\tdata, err = os.ReadFile(scriptFile)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to read test.py: %v\", err)\n\t\t\treturn\n\t\t}\n\t\tif string(data) != fileContent {\n\t\t\tt.Errorf(\"test.py content = %q, want %q\", string(data), fileContent)\n\t\t}\n\t})\n\n\tt.Run(\"http error response\", func(t *testing.T) {\n\t\terrorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t}))\n\t\tdefer errorServer.Close()\n\n\t\terr := installer.getGithubDirAllFiles(\n\t\t\tcontext.Background(),\n\t\t\terrorServer.URL,\n\t\t\tfilepath.Join(tmpDir, \"error-test\"),\n\t\t\ttrue,\n\t\t)\n\t\tif err == nil {\n\t\t\tt.Error(\"getGithubDirAllFiles() expected error for 403, got nil\")\n\t\t}\n\t})\n}\n\nfunc TestSkillInstaller_InstallFromGitHub_WithToken(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tskillsDir := filepath.Join(tmpDir, \"skills\")\n\tos.MkdirAll(skillsDir, 0o755)\n\n\tvar serverURL string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Capture the authorization header\n\t\tauthHeader := r.Header.Get(\"Authorization\")\n\t\tif authHeader != \"\" {\n\t\t\ttokenReceived := strings.TrimPrefix(authHeader, \"Bearer \")\n\t\t\tt.Fatalf(\"github token is %s\", tokenReceived)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\n\t\titems := []map[string]any{\n\t\t\t{\n\t\t\t\t\"name\":         \"SKILL.md\",\n\t\t\t\t\"path\":         \"SKILL.md\",\n\t\t\t\t\"type\":         \"file\",\n\t\t\t\t\"download_url\": serverURL + \"/download/SKILL.md\",\n\t\t\t},\n\t\t}\n\t\tjson.NewEncoder(w).Encode(items)\n\t}))\n\tserverURL = server.URL\n\tdefer server.Close()\n\n\tinstaller, err := NewSkillInstaller(tmpDir, \"test-github-token\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\t// We need to test the token is passed - the actual install will fail\n\t// because we're not fully mocking the download, but we can verify\n\t// the token is sent in the request\n\n\t// Use a simple context with timeout\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// The install will fail because download URL isn't properly set up,\n\t// but the token should be sent in the API request\n\t_ = installer.InstallFromGitHub(ctx, \"owner/repo\")\n\n\t// Note: We can't easily intercept the download request since it's a different URL,\n\t// but the fact that the API request was made verifies the token flow\n\t// In a real scenario, the token would be sent to both API and raw downloads\n}\n\nfunc TestSkillInstaller_ContextCancellation(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tinstaller, err := NewSkillInstaller(tmpDir, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"NewSkillInstaller() error = %v\", err)\n\t}\n\n\t// Create a slow server\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(100 * time.Millisecond)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"response\"))\n\t}))\n\tdefer server.Close()\n\n\t// Create a canceled context\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // Cancel immediately\n\n\tlocalPath := filepath.Join(tmpDir, \"cancel-test\", \"file.txt\")\n\terr = installer.downloadFile(ctx, server.URL, localPath)\n\n\tif err == nil {\n\t\tt.Error(\"downloadFile() expected error for canceled context, got nil\")\n\t}\n}\n"
  },
  {
    "path": "pkg/skills/loader.go",
    "content": "package skills\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/gomarkdown/markdown\"\n\t\"github.com/gomarkdown/markdown/ast\"\n\t\"github.com/gomarkdown/markdown/parser\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nvar namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)\n\nconst (\n\tMaxNameLength        = 64\n\tMaxDescriptionLength = 1024\n)\n\ntype SkillMetadata struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\ntype SkillInfo struct {\n\tName        string `json:\"name\"`\n\tPath        string `json:\"path\"`\n\tSource      string `json:\"source\"`\n\tDescription string `json:\"description\"`\n}\n\nfunc (info SkillInfo) validate() error {\n\tvar errs error\n\tif info.Name == \"\" {\n\t\terrs = errors.Join(errs, errors.New(\"name is required\"))\n\t} else {\n\t\tif len(info.Name) > MaxNameLength {\n\t\t\terrs = errors.Join(errs, fmt.Errorf(\"name exceeds %d characters\", MaxNameLength))\n\t\t}\n\t\tif !namePattern.MatchString(info.Name) {\n\t\t\terrs = errors.Join(errs, errors.New(\"name must be alphanumeric with hyphens\"))\n\t\t}\n\t}\n\n\tif info.Description == \"\" {\n\t\terrs = errors.Join(errs, errors.New(\"description is required\"))\n\t} else if len(info.Description) > MaxDescriptionLength {\n\t\terrs = errors.Join(errs, fmt.Errorf(\"description exceeds %d character\", MaxDescriptionLength))\n\t}\n\treturn errs\n}\n\ntype SkillsLoader struct {\n\tworkspace       string\n\tworkspaceSkills string // workspace skills (project-level)\n\tglobalSkills    string // global skills (~/.picoclaw/skills)\n\tbuiltinSkills   string // builtin skills\n}\n\n// SkillRoots returns all unique skill root directories used by this loader.\n// The order follows resolution priority: workspace > global > builtin.\nfunc (sl *SkillsLoader) SkillRoots() []string {\n\troots := []string{sl.workspaceSkills, sl.globalSkills, sl.builtinSkills}\n\tseen := make(map[string]struct{}, len(roots))\n\tout := make([]string, 0, len(roots))\n\n\tfor _, root := range roots {\n\t\ttrimmed := strings.TrimSpace(root)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tclean := filepath.Clean(trimmed)\n\t\tif _, ok := seen[clean]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[clean] = struct{}{}\n\t\tout = append(out, clean)\n\t}\n\n\treturn out\n}\n\nfunc NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {\n\treturn &SkillsLoader{\n\t\tworkspace:       workspace,\n\t\tworkspaceSkills: filepath.Join(workspace, \"skills\"),\n\t\tglobalSkills:    globalSkills, // ~/.picoclaw/skills\n\t\tbuiltinSkills:   builtinSkills,\n\t}\n}\n\nfunc (sl *SkillsLoader) ListSkills() []SkillInfo {\n\tskills := make([]SkillInfo, 0)\n\tseen := make(map[string]bool)\n\n\taddSkills := func(dir, source string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tdirs, err := os.ReadDir(dir)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tfor _, d := range dirs {\n\t\t\tif !d.IsDir() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tskillFile := filepath.Join(dir, d.Name(), \"SKILL.md\")\n\t\t\tif _, err := os.Stat(skillFile); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tinfo := SkillInfo{\n\t\t\t\tName:   d.Name(),\n\t\t\t\tPath:   skillFile,\n\t\t\t\tSource: source,\n\t\t\t}\n\t\t\tmetadata := sl.getSkillMetadata(skillFile)\n\t\t\tif metadata != nil {\n\t\t\t\tinfo.Description = metadata.Description\n\t\t\t\tinfo.Name = metadata.Name\n\t\t\t}\n\t\t\tif err := info.validate(); err != nil {\n\t\t\t\tslog.Warn(\"invalid skill from \"+source, \"name\", info.Name, \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif seen[info.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[info.Name] = true\n\t\t\tskills = append(skills, info)\n\t\t}\n\t}\n\n\t// Priority: workspace > global > builtin\n\taddSkills(sl.workspaceSkills, \"workspace\")\n\taddSkills(sl.globalSkills, \"global\")\n\taddSkills(sl.builtinSkills, \"builtin\")\n\n\treturn skills\n}\n\nfunc (sl *SkillsLoader) LoadSkill(name string) (string, bool) {\n\t// 1. load from workspace skills first (project-level)\n\tif sl.workspaceSkills != \"\" {\n\t\tskillFile := filepath.Join(sl.workspaceSkills, name, \"SKILL.md\")\n\t\tif content, err := os.ReadFile(skillFile); err == nil {\n\t\t\treturn sl.stripFrontmatter(string(content)), true\n\t\t}\n\t}\n\n\t// 2. then load from global skills (~/.picoclaw/skills)\n\tif sl.globalSkills != \"\" {\n\t\tskillFile := filepath.Join(sl.globalSkills, name, \"SKILL.md\")\n\t\tif content, err := os.ReadFile(skillFile); err == nil {\n\t\t\treturn sl.stripFrontmatter(string(content)), true\n\t\t}\n\t}\n\n\t// 3. finally load from builtin skills\n\tif sl.builtinSkills != \"\" {\n\t\tskillFile := filepath.Join(sl.builtinSkills, name, \"SKILL.md\")\n\t\tif content, err := os.ReadFile(skillFile); err == nil {\n\t\t\treturn sl.stripFrontmatter(string(content)), true\n\t\t}\n\t}\n\n\treturn \"\", false\n}\n\nfunc (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {\n\tif len(skillNames) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar parts []string\n\tfor _, name := range skillNames {\n\t\tcontent, ok := sl.LoadSkill(name)\n\t\tif ok {\n\t\t\tparts = append(parts, fmt.Sprintf(\"### Skill: %s\\n\\n%s\", name, content))\n\t\t}\n\t}\n\n\treturn strings.Join(parts, \"\\n\\n---\\n\\n\")\n}\n\nfunc (sl *SkillsLoader) BuildSkillsSummary() string {\n\tallSkills := sl.ListSkills()\n\tif len(allSkills) == 0 {\n\t\treturn \"\"\n\t}\n\n\tvar lines []string\n\tlines = append(lines, \"<skills>\")\n\tfor _, s := range allSkills {\n\t\tescapedName := escapeXML(s.Name)\n\t\tescapedDesc := escapeXML(s.Description)\n\t\tescapedPath := escapeXML(s.Path)\n\n\t\tlines = append(lines, fmt.Sprintf(\"  <skill>\"))\n\t\tlines = append(lines, fmt.Sprintf(\"    <name>%s</name>\", escapedName))\n\t\tlines = append(lines, fmt.Sprintf(\"    <description>%s</description>\", escapedDesc))\n\t\tlines = append(lines, fmt.Sprintf(\"    <location>%s</location>\", escapedPath))\n\t\tlines = append(lines, fmt.Sprintf(\"    <source>%s</source>\", s.Source))\n\t\tlines = append(lines, \"  </skill>\")\n\t}\n\tlines = append(lines, \"</skills>\")\n\n\treturn strings.Join(lines, \"\\n\")\n}\n\nfunc (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {\n\tcontent, err := os.ReadFile(skillPath)\n\tif err != nil {\n\t\tlogger.WarnCF(\"skills\", \"Failed to read skill metadata\",\n\t\t\tmap[string]any{\n\t\t\t\t\"skill_path\": skillPath,\n\t\t\t\t\"error\":      err.Error(),\n\t\t\t})\n\t\treturn nil\n\t}\n\n\tfrontmatter, bodyContent := splitFrontmatter(string(content))\n\tdirName := filepath.Base(filepath.Dir(skillPath))\n\ttitle, bodyDescription := extractMarkdownMetadata(bodyContent)\n\n\tmetadata := &SkillMetadata{\n\t\tName:        dirName,\n\t\tDescription: bodyDescription,\n\t}\n\tif title != \"\" && namePattern.MatchString(title) && len(title) <= MaxNameLength {\n\t\tmetadata.Name = title\n\t}\n\n\tif frontmatter == \"\" {\n\t\treturn metadata\n\t}\n\n\t// Try JSON first (for backward compatibility)\n\tvar jsonMeta struct {\n\t\tName        string `json:\"name\"`\n\t\tDescription string `json:\"description\"`\n\t}\n\tif err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {\n\t\tif jsonMeta.Name != \"\" {\n\t\t\tmetadata.Name = jsonMeta.Name\n\t\t}\n\t\tif jsonMeta.Description != \"\" {\n\t\t\tmetadata.Description = jsonMeta.Description\n\t\t}\n\t\treturn metadata\n\t}\n\n\t// Fall back to simple YAML parsing\n\tyamlMeta := sl.parseSimpleYAML(frontmatter)\n\tif name := yamlMeta[\"name\"]; name != \"\" {\n\t\tmetadata.Name = name\n\t}\n\tif description := yamlMeta[\"description\"]; description != \"\" {\n\t\tmetadata.Description = description\n\t}\n\treturn metadata\n}\n\nfunc extractMarkdownMetadata(content string) (title, description string) {\n\tp := parser.NewWithExtensions(parser.CommonExtensions)\n\tdoc := markdown.Parse([]byte(content), p)\n\tif doc == nil {\n\t\treturn \"\", \"\"\n\t}\n\n\tast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {\n\t\tif !entering {\n\t\t\treturn ast.GoToNext\n\t\t}\n\n\t\tswitch n := node.(type) {\n\t\tcase *ast.Heading:\n\t\t\tif title == \"\" && n.Level == 1 {\n\t\t\t\ttitle = nodeText(n)\n\t\t\t\tif title != \"\" && description != \"\" {\n\t\t\t\t\treturn ast.Terminate\n\t\t\t\t}\n\t\t\t}\n\t\tcase *ast.Paragraph:\n\t\t\tif description == \"\" {\n\t\t\t\tdescription = nodeText(n)\n\t\t\t\tif title != \"\" && description != \"\" {\n\t\t\t\t\treturn ast.Terminate\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn ast.GoToNext\n\t})\n\n\treturn title, description\n}\n\nfunc nodeText(n ast.Node) string {\n\tvar b strings.Builder\n\tast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus {\n\t\tif !entering {\n\t\t\treturn ast.GoToNext\n\t\t}\n\n\t\tswitch t := node.(type) {\n\t\tcase *ast.Text:\n\t\t\tb.Write(t.Literal)\n\t\tcase *ast.Code:\n\t\t\tb.Write(t.Literal)\n\t\tcase *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace:\n\t\t\tb.WriteByte(' ')\n\t\t}\n\t\treturn ast.GoToNext\n\t})\n\treturn strings.Join(strings.Fields(b.String()), \" \")\n}\n\n// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields.\nfunc (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {\n\tresult := make(map[string]string)\n\n\tvar meta struct {\n\t\tName        string `yaml:\"name\"`\n\t\tDescription string `yaml:\"description\"`\n\t}\n\tif err := yaml.Unmarshal([]byte(content), &meta); err != nil {\n\t\treturn result\n\t}\n\tif meta.Name != \"\" {\n\t\tresult[\"name\"] = meta.Name\n\t}\n\tif meta.Description != \"\" {\n\t\tresult[\"description\"] = meta.Description\n\t}\n\n\treturn result\n}\n\nfunc (sl *SkillsLoader) extractFrontmatter(content string) string {\n\tfrontmatter, _ := splitFrontmatter(content)\n\treturn frontmatter\n}\n\nfunc (sl *SkillsLoader) stripFrontmatter(content string) string {\n\t_, body := splitFrontmatter(content)\n\treturn body\n}\n\nfunc splitFrontmatter(content string) (frontmatter, body string) {\n\tnormalized := string(parser.NormalizeNewlines([]byte(content)))\n\tlines := strings.Split(normalized, \"\\n\")\n\tif len(lines) == 0 || lines[0] != \"---\" {\n\t\treturn \"\", content\n\t}\n\n\tend := -1\n\tfor i := 1; i < len(lines); i++ {\n\t\tif lines[i] == \"---\" {\n\t\t\tend = i\n\t\t\tbreak\n\t\t}\n\t}\n\tif end == -1 {\n\t\treturn \"\", content\n\t}\n\n\tfrontmatter = strings.Join(lines[1:end], \"\\n\")\n\tbody = strings.Join(lines[end+1:], \"\\n\")\n\tbody = strings.TrimLeft(body, \"\\n\")\n\treturn frontmatter, body\n}\n\nfunc escapeXML(s string) string {\n\ts = strings.ReplaceAll(s, \"&\", \"&amp;\")\n\ts = strings.ReplaceAll(s, \"<\", \"&lt;\")\n\ts = strings.ReplaceAll(s, \">\", \"&gt;\")\n\treturn s\n}\n"
  },
  {
    "path": "pkg/skills/loader_test.go",
    "content": "package skills\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestSkillsInfoValidate(t *testing.T) {\n\ttestcases := []struct {\n\t\tname        string\n\t\tskillName   string\n\t\tdescription string\n\t\twantErr     bool\n\t\terrContains []string\n\t}{\n\t\t{\n\t\t\tname:        \"valid-skill\",\n\t\t\tskillName:   \"valid-skill\",\n\t\t\tdescription: \"a valid skill description\",\n\t\t\twantErr:     false,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty-name\",\n\t\t\tskillName:   \"\",\n\t\t\tdescription: \"description without name\",\n\t\t\twantErr:     true,\n\t\t\terrContains: []string{\"name is required\"},\n\t\t},\n\t\t{\n\t\t\tname:        \"empty-description\",\n\t\t\tskillName:   \"skill-without-description\",\n\t\t\tdescription: \"\",\n\t\t\twantErr:     true,\n\t\t\terrContains: []string{\"description is required\"},\n\t\t},\n\t\t{\n\t\t\tname:        \"empty-both\",\n\t\t\tskillName:   \"\",\n\t\t\tdescription: \"\",\n\t\t\twantErr:     true,\n\t\t\terrContains: []string{\"name is required\", \"description is required\"},\n\t\t},\n\t\t{\n\t\t\tname:        \"name-with-spaces\",\n\t\t\tskillName:   \"skill with spaces\",\n\t\t\tdescription: \"invalid name with spaces\",\n\t\t\twantErr:     true,\n\t\t\terrContains: []string{\"name must be alphanumeric with hyphens\"},\n\t\t},\n\t\t{\n\t\t\tname:        \"name-with-underscore\",\n\t\t\tskillName:   \"skill_underscore\",\n\t\t\tdescription: \"invalid name with underscore\",\n\t\t\twantErr:     true,\n\t\t\terrContains: []string{\"name must be alphanumeric with hyphens\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testcases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tinfo := SkillInfo{\n\t\t\t\tName:        tc.skillName,\n\t\t\t\tDescription: tc.description,\n\t\t\t}\n\t\t\terr := info.validate()\n\t\t\tif tc.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tfor _, msg := range tc.errContains {\n\t\t\t\t\tassert.ErrorContains(t, err, msg)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractFrontmatter(t *testing.T) {\n\tsl := &SkillsLoader{}\n\n\ttestcases := []struct {\n\t\tname           string\n\t\tcontent        string\n\t\texpectedName   string\n\t\texpectedDesc   string\n\t\tlineEndingType string\n\t}{\n\t\t{\n\t\t\tname:           \"unix-line-endings\",\n\t\t\tlineEndingType: \"Unix (\\\\n)\",\n\t\t\tcontent:        \"---\\nname: test-skill\\ndescription: A test skill\\n---\\n\\n# Skill Content\",\n\t\t\texpectedName:   \"test-skill\",\n\t\t\texpectedDesc:   \"A test skill\",\n\t\t},\n\t\t{\n\t\t\tname:           \"windows-line-endings\",\n\t\t\tlineEndingType: \"Windows (\\\\r\\\\n)\",\n\t\t\tcontent:        \"---\\r\\nname: test-skill\\r\\ndescription: A test skill\\r\\n---\\r\\n\\r\\n# Skill Content\",\n\t\t\texpectedName:   \"test-skill\",\n\t\t\texpectedDesc:   \"A test skill\",\n\t\t},\n\t\t{\n\t\t\tname:           \"classic-mac-line-endings\",\n\t\t\tlineEndingType: \"Classic Mac (\\\\r)\",\n\t\t\tcontent:        \"---\\rname: test-skill\\rdescription: A test skill\\r---\\r\\r# Skill Content\",\n\t\t\texpectedName:   \"test-skill\",\n\t\t\texpectedDesc:   \"A test skill\",\n\t\t},\n\t}\n\n\tfor _, tc := range testcases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Extract frontmatter\n\t\t\tfrontmatter := sl.extractFrontmatter(tc.content)\n\t\t\tassert.NotEmpty(t, frontmatter, \"Frontmatter should be extracted for %s line endings\", tc.lineEndingType)\n\n\t\t\t// Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)\n\t\t\tyamlMeta := sl.parseSimpleYAML(frontmatter)\n\t\t\tassert.Equal(\n\t\t\t\tt,\n\t\t\t\ttc.expectedName,\n\t\t\t\tyamlMeta[\"name\"],\n\t\t\t\t\"Name should be correctly parsed from frontmatter with %s line endings\",\n\t\t\t\ttc.lineEndingType,\n\t\t\t)\n\t\t\tassert.Equal(\n\t\t\t\tt,\n\t\t\t\ttc.expectedDesc,\n\t\t\t\tyamlMeta[\"description\"],\n\t\t\t\t\"Description should be correctly parsed from frontmatter with %s line endings\",\n\t\t\t\ttc.lineEndingType,\n\t\t\t)\n\t\t})\n\t}\n}\n\n// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter.\nfunc createSkillDir(t *testing.T, base, dirName, name, description string) {\n\tt.Helper()\n\tdir := filepath.Join(base, dirName)\n\trequire.NoError(t, os.MkdirAll(dir, 0o755))\n\tcontent := \"---\\nname: \" + name + \"\\ndescription: \" + description + \"\\n---\\n\\n# \" + name\n\trequire.NoError(t, os.WriteFile(filepath.Join(dir, \"SKILL.md\"), []byte(content), 0o644))\n}\n\nfunc TestListSkillsWorkspaceOverridesGlobal(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\n\tcreateSkillDir(t, filepath.Join(ws, \"skills\"), \"my-skill\", \"my-skill\", \"workspace version\")\n\tcreateSkillDir(t, global, \"my-skill\", \"my-skill\", \"global version\")\n\n\tsl := NewSkillsLoader(ws, global, \"\")\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 1)\n\tassert.Equal(t, \"workspace\", skills[0].Source)\n\tassert.Equal(t, \"workspace version\", skills[0].Description)\n}\n\nfunc TestListSkillsGlobalOverridesBuiltin(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\tbuiltin := filepath.Join(tmp, \"builtin\")\n\n\tcreateSkillDir(t, global, \"my-skill\", \"my-skill\", \"global version\")\n\tcreateSkillDir(t, builtin, \"my-skill\", \"my-skill\", \"builtin version\")\n\n\tsl := NewSkillsLoader(ws, global, builtin)\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 1)\n\tassert.Equal(t, \"global\", skills[0].Source)\n\tassert.Equal(t, \"global version\", skills[0].Description)\n}\n\nfunc TestListSkillsMetadataNameDedup(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\n\t// Different directory names but same metadata name\n\tcreateSkillDir(t, filepath.Join(ws, \"skills\"), \"dir-a\", \"shared-name\", \"workspace version\")\n\tcreateSkillDir(t, global, \"dir-b\", \"shared-name\", \"global version\")\n\n\tsl := NewSkillsLoader(ws, global, \"\")\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 1)\n\tassert.Equal(t, \"shared-name\", skills[0].Name)\n\tassert.Equal(t, \"workspace\", skills[0].Source)\n}\n\nfunc TestListSkillsMultipleDistinctSkills(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\tbuiltin := filepath.Join(tmp, \"builtin\")\n\n\tcreateSkillDir(t, filepath.Join(ws, \"skills\"), \"skill-a\", \"skill-a\", \"desc a\")\n\tcreateSkillDir(t, global, \"skill-b\", \"skill-b\", \"desc b\")\n\tcreateSkillDir(t, builtin, \"skill-c\", \"skill-c\", \"desc c\")\n\n\tsl := NewSkillsLoader(ws, global, builtin)\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 3)\n\tnames := map[string]string{}\n\tfor _, s := range skills {\n\t\tnames[s.Name] = s.Source\n\t}\n\tassert.Equal(t, \"workspace\", names[\"skill-a\"])\n\tassert.Equal(t, \"global\", names[\"skill-b\"])\n\tassert.Equal(t, \"builtin\", names[\"skill-c\"])\n}\n\nfunc TestListSkillsInvalidSkillSkipped(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\n\t// Invalid name (underscore)\n\tcreateSkillDir(t, filepath.Join(ws, \"skills\"), \"bad_skill\", \"bad_skill\", \"desc\")\n\t// Valid skill\n\tcreateSkillDir(t, global, \"good-skill\", \"good-skill\", \"desc\")\n\n\tsl := NewSkillsLoader(ws, global, \"\")\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 1)\n\tassert.Equal(t, \"good-skill\", skills[0].Name)\n}\n\nfunc TestListSkillsEmptyAndNonexistentDirs(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\temptyDir := filepath.Join(tmp, \"empty\")\n\trequire.NoError(t, os.MkdirAll(emptyDir, 0o755))\n\n\tsl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, \"nonexistent\"))\n\tskills := sl.ListSkills()\n\n\tassert.Empty(t, skills)\n}\n\nfunc TestListSkillsDirWithoutSkillMD(t *testing.T) {\n\ttmp := t.TempDir()\n\tws := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\n\t// Directory exists but has no SKILL.md\n\trequire.NoError(t, os.MkdirAll(filepath.Join(global, \"no-skillmd\"), 0o755))\n\t// Valid skill alongside\n\tcreateSkillDir(t, global, \"real-skill\", \"real-skill\", \"desc\")\n\n\tsl := NewSkillsLoader(ws, global, \"\")\n\tskills := sl.ListSkills()\n\n\tassert.Len(t, skills, 1)\n\tassert.Equal(t, \"real-skill\", skills[0].Name)\n}\n\nfunc TestStripFrontmatter(t *testing.T) {\n\tsl := &SkillsLoader{}\n\n\ttestcases := []struct {\n\t\tname            string\n\t\tcontent         string\n\t\texpectedContent string\n\t\tlineEndingType  string\n\t}{\n\t\t{\n\t\t\tname:            \"unix-line-endings\",\n\t\t\tlineEndingType:  \"Unix (\\\\n)\",\n\t\t\tcontent:         \"---\\nname: test-skill\\ndescription: A test skill\\n---\\n\\n# Skill Content\",\n\t\t\texpectedContent: \"# Skill Content\",\n\t\t},\n\t\t{\n\t\t\tname:            \"windows-line-endings\",\n\t\t\tlineEndingType:  \"Windows (\\\\r\\\\n)\",\n\t\t\tcontent:         \"---\\r\\nname: test-skill\\r\\ndescription: A test skill\\r\\n---\\r\\n\\r\\n# Skill Content\",\n\t\t\texpectedContent: \"# Skill Content\",\n\t\t},\n\t\t{\n\t\t\tname:            \"classic-mac-line-endings\",\n\t\t\tlineEndingType:  \"Classic Mac (\\\\r)\",\n\t\t\tcontent:         \"---\\rname: test-skill\\rdescription: A test skill\\r---\\r\\r# Skill Content\",\n\t\t\texpectedContent: \"# Skill Content\",\n\t\t},\n\t\t{\n\t\t\tname:            \"unix-line-endings-without-trailing-newline\",\n\t\t\tlineEndingType:  \"Unix (\\\\n) without trailing newline\",\n\t\t\tcontent:         \"---\\nname: test-skill\\ndescription: A test skill\\n---\\n# Skill Content\",\n\t\t\texpectedContent: \"# Skill Content\",\n\t\t},\n\t\t{\n\t\t\tname:            \"windows-line-endings-without-trailing-newline\",\n\t\t\tlineEndingType:  \"Windows (\\\\r\\\\n) without trailing newline\",\n\t\t\tcontent:         \"---\\r\\nname: test-skill\\r\\ndescription: A test skill\\r\\n---\\r\\n# Skill Content\",\n\t\t\texpectedContent: \"# Skill Content\",\n\t\t},\n\t\t{\n\t\t\tname:            \"no-frontmatter\",\n\t\t\tlineEndingType:  \"No frontmatter\",\n\t\t\tcontent:         \"# Skill Content\\n\\nSome content here.\",\n\t\t\texpectedContent: \"# Skill Content\\n\\nSome content here.\",\n\t\t},\n\t}\n\n\tfor _, tc := range testcases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := sl.stripFrontmatter(tc.content)\n\t\t\tassert.Equal(\n\t\t\t\tt,\n\t\t\t\ttc.expectedContent,\n\t\t\t\tresult,\n\t\t\t\t\"Frontmatter should be stripped correctly for %s\",\n\t\t\t\ttc.lineEndingType,\n\t\t\t)\n\t\t})\n\t}\n}\n\nfunc TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {\n\ttmp := t.TempDir()\n\tworkspace := filepath.Join(tmp, \"workspace\")\n\tglobal := filepath.Join(tmp, \"global\")\n\tbuiltin := filepath.Join(tmp, \"builtin\")\n\n\tsl := NewSkillsLoader(workspace, \"  \"+global+\"  \", \"\\t\"+builtin+\"\\n\")\n\troots := sl.SkillRoots()\n\n\tassert.Equal(t, []string{\n\t\tfilepath.Join(workspace, \"skills\"),\n\t\tglobal,\n\t\tbuiltin,\n\t}, roots)\n}\n\nfunc TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) {\n\ttmp := t.TempDir()\n\tskillDir := filepath.Join(tmp, \"workspace\", \"skills\", \"plain-skill\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\tcontent := \"# Plain Skill\\n\\nThis is parsed from markdown paragraph.\\n\"\n\trequire.NoError(t, os.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(content), 0o644))\n\n\tsl := &SkillsLoader{}\n\tmeta := sl.getSkillMetadata(filepath.Join(skillDir, \"SKILL.md\"))\n\trequire.NotNil(t, meta)\n\tassert.Equal(t, \"plain-skill\", meta.Name)\n\tassert.Equal(t, \"This is parsed from markdown paragraph.\", meta.Description)\n}\n\nfunc TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) {\n\ttmp := t.TempDir()\n\tskillDir := filepath.Join(tmp, \"workspace\", \"skills\", \"plain-skill\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\tcontent := \"---\\nname: frontmatter-skill\\ndescription: frontmatter description\\n---\\n\\n# Plain Skill\\n\\nBody description.\\n\"\n\trequire.NoError(t, os.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(content), 0o644))\n\n\tsl := &SkillsLoader{}\n\tmeta := sl.getSkillMetadata(filepath.Join(skillDir, \"SKILL.md\"))\n\trequire.NotNil(t, meta)\n\tassert.Equal(t, \"frontmatter-skill\", meta.Name)\n\tassert.Equal(t, \"frontmatter description\", meta.Description)\n}\n\nfunc TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) {\n\ttmp := t.TempDir()\n\tskillDir := filepath.Join(tmp, \"workspace\", \"skills\", \"plain-skill\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\tcontent := \"---\\nname: frontmatter-skill\\ndescription: |\\n  line 1: with colon\\n  line 2\\n---\\n\\n# Plain Skill\\n\\nBody description.\\n\"\n\trequire.NoError(t, os.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(content), 0o644))\n\n\tsl := &SkillsLoader{}\n\tmeta := sl.getSkillMetadata(filepath.Join(skillDir, \"SKILL.md\"))\n\trequire.NotNil(t, meta)\n\tassert.Equal(t, \"frontmatter-skill\", meta.Name)\n\tassert.Equal(t, \"line 1: with colon\\nline 2\", meta.Description)\n}\n\nfunc TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) {\n\ttmp := t.TempDir()\n\tskillDir := filepath.Join(tmp, \"workspace\", \"skills\", \"valid-name\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\tcontent := \"# Invalid Heading Name\\n\\nBody description.\\n\"\n\trequire.NoError(t, os.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(content), 0o644))\n\n\tsl := &SkillsLoader{}\n\tmeta := sl.getSkillMetadata(filepath.Join(skillDir, \"SKILL.md\"))\n\trequire.NotNil(t, meta)\n\tassert.Equal(t, \"valid-name\", meta.Name)\n\tassert.Equal(t, \"Body description.\", meta.Description)\n}\n\nfunc TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) {\n\ttmp := t.TempDir()\n\tskillDir := filepath.Join(tmp, \"workspace\", \"skills\", \"biomed-skill\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\tcontent := \"<!--\\n# COPYRIGHT NOTICE\\n# This file is part of the \\\"Universal Biomedical Skills\\\" project.\\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\\n# All Rights Reserved.\\n#\\n# This code is proprietary and confidential.\\n# Unauthorized copying of this file, via any medium is strictly prohibited.\\n#\\n# Provenance: Authenticated by MD BABU MIA\\n\\n-->\\n\\n# Biomed Skill\\n\\nSummarize biomedical papers.\\n\"\n\trequire.NoError(t, os.WriteFile(filepath.Join(skillDir, \"SKILL.md\"), []byte(content), 0o644))\n\n\tsl := &SkillsLoader{}\n\tmeta := sl.getSkillMetadata(filepath.Join(skillDir, \"SKILL.md\"))\n\trequire.NotNil(t, meta)\n\tassert.Equal(t, \"biomed-skill\", meta.Name)\n\tassert.Equal(t, \"Summarize biomedical papers.\", meta.Description)\n}\n"
  },
  {
    "path": "pkg/skills/registry.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\tdefaultMaxConcurrentSearches = 2\n)\n\n// SearchResult represents a single result from a skill registry search.\ntype SearchResult struct {\n\tScore        float64 `json:\"score\"`\n\tSlug         string  `json:\"slug\"`\n\tDisplayName  string  `json:\"display_name\"`\n\tSummary      string  `json:\"summary\"`\n\tVersion      string  `json:\"version\"`\n\tRegistryName string  `json:\"registry_name\"`\n}\n\n// SkillMeta holds metadata about a skill from a registry.\ntype SkillMeta struct {\n\tSlug             string `json:\"slug\"`\n\tDisplayName      string `json:\"display_name\"`\n\tSummary          string `json:\"summary\"`\n\tLatestVersion    string `json:\"latest_version\"`\n\tIsMalwareBlocked bool   `json:\"is_malware_blocked\"`\n\tIsSuspicious     bool   `json:\"is_suspicious\"`\n\tRegistryName     string `json:\"registry_name\"`\n}\n\n// InstallResult is returned by DownloadAndInstall to carry metadata\n// back to the caller for moderation and user messaging.\ntype InstallResult struct {\n\tVersion          string\n\tIsMalwareBlocked bool\n\tIsSuspicious     bool\n\tSummary          string\n}\n\n// SkillRegistry is the interface that all skill registries must implement.\n// Each registry represents a different source of skills (e.g., clawhub.ai)\ntype SkillRegistry interface {\n\t// Name returns the unique name of this registry (e.g., \"clawhub\").\n\tName() string\n\t// Search searches the registry for skills matching the query.\n\tSearch(ctx context.Context, query string, limit int) ([]SearchResult, error)\n\t// GetSkillMeta retrieves metadata for a specific skill by slug.\n\tGetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error)\n\t// DownloadAndInstall fetches metadata, resolves the version, downloads and\n\t// installs the skill to targetDir. Returns an InstallResult with metadata\n\t// for the caller to use for moderation and user messaging.\n\tDownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)\n}\n\n// RegistryConfig holds configuration for all skill registries.\n// This is the input to NewRegistryManagerFromConfig.\ntype RegistryConfig struct {\n\tClawHub               ClawHubConfig\n\tMaxConcurrentSearches int\n}\n\n// ClawHubConfig configures the ClawHub registry.\ntype ClawHubConfig struct {\n\tEnabled         bool\n\tBaseURL         string\n\tAuthToken       string\n\tSearchPath      string // e.g. \"/api/v1/search\"\n\tSkillsPath      string // e.g. \"/api/v1/skills\"\n\tDownloadPath    string // e.g. \"/api/v1/download\"\n\tTimeout         int    // seconds, 0 = default (30s)\n\tMaxZipSize      int    // bytes, 0 = default (50MB)\n\tMaxResponseSize int    // bytes, 0 = default (2MB)\n}\n\n// RegistryManager coordinates multiple skill registries.\n// It fans out search requests and routes installs to the correct registry.\ntype RegistryManager struct {\n\tregistries    []SkillRegistry\n\tmaxConcurrent int\n\tmu            sync.RWMutex\n}\n\n// NewRegistryManager creates an empty RegistryManager.\nfunc NewRegistryManager() *RegistryManager {\n\treturn &RegistryManager{\n\t\tregistries:    make([]SkillRegistry, 0),\n\t\tmaxConcurrent: defaultMaxConcurrentSearches,\n\t}\n}\n\n// NewRegistryManagerFromConfig builds a RegistryManager from config,\n// instantiating only the enabled registries.\nfunc NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {\n\trm := NewRegistryManager()\n\tif cfg.MaxConcurrentSearches > 0 {\n\t\trm.maxConcurrent = cfg.MaxConcurrentSearches\n\t}\n\tif cfg.ClawHub.Enabled {\n\t\trm.AddRegistry(NewClawHubRegistry(cfg.ClawHub))\n\t}\n\treturn rm\n}\n\n// AddRegistry adds a registry to the manager.\nfunc (rm *RegistryManager) AddRegistry(r SkillRegistry) {\n\trm.mu.Lock()\n\tdefer rm.mu.Unlock()\n\trm.registries = append(rm.registries, r)\n}\n\n// GetRegistry returns a registry by name, or nil if not found.\nfunc (rm *RegistryManager) GetRegistry(name string) SkillRegistry {\n\trm.mu.RLock()\n\tdefer rm.mu.RUnlock()\n\tfor _, r := range rm.registries {\n\t\tif r.Name() == name {\n\t\t\treturn r\n\t\t}\n\t}\n\treturn nil\n}\n\n// SearchAll fans out the query to all registries concurrently\n// and merges results sorted by score descending.\nfunc (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {\n\trm.mu.RLock()\n\tregs := make([]SkillRegistry, len(rm.registries))\n\tcopy(regs, rm.registries)\n\trm.mu.RUnlock()\n\n\tif len(regs) == 0 {\n\t\treturn nil, fmt.Errorf(\"no registries configured\")\n\t}\n\n\ttype regResult struct {\n\t\tresults []SearchResult\n\t\terr     error\n\t}\n\n\t// Semaphore: limit concurrency.\n\tsem := make(chan struct{}, rm.maxConcurrent)\n\tresultsCh := make(chan regResult, len(regs))\n\n\tvar wg sync.WaitGroup\n\tfor _, reg := range regs {\n\t\twg.Add(1)\n\t\tgo func(r SkillRegistry) {\n\t\t\tdefer wg.Done()\n\n\t\t\t// Acquire semaphore slot.\n\t\t\tselect {\n\t\t\tcase sem <- struct{}{}:\n\t\t\t\tdefer func() { <-sem }()\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresultsCh <- regResult{err: ctx.Err()}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsearchCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)\n\t\t\tdefer cancel()\n\n\t\t\tresults, err := r.Search(searchCtx, query, limit)\n\t\t\tif err != nil {\n\t\t\t\tslog.Warn(\"registry search failed\", \"registry\", r.Name(), \"error\", err)\n\t\t\t\tresultsCh <- regResult{err: err}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresultsCh <- regResult{results: results}\n\t\t}(reg)\n\t}\n\n\t// Close results channel after all goroutines complete.\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultsCh)\n\t}()\n\n\tvar merged []SearchResult\n\tvar lastErr error\n\n\tvar anyRegistrySucceeded bool\n\tfor rr := range resultsCh {\n\t\tif rr.err != nil {\n\t\t\tlastErr = rr.err\n\t\t\tcontinue\n\t\t}\n\t\tanyRegistrySucceeded = true\n\t\tmerged = append(merged, rr.results...)\n\t}\n\n\t// If all registries failed, return the last error.\n\tif !anyRegistrySucceeded && lastErr != nil {\n\t\treturn nil, fmt.Errorf(\"all registries failed: %w\", lastErr)\n\t}\n\n\t// Sort by score descending.\n\tsortByScoreDesc(merged)\n\n\t// Clamp to limit.\n\tif limit > 0 && len(merged) > limit {\n\t\tmerged = merged[:limit]\n\t}\n\n\treturn merged, nil\n}\n\n// sortByScoreDesc sorts SearchResults by Score in descending order (insertion sort — small slices).\nfunc sortByScoreDesc(results []SearchResult) {\n\tfor i := 1; i < len(results); i++ {\n\t\tkey := results[i]\n\t\tj := i - 1\n\t\tfor j >= 0 && results[j].Score < key.Score {\n\t\t\tresults[j+1] = results[j]\n\t\t\tj--\n\t\t}\n\t\tresults[j+1] = key\n\t}\n}\n"
  },
  {
    "path": "pkg/skills/registry_test.go",
    "content": "package skills\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// mockRegistry is a test double implementing SkillRegistry.\ntype mockRegistry struct {\n\tname          string\n\tsearchResults []SearchResult\n\tsearchErr     error\n\tmeta          *SkillMeta\n\tmetaErr       error\n\tinstallResult *InstallResult\n\tinstallErr    error\n}\n\nfunc (m *mockRegistry) Name() string { return m.name }\n\nfunc (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) {\n\treturn m.searchResults, m.searchErr\n}\n\nfunc (m *mockRegistry) GetSkillMeta(_ context.Context, _ string) (*SkillMeta, error) {\n\treturn m.meta, m.metaErr\n}\n\nfunc (m *mockRegistry) DownloadAndInstall(_ context.Context, _, _, _ string) (*InstallResult, error) {\n\treturn m.installResult, m.installErr\n}\n\nfunc TestRegistryManagerSearchAllSingle(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname: \"test\",\n\t\tsearchResults: []SearchResult{\n\t\t\t{Slug: \"skill-a\", Score: 0.9, RegistryName: \"test\"},\n\t\t\t{Slug: \"skill-b\", Score: 0.5, RegistryName: \"test\"},\n\t\t},\n\t})\n\n\tresults, err := mgr.SearchAll(context.Background(), \"test query\", 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, results, 2)\n\tassert.Equal(t, \"skill-a\", results[0].Slug)\n}\n\nfunc TestRegistryManagerSearchAllMultiple(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname: \"alpha\",\n\t\tsearchResults: []SearchResult{\n\t\t\t{Slug: \"skill-a\", Score: 0.8, RegistryName: \"alpha\"},\n\t\t},\n\t})\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname: \"beta\",\n\t\tsearchResults: []SearchResult{\n\t\t\t{Slug: \"skill-b\", Score: 0.95, RegistryName: \"beta\"},\n\t\t},\n\t})\n\n\tresults, err := mgr.SearchAll(context.Background(), \"test query\", 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, results, 2)\n\t// Should be sorted by score descending\n\tassert.Equal(t, \"skill-b\", results[0].Slug)\n\tassert.Equal(t, \"skill-a\", results[1].Slug)\n}\n\nfunc TestRegistryManagerSearchAllOneFailsGracefully(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname:      \"failing\",\n\t\tsearchErr: fmt.Errorf(\"network error\"),\n\t})\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname: \"working\",\n\t\tsearchResults: []SearchResult{\n\t\t\t{Slug: \"skill-a\", Score: 0.8, RegistryName: \"working\"},\n\t\t},\n\t})\n\n\tresults, err := mgr.SearchAll(context.Background(), \"test query\", 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, results, 1)\n\tassert.Equal(t, \"skill-a\", results[0].Slug)\n}\n\nfunc TestRegistryManagerSearchAllAllFail(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname:      \"fail-1\",\n\t\tsearchErr: fmt.Errorf(\"error 1\"),\n\t})\n\n\t_, err := mgr.SearchAll(context.Background(), \"test query\", 10)\n\tassert.Error(t, err)\n}\n\nfunc TestRegistryManagerSearchAllNoRegistries(t *testing.T) {\n\tmgr := NewRegistryManager()\n\t_, err := mgr.SearchAll(context.Background(), \"test query\", 10)\n\tassert.Error(t, err)\n}\n\nfunc TestRegistryManagerGetRegistry(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tmock := &mockRegistry{name: \"clawhub\"}\n\tmgr.AddRegistry(mock)\n\n\tgot := mgr.GetRegistry(\"clawhub\")\n\tassert.NotNil(t, got)\n\tassert.Equal(t, \"clawhub\", got.Name())\n\n\tgot = mgr.GetRegistry(\"nonexistent\")\n\tassert.Nil(t, got)\n}\n\nfunc TestRegistryManagerSearchAllRespectLimit(t *testing.T) {\n\tmgr := NewRegistryManager()\n\tresults := make([]SearchResult, 20)\n\tfor i := range results {\n\t\tresults[i] = SearchResult{Slug: fmt.Sprintf(\"skill-%d\", i), Score: float64(20 - i)}\n\t}\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname:          \"test\",\n\t\tsearchResults: results,\n\t})\n\n\tgot, err := mgr.SearchAll(context.Background(), \"test\", 5)\n\tassert.NoError(t, err)\n\tassert.Len(t, got, 5)\n\t// Top scores first\n\tassert.Equal(t, \"skill-0\", got[0].Slug)\n}\n\nfunc TestRegistryManagerSearchAllTimeout(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)\n\tdefer cancel()\n\n\ttime.Sleep(5 * time.Millisecond) // Let context expire.\n\n\tmgr := NewRegistryManager()\n\tmgr.AddRegistry(&mockRegistry{\n\t\tname:      \"slow\",\n\t\tsearchErr: fmt.Errorf(\"context deadline exceeded\"),\n\t})\n\n\t_, err := mgr.SearchAll(ctx, \"test\", 5)\n\tassert.Error(t, err)\n}\n\nfunc TestSortByScoreDesc(t *testing.T) {\n\tresults := []SearchResult{\n\t\t{Slug: \"c\", Score: 0.3},\n\t\t{Slug: \"a\", Score: 0.9},\n\t\t{Slug: \"b\", Score: 0.5},\n\t}\n\tsortByScoreDesc(results)\n\tassert.Equal(t, \"a\", results[0].Slug)\n\tassert.Equal(t, \"b\", results[1].Slug)\n\tassert.Equal(t, \"c\", results[2].Slug)\n}\n\nfunc TestIsSafeSlug(t *testing.T) {\n\tassert.NoError(t, utils.ValidateSkillIdentifier(\"github\"))\n\tassert.NoError(t, utils.ValidateSkillIdentifier(\"docker-compose\"))\n\tassert.Error(t, utils.ValidateSkillIdentifier(\"\"))\n\tassert.Error(t, utils.ValidateSkillIdentifier(\"../etc/passwd\"))\n\tassert.Error(t, utils.ValidateSkillIdentifier(\"path/traversal\"))\n\tassert.Error(t, utils.ValidateSkillIdentifier(\"path\\\\traversal\"))\n}\n"
  },
  {
    "path": "pkg/skills/search_cache.go",
    "content": "package skills\n\nimport (\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\n// SearchCache provides lightweight caching for search results.\n// It uses trigram-based similarity to match similar queries to cached results,\n// avoiding redundant API calls. Thread-safe for concurrent access.\ntype SearchCache struct {\n\tmu         sync.RWMutex\n\tentries    map[string]*cacheEntry\n\torder      []string // LRU order: oldest first.\n\tmaxEntries int\n\tttl        time.Duration\n}\n\ntype cacheEntry struct {\n\tquery     string\n\ttrigrams  []uint32\n\tresults   []SearchResult\n\tcreatedAt time.Time\n}\n\n// similarityThreshold is the minimum trigram Jaccard similarity for a cache hit.\nconst similarityThreshold = 0.7\n\n// NewSearchCache creates a new search cache.\n// maxEntries is the maximum number of cached queries (excess evicts LRU).\n// ttl is how long each entry lives before expiration.\nfunc NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache {\n\tif maxEntries <= 0 {\n\t\tmaxEntries = 50\n\t}\n\tif ttl <= 0 {\n\t\tttl = 5 * time.Minute\n\t}\n\treturn &SearchCache{\n\t\tentries:    make(map[string]*cacheEntry),\n\t\torder:      make([]string, 0),\n\t\tmaxEntries: maxEntries,\n\t\tttl:        ttl,\n\t}\n}\n\n// Get looks up results for a query. Returns cached results and true if found\n// (either exact or similar match above threshold). Returns nil, false on miss.\nfunc (sc *SearchCache) Get(query string) ([]SearchResult, bool) {\n\tnormalized := normalizeQuery(query)\n\tif normalized == \"\" {\n\t\treturn nil, false\n\t}\n\n\tsc.mu.Lock()\n\tdefer sc.mu.Unlock()\n\n\t// Exact match first.\n\tif entry, ok := sc.entries[normalized]; ok {\n\t\tif time.Since(entry.createdAt) < sc.ttl {\n\t\t\tsc.moveToEndLocked(normalized)\n\t\t\treturn copyResults(entry.results), true\n\t\t}\n\t}\n\n\t// Similarity match.\n\tqueryTrigrams := buildTrigrams(normalized)\n\tvar bestEntry *cacheEntry\n\tvar bestSim float64\n\n\tfor _, entry := range sc.entries {\n\t\tif time.Since(entry.createdAt) >= sc.ttl {\n\t\t\tcontinue // Skip expired.\n\t\t}\n\t\tsim := jaccardSimilarity(queryTrigrams, entry.trigrams)\n\t\tif sim > bestSim {\n\t\t\tbestSim = sim\n\t\t\tbestEntry = entry\n\t\t}\n\t}\n\n\tif bestSim >= similarityThreshold && bestEntry != nil {\n\t\tsc.moveToEndLocked(bestEntry.query)\n\t\treturn copyResults(bestEntry.results), true\n\t}\n\n\treturn nil, false\n}\n\n// Put stores results for a query. Evicts the oldest entry if at capacity.\nfunc (sc *SearchCache) Put(query string, results []SearchResult) {\n\tnormalized := normalizeQuery(query)\n\tif normalized == \"\" {\n\t\treturn\n\t}\n\n\tsc.mu.Lock()\n\tdefer sc.mu.Unlock()\n\n\t// Evict expired entries first.\n\tsc.evictExpiredLocked()\n\n\t// If already exists, update.\n\tif _, ok := sc.entries[normalized]; ok {\n\t\tsc.entries[normalized] = &cacheEntry{\n\t\t\tquery:     normalized,\n\t\t\ttrigrams:  buildTrigrams(normalized),\n\t\t\tresults:   copyResults(results),\n\t\t\tcreatedAt: time.Now(),\n\t\t}\n\t\t// Move to end of LRU order.\n\t\tsc.moveToEndLocked(normalized)\n\t\treturn\n\t}\n\n\t// Evict LRU if at capacity.\n\tfor len(sc.entries) >= sc.maxEntries && len(sc.order) > 0 {\n\t\toldest := sc.order[0]\n\t\tsc.order = sc.order[1:]\n\t\tdelete(sc.entries, oldest)\n\t}\n\n\t// Insert new entry.\n\tsc.entries[normalized] = &cacheEntry{\n\t\tquery:     normalized,\n\t\ttrigrams:  buildTrigrams(normalized),\n\t\tresults:   copyResults(results),\n\t\tcreatedAt: time.Now(),\n\t}\n\tsc.order = append(sc.order, normalized)\n}\n\n// Len returns the number of entries (for testing).\nfunc (sc *SearchCache) Len() int {\n\tsc.mu.RLock()\n\tdefer sc.mu.RUnlock()\n\treturn len(sc.entries)\n}\n\n// --- internal ---\n\nfunc (sc *SearchCache) evictExpiredLocked() {\n\tnow := time.Now()\n\tnewOrder := make([]string, 0, len(sc.order))\n\tfor _, key := range sc.order {\n\t\tentry, ok := sc.entries[key]\n\t\tif !ok || now.Sub(entry.createdAt) >= sc.ttl {\n\t\t\tdelete(sc.entries, key)\n\t\t\tcontinue\n\t\t}\n\t\tnewOrder = append(newOrder, key)\n\t}\n\tsc.order = newOrder\n}\n\nfunc (sc *SearchCache) moveToEndLocked(key string) {\n\tfor i, k := range sc.order {\n\t\tif k == key {\n\t\t\tsc.order = append(sc.order[:i], sc.order[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\tsc.order = append(sc.order, key)\n}\n\nfunc normalizeQuery(q string) string {\n\treturn strings.ToLower(strings.TrimSpace(q))\n}\n\n// buildTrigrams generates hash of trigrams from a string.\n// Example: \"hello\" → {\"hel\", \"ell\", \"llo\"}\n// \"hel\" -> 0x0068656c -> 4 bytes; compared to 16 bytes of a string\nfunc buildTrigrams(s string) []uint32 {\n\tif len(s) < 3 {\n\t\treturn nil\n\t}\n\n\ttrigrams := make([]uint32, 0, len(s)-2)\n\tfor i := 0; i <= len(s)-3; i++ {\n\t\ttrigrams = append(trigrams, uint32(s[i])<<16|uint32(s[i+1])<<8|uint32(s[i+2]))\n\t}\n\n\t// Sort and Deduplication\n\tslices.Sort(trigrams)\n\tn := 1\n\tfor i := 1; i < len(trigrams); i++ {\n\t\tif trigrams[i] != trigrams[i-1] {\n\t\t\ttrigrams[n] = trigrams[i]\n\t\t\tn++\n\t\t}\n\t}\n\n\treturn trigrams[:n]\n}\n\n// jaccardSimilarity computes |A ∩ B| / |A ∪ B|.\nfunc jaccardSimilarity(a, b []uint32) float64 {\n\tif len(a) == 0 && len(b) == 0 {\n\t\treturn 1\n\t}\n\ti, j := 0, 0\n\tintersection := 0\n\n\tfor i < len(a) && j < len(b) {\n\t\tif a[i] == b[j] {\n\t\t\tintersection++\n\t\t\ti++\n\t\t\tj++\n\t\t} else if a[i] < b[j] {\n\t\t\ti++\n\t\t} else {\n\t\t\tj++\n\t\t}\n\t}\n\n\tunion := len(a) + len(b) - intersection\n\treturn float64(intersection) / float64(union)\n}\n\nfunc copyResults(results []SearchResult) []SearchResult {\n\tif results == nil {\n\t\treturn nil\n\t}\n\tcp := make([]SearchResult, len(results))\n\tcopy(cp, results)\n\treturn cp\n}\n"
  },
  {
    "path": "pkg/skills/search_cache_test.go",
    "content": "package skills\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSearchCacheExactHit(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\tresults := []SearchResult{\n\t\t{Slug: \"github\", Score: 0.9, RegistryName: \"clawhub\"},\n\t\t{Slug: \"docker\", Score: 0.7, RegistryName: \"clawhub\"},\n\t}\n\tcache.Put(\"github integration\", results)\n\n\tgot, hit := cache.Get(\"github integration\")\n\tassert.True(t, hit)\n\tassert.Len(t, got, 2)\n\tassert.Equal(t, \"github\", got[0].Slug)\n}\n\nfunc TestSearchCacheExactHitCaseInsensitive(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\tresults := []SearchResult{{Slug: \"github\", Score: 0.9}}\n\tcache.Put(\"GitHub Integration\", results)\n\n\tgot, hit := cache.Get(\"github integration\")\n\tassert.True(t, hit)\n\tassert.Len(t, got, 1)\n}\n\nfunc TestSearchCacheSimilarHit(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\tresults := []SearchResult{{Slug: \"github\", Score: 0.9}}\n\tcache.Put(\"github integration tool\", results)\n\n\t// \"github integration\" is very similar to \"github integration tool\"\n\tgot, hit := cache.Get(\"github integration\")\n\tassert.True(t, hit)\n\tassert.Len(t, got, 1)\n}\n\nfunc TestSearchCacheDissimilarMiss(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\tresults := []SearchResult{{Slug: \"github\", Score: 0.9}}\n\tcache.Put(\"github integration\", results)\n\n\t// Completely unrelated query\n\t_, hit := cache.Get(\"database management\")\n\tassert.False(t, hit)\n}\n\nfunc TestSearchCacheTTLExpiration(t *testing.T) {\n\tcache := NewSearchCache(10, 50*time.Millisecond)\n\n\tresults := []SearchResult{{Slug: \"github\", Score: 0.9}}\n\tcache.Put(\"github integration\", results)\n\n\t// Immediately should hit\n\t_, hit := cache.Get(\"github integration\")\n\tassert.True(t, hit)\n\n\t// Wait for expiration\n\ttime.Sleep(100 * time.Millisecond)\n\n\t_, hit = cache.Get(\"github integration\")\n\tassert.False(t, hit)\n}\n\nfunc TestSearchCacheLRUEviction(t *testing.T) {\n\tcache := NewSearchCache(3, 5*time.Minute)\n\n\tcache.Put(\"query-1\", []SearchResult{{Slug: \"a\"}})\n\tcache.Put(\"query-2\", []SearchResult{{Slug: \"b\"}})\n\tcache.Put(\"query-3\", []SearchResult{{Slug: \"c\"}})\n\n\tassert.Equal(t, 3, cache.Len())\n\n\t// Adding a 4th should evict query-1 (oldest)\n\tcache.Put(\"query-4\", []SearchResult{{Slug: \"d\"}})\n\tassert.Equal(t, 3, cache.Len())\n\n\t_, hit := cache.Get(\"query-1\")\n\tassert.False(t, hit, \"oldest entry should be evicted\")\n\n\tgot, hit := cache.Get(\"query-4\")\n\tassert.True(t, hit)\n\tassert.Equal(t, \"d\", got[0].Slug)\n}\n\nfunc TestSearchCacheEmptyQuery(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\t_, hit := cache.Get(\"\")\n\tassert.False(t, hit)\n\n\t_, hit = cache.Get(\"   \")\n\tassert.False(t, hit)\n}\n\nfunc TestSearchCacheResultsCopied(t *testing.T) {\n\tcache := NewSearchCache(10, 5*time.Minute)\n\n\toriginal := []SearchResult{{Slug: \"github\", Score: 0.9}}\n\tcache.Put(\"test\", original)\n\n\t// Mutate original after putting\n\toriginal[0].Slug = \"mutated\"\n\n\tgot, hit := cache.Get(\"test\")\n\tassert.True(t, hit)\n\tassert.Equal(t, \"github\", got[0].Slug, \"cache should hold a copy, not a reference\")\n}\n\nfunc TestBuildTrigrams(t *testing.T) {\n\ttrigrams := buildTrigrams(\"hello\")\n\tassert.Contains(t, trigrams, uint32('h')<<16|uint32('e')<<8|uint32('l'))\n\tassert.Contains(t, trigrams, uint32('e')<<16|uint32('l')<<8|uint32('l'))\n\tassert.Contains(t, trigrams, uint32('l')<<16|uint32('l')<<8|uint32('o'))\n\tassert.Len(t, trigrams, 3)\n}\n\nfunc TestJaccardSimilarity(t *testing.T) {\n\ta := buildTrigrams(\"github integration\")\n\tb := buildTrigrams(\"github integration tool\")\n\n\tsim := jaccardSimilarity(a, b)\n\tassert.Greater(t, sim, 0.5, \"similar strings should have high sim\")\n\n\tc := buildTrigrams(\"completely different query about databases\")\n\tsim2 := jaccardSimilarity(a, c)\n\tassert.Less(t, sim2, 0.3, \"dissimilar strings should have low sim\")\n}\n\nfunc TestJaccardSimilarityEdgeCases(t *testing.T) {\n\tempty := buildTrigrams(\"\")\n\tnonempty := buildTrigrams(\"hello\")\n\n\tassert.Equal(t, 1.0, jaccardSimilarity(empty, empty))\n\tassert.Equal(t, 0.0, jaccardSimilarity(empty, nonempty))\n\tassert.Equal(t, 0.0, jaccardSimilarity(nonempty, empty))\n}\n\nfunc TestSearchCacheConcurrency(t *testing.T) {\n\tcache := NewSearchCache(50, 5*time.Minute)\n\tdone := make(chan struct{})\n\n\t// Concurrent writes\n\tgo func() {\n\t\tfor i := range 100 {\n\t\t\tcache.Put(\"query-write-\"+string(rune('a'+i%26)), []SearchResult{{Slug: \"x\"}})\n\t\t}\n\t\tdone <- struct{}{}\n\t}()\n\n\t// Concurrent reads\n\tgo func() {\n\t\tfor range 100 {\n\t\t\tcache.Get(\"query-write-a\")\n\t\t}\n\t\tdone <- struct{}{}\n\t}()\n\n\t<-done\n}\n\nfunc TestSearchCacheLRUUpdateOnGet(t *testing.T) {\n\t// Capacity 3\n\tcache := NewSearchCache(3, time.Hour)\n\n\t// Fill cache: query-A, query-B, query-C\n\t// Use longer strings to ensure trigrams are generated and avoid false positive similarity\n\tcache.Put(\"query-A\", []SearchResult{{Slug: \"A\"}})\n\tcache.Put(\"query-B\", []SearchResult{{Slug: \"B\"}})\n\tcache.Put(\"query-C\", []SearchResult{{Slug: \"C\"}})\n\n\t// Access query-A (should make it most recently used)\n\tif _, found := cache.Get(\"query-A\"); !found {\n\t\tt.Fatal(\"query-A should be in cache\")\n\t}\n\n\t// Add query-D. Should evict query-B (LRU) instead of query-A (which was refreshed)\n\tcache.Put(\"query-D\", []SearchResult{{Slug: \"D\"}})\n\n\t// Check if query-A is still there\n\tif _, found := cache.Get(\"query-A\"); !found {\n\t\tt.Fatalf(\"query-A was evicted! valid LRU should have kept query-A and evicted query-B.\")\n\t}\n\n\t// Check if query-B is evicted\n\tif _, found := cache.Get(\"query-B\"); found {\n\t\tt.Fatal(\"query-B should have been evicted\")\n\t}\n}\n"
  },
  {
    "path": "pkg/state/state.go",
    "content": "package state\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n)\n\n// State represents the persistent state for a workspace.\n// It includes information about the last active channel/chat.\ntype State struct {\n\t// LastChannel is the last channel used for communication\n\tLastChannel string `json:\"last_channel,omitempty\"`\n\n\t// LastChatID is the last chat ID used for communication\n\tLastChatID string `json:\"last_chat_id,omitempty\"`\n\n\t// Timestamp is the last time this state was updated\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\n// Manager manages persistent state with atomic saves.\ntype Manager struct {\n\tworkspace string\n\tstate     *State\n\tmu        sync.RWMutex\n\tstateFile string\n}\n\n// NewManager creates a new state manager for the given workspace.\nfunc NewManager(workspace string) *Manager {\n\tstateDir := filepath.Join(workspace, \"state\")\n\tstateFile := filepath.Join(stateDir, \"state.json\")\n\toldStateFile := filepath.Join(workspace, \"state.json\")\n\n\t// Create state directory if it doesn't exist\n\tif err := os.MkdirAll(stateDir, 0o700); err != nil {\n\t\tlog.Printf(\"[WARN] state: failed to create state directory %s: %v\", stateDir, err)\n\t}\n\n\tsm := &Manager{\n\t\tworkspace: workspace,\n\t\tstateFile: stateFile,\n\t\tstate:     &State{},\n\t}\n\n\t// Try to load from new location first\n\tif _, err := os.Stat(stateFile); os.IsNotExist(err) {\n\t\t// New file doesn't exist, try migrating from old location\n\t\tif data, err := os.ReadFile(oldStateFile); err == nil {\n\t\t\tif err := json.Unmarshal(data, sm.state); err == nil {\n\t\t\t\t// Migrate to new location\n\t\t\t\tif err := sm.saveAtomic(); err != nil {\n\t\t\t\t\tlog.Printf(\"[WARN] state: failed to save state: %v\", err)\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"[INFO] state: migrated state from %s to %s\", oldStateFile, stateFile)\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Load from new location\n\t\tif err := sm.load(); err != nil {\n\t\t\tlog.Printf(\"[WARN] state: failed to load state: %v\", err)\n\t\t}\n\t}\n\n\treturn sm\n}\n\n// SetLastChannel atomically updates the last channel and saves the state.\n// This method uses a temp file + rename pattern for atomic writes,\n// ensuring that the state file is never corrupted even if the process crashes.\nfunc (sm *Manager) SetLastChannel(channel string) error {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\t// Update state\n\tsm.state.LastChannel = channel\n\tsm.state.Timestamp = time.Now()\n\n\t// Atomic save using temp file + rename\n\tif err := sm.saveAtomic(); err != nil {\n\t\treturn fmt.Errorf(\"failed to save state atomically: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// SetLastChatID atomically updates the last chat ID and saves the state.\nfunc (sm *Manager) SetLastChatID(chatID string) error {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\t// Update state\n\tsm.state.LastChatID = chatID\n\tsm.state.Timestamp = time.Now()\n\n\t// Atomic save using temp file + rename\n\tif err := sm.saveAtomic(); err != nil {\n\t\treturn fmt.Errorf(\"failed to save state atomically: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetLastChannel returns the last channel from the state.\nfunc (sm *Manager) GetLastChannel() string {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\treturn sm.state.LastChannel\n}\n\n// GetLastChatID returns the last chat ID from the state.\nfunc (sm *Manager) GetLastChatID() string {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\treturn sm.state.LastChatID\n}\n\n// GetTimestamp returns the timestamp of the last state update.\nfunc (sm *Manager) GetTimestamp() time.Time {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\treturn sm.state.Timestamp\n}\n\n// saveAtomic performs an atomic save using temp file + rename.\n// This ensures that the state file is never corrupted:\n// 1. Write to a temp file\n// 2. Sync to disk (critical for SD cards/flash storage)\n// 3. Rename temp file to target (atomic on POSIX systems)\n// 4. If rename fails, cleanup the temp file\n//\n// Must be called with the lock held.\nfunc (sm *Manager) saveAtomic() error {\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\t// Using 0o600 (owner read/write only) for secure default permissions.\n\tdata, err := json.MarshalIndent(sm.state, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal state: %w\", err)\n\t}\n\n\treturn fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)\n}\n\n// load loads the state from disk.\nfunc (sm *Manager) load() error {\n\tdata, err := os.ReadFile(sm.stateFile)\n\tif err != nil {\n\t\t// File doesn't exist yet, that's OK\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"failed to read state file: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(data, sm.state); err != nil {\n\t\treturn fmt.Errorf(\"failed to unmarshal state: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/state/state_test.go",
    "content": "package state\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestAtomicSave(t *testing.T) {\n\t// Create temp workspace\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tsm := NewManager(tmpDir)\n\n\t// Test SetLastChannel\n\terr = sm.SetLastChannel(\"test-channel\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetLastChannel failed: %v\", err)\n\t}\n\n\t// Verify the channel was saved\n\tlastChannel := sm.GetLastChannel()\n\tif lastChannel != \"test-channel\" {\n\t\tt.Errorf(\"Expected channel 'test-channel', got '%s'\", lastChannel)\n\t}\n\n\t// Verify timestamp was updated\n\tif sm.GetTimestamp().IsZero() {\n\t\tt.Error(\"Expected timestamp to be updated\")\n\t}\n\n\t// Verify state file exists\n\tstateFile := filepath.Join(tmpDir, \"state\", \"state.json\")\n\tif _, err := os.Stat(stateFile); os.IsNotExist(err) {\n\t\tt.Error(\"Expected state file to exist\")\n\t}\n\n\t// Create a new manager to verify persistence\n\tsm2 := NewManager(tmpDir)\n\tif sm2.GetLastChannel() != \"test-channel\" {\n\t\tt.Errorf(\"Expected persistent channel 'test-channel', got '%s'\", sm2.GetLastChannel())\n\t}\n}\n\nfunc TestSetLastChatID(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tsm := NewManager(tmpDir)\n\n\t// Test SetLastChatID\n\terr = sm.SetLastChatID(\"test-chat-id\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetLastChatID failed: %v\", err)\n\t}\n\n\t// Verify the chat ID was saved\n\tlastChatID := sm.GetLastChatID()\n\tif lastChatID != \"test-chat-id\" {\n\t\tt.Errorf(\"Expected chat ID 'test-chat-id', got '%s'\", lastChatID)\n\t}\n\n\t// Verify timestamp was updated\n\tif sm.GetTimestamp().IsZero() {\n\t\tt.Error(\"Expected timestamp to be updated\")\n\t}\n\n\t// Create a new manager to verify persistence\n\tsm2 := NewManager(tmpDir)\n\tif sm2.GetLastChatID() != \"test-chat-id\" {\n\t\tt.Errorf(\"Expected persistent chat ID 'test-chat-id', got '%s'\", sm2.GetLastChatID())\n\t}\n}\n\nfunc TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tsm := NewManager(tmpDir)\n\n\t// Write initial state\n\terr = sm.SetLastChannel(\"initial-channel\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetLastChannel failed: %v\", err)\n\t}\n\n\t// Simulate a crash scenario by manually creating a corrupted temp file\n\ttempFile := filepath.Join(tmpDir, \"state\", \"state.json.tmp\")\n\terr = os.WriteFile(tempFile, []byte(\"corrupted data\"), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp file: %v\", err)\n\t}\n\n\t// Verify that the original state is still intact\n\tlastChannel := sm.GetLastChannel()\n\tif lastChannel != \"initial-channel\" {\n\t\tt.Errorf(\"Expected channel 'initial-channel' after corrupted temp file, got '%s'\", lastChannel)\n\t}\n\n\t// Clean up the temp file manually\n\tos.Remove(tempFile)\n\n\t// Now do a proper save\n\terr = sm.SetLastChannel(\"new-channel\")\n\tif err != nil {\n\t\tt.Fatalf(\"SetLastChannel failed: %v\", err)\n\t}\n\n\t// Verify the new state was saved\n\tif sm.GetLastChannel() != \"new-channel\" {\n\t\tt.Errorf(\"Expected channel 'new-channel', got '%s'\", sm.GetLastChannel())\n\t}\n}\n\nfunc TestConcurrentAccess(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tsm := NewManager(tmpDir)\n\n\t// Test concurrent writes\n\tdone := make(chan bool, 10)\n\tfor i := range 10 {\n\t\tgo func(idx int) {\n\t\t\tchannel := fmt.Sprintf(\"channel-%d\", idx)\n\t\t\tsm.SetLastChannel(channel)\n\t\t\tdone <- true\n\t\t}(i)\n\t}\n\n\t// Wait for all goroutines to complete\n\tfor range 10 {\n\t\t<-done\n\t}\n\n\t// Verify the final state is consistent\n\tlastChannel := sm.GetLastChannel()\n\tif lastChannel == \"\" {\n\t\tt.Error(\"Expected non-empty channel after concurrent writes\")\n\t}\n\n\t// Verify state file is valid JSON\n\tstateFile := filepath.Join(tmpDir, \"state\", \"state.json\")\n\tdata, err := os.ReadFile(stateFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read state file: %v\", err)\n\t}\n\n\tvar state State\n\tif err := json.Unmarshal(data, &state); err != nil {\n\t\tt.Errorf(\"State file contains invalid JSON: %v\", err)\n\t}\n}\n\nfunc TestNewManager_ExistingState(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\t// Create initial state\n\tsm1 := NewManager(tmpDir)\n\tsm1.SetLastChannel(\"existing-channel\")\n\tsm1.SetLastChatID(\"existing-chat-id\")\n\n\t// Create new manager with same workspace\n\tsm2 := NewManager(tmpDir)\n\n\t// Verify state was loaded\n\tif sm2.GetLastChannel() != \"existing-channel\" {\n\t\tt.Errorf(\"Expected channel 'existing-channel', got '%s'\", sm2.GetLastChannel())\n\t}\n\n\tif sm2.GetLastChatID() != \"existing-chat-id\" {\n\t\tt.Errorf(\"Expected chat ID 'existing-chat-id', got '%s'\", sm2.GetLastChatID())\n\t}\n}\n\nfunc TestNewManager_EmptyWorkspace(t *testing.T) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tsm := NewManager(tmpDir)\n\n\t// Verify default state\n\tif sm.GetLastChannel() != \"\" {\n\t\tt.Errorf(\"Expected empty channel, got '%s'\", sm.GetLastChannel())\n\t}\n\n\tif sm.GetLastChatID() != \"\" {\n\t\tt.Errorf(\"Expected empty chat ID, got '%s'\", sm.GetLastChatID())\n\t}\n\n\tif !sm.GetTimestamp().IsZero() {\n\t\tt.Error(\"Expected zero timestamp for new state\")\n\t}\n}\n\nfunc TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {\n\tif os.Getenv(\"BE_CRASHER\") == \"1\" {\n\t\ttmpDir := os.Getenv(\"CRASH_DIR\")\n\n\t\tstatePath := filepath.Join(tmpDir, \"state\")\n\t\tif err := os.WriteFile(statePath, []byte(\"I'm a file, not a folder\"), 0o644); err != nil {\n\t\t\tfmt.Printf(\"setup failed: %v\", err)\n\t\t\tos.Exit(0)\n\t\t}\n\n\t\tNewManager(tmpDir)\n\t\tos.Exit(0)\n\t}\n\n\ttmpDir, err := os.MkdirTemp(\"\", \"state-crash-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tmpDir)\n\n\tcmd := exec.Command(os.Args[0], \"-test.run=TestNewManager_MkdirFailureDoesNotCrash\")\n\tcmd.Env = append(os.Environ(), \"BE_CRASHER=1\", \"CRASH_DIR=\"+tmpDir)\n\n\terr = cmd.Run()\n\tif err != nil {\n\t\tt.Fatalf(\"NewManager should not crash when state dir creation fails, got: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/base.go",
    "content": "package tools\n\nimport \"context\"\n\n// Tool is the interface that all tools must implement.\ntype Tool interface {\n\tName() string\n\tDescription() string\n\tParameters() map[string]any\n\tExecute(ctx context.Context, args map[string]any) *ToolResult\n}\n\n// --- Request-scoped tool context (channel / chatID) ---\n//\n// Carried via context.Value so that concurrent tool calls each receive\n// their own immutable copy — no mutable state on singleton tool instances.\n//\n// Keys are unexported pointer-typed vars — guaranteed collision-free,\n// and only accessible through the helper functions below.\n\ntype toolCtxKey struct{ name string }\n\nvar (\n\tctxKeyChannel = &toolCtxKey{\"channel\"}\n\tctxKeyChatID  = &toolCtxKey{\"chatID\"}\n)\n\n// WithToolContext returns a child context carrying channel and chatID.\nfunc WithToolContext(ctx context.Context, channel, chatID string) context.Context {\n\tctx = context.WithValue(ctx, ctxKeyChannel, channel)\n\tctx = context.WithValue(ctx, ctxKeyChatID, chatID)\n\treturn ctx\n}\n\n// ToolChannel extracts the channel from ctx, or \"\" if unset.\nfunc ToolChannel(ctx context.Context) string {\n\tv, _ := ctx.Value(ctxKeyChannel).(string)\n\treturn v\n}\n\n// ToolChatID extracts the chatID from ctx, or \"\" if unset.\nfunc ToolChatID(ctx context.Context) string {\n\tv, _ := ctx.Value(ctxKeyChatID).(string)\n\treturn v\n}\n\n// AsyncCallback is a function type that async tools use to notify completion.\n// When an async tool finishes its work, it calls this callback with the result.\n//\n// The ctx parameter allows the callback to be canceled if the agent is shutting down.\n// The result parameter contains the tool's execution result.\ntype AsyncCallback func(ctx context.Context, result *ToolResult)\n\n// AsyncExecutor is an optional interface that tools can implement to support\n// asynchronous execution with completion callbacks.\n//\n// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor\n// receives the callback as a parameter of ExecuteAsync. This eliminates the\n// data race where concurrent calls could overwrite each other's callbacks\n// on a shared tool instance.\n//\n// This is useful for:\n//   - Long-running operations that shouldn't block the agent loop\n//   - Subagent spawns that complete independently\n//   - Background tasks that need to report results later\n//\n// Example:\n//\n//\tfunc (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {\n//\t    go func() {\n//\t        result := t.runSubagent(ctx, args)\n//\t        if cb != nil { cb(ctx, result) }\n//\t    }()\n//\t    return AsyncResult(\"Subagent spawned, will report back\")\n//\t}\ntype AsyncExecutor interface {\n\tTool\n\t// ExecuteAsync runs the tool asynchronously. The callback cb will be\n\t// invoked (possibly from another goroutine) when the async operation\n\t// completes. cb is guaranteed to be non-nil by the caller (registry).\n\tExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult\n}\n\nfunc ToolToSchema(tool Tool) map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"function\",\n\t\t\"function\": map[string]any{\n\t\t\t\"name\":        tool.Name(),\n\t\t\t\"description\": tool.Description(),\n\t\t\t\"parameters\":  tool.Parameters(),\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/cron.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n\t\"github.com/sipeed/picoclaw/pkg/cron\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// JobExecutor is the interface for executing cron jobs through the agent\ntype JobExecutor interface {\n\tProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)\n}\n\n// CronTool provides scheduling capabilities for the agent\ntype CronTool struct {\n\tcronService  *cron.CronService\n\texecutor     JobExecutor\n\tmsgBus       *bus.MessageBus\n\texecTool     *ExecTool\n\tallowCommand bool\n\texecEnabled  bool\n}\n\n// NewCronTool creates a new CronTool\n// execTimeout: 0 means no timeout, >0 sets the timeout duration\nfunc NewCronTool(\n\tcronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,\n\texecTimeout time.Duration, config *config.Config,\n) (*CronTool, error) {\n\tallowCommand := true\n\texecEnabled := true\n\tif config != nil {\n\t\tallowCommand = config.Tools.Cron.AllowCommand\n\t\texecEnabled = config.Tools.Exec.Enabled\n\t}\n\n\tvar execTool *ExecTool\n\tif execEnabled {\n\t\tvar err error\n\t\texecTool, err = NewExecToolWithConfig(workspace, restrict, config)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to configure exec tool: %w\", err)\n\t\t}\n\t}\n\n\tif execTool != nil {\n\t\texecTool.SetTimeout(execTimeout)\n\t}\n\treturn &CronTool{\n\t\tcronService:  cronService,\n\t\texecutor:     executor,\n\t\tmsgBus:       msgBus,\n\t\texecTool:     execTool,\n\t\tallowCommand: allowCommand,\n\t\texecEnabled:  execEnabled,\n\t}, nil\n}\n\n// Name returns the tool name\nfunc (t *CronTool) Name() string {\n\treturn \"cron\"\n}\n\n// Description returns the tool description\nfunc (t *CronTool) Description() string {\n\treturn \"Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly.\"\n}\n\n// Parameters returns the tool parameters schema\nfunc (t *CronTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"action\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"enum\":        []string{\"add\", \"list\", \"remove\", \"enable\", \"disable\"},\n\t\t\t\t\"description\": \"Action to perform. Use 'add' when user wants to schedule a reminder or task.\",\n\t\t\t},\n\t\t\t\"message\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.\",\n\t\t\t},\n\t\t\t\"command\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.\",\n\t\t\t},\n\t\t\t\"command_confirm\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.\",\n\t\t\t},\n\t\t\t\"at_seconds\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.\",\n\t\t\t},\n\t\t\t\"every_seconds\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.\",\n\t\t\t},\n\t\t\t\"cron_expr\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.\",\n\t\t\t},\n\t\t\t\"job_id\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Job ID (for remove/enable/disable)\",\n\t\t\t},\n\t\t\t\"deliver\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"action\"},\n\t}\n}\n\n// Execute runs the tool with the given arguments\nfunc (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\taction, ok := args[\"action\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"action is required\")\n\t}\n\n\tswitch action {\n\tcase \"add\":\n\t\treturn t.addJob(ctx, args)\n\tcase \"list\":\n\t\treturn t.listJobs()\n\tcase \"remove\":\n\t\treturn t.removeJob(args)\n\tcase \"enable\":\n\t\treturn t.enableJob(args, true)\n\tcase \"disable\":\n\t\treturn t.enableJob(args, false)\n\tdefault:\n\t\treturn ErrorResult(fmt.Sprintf(\"unknown action: %s\", action))\n\t}\n}\n\nfunc (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult {\n\tchannel := ToolChannel(ctx)\n\tchatID := ToolChatID(ctx)\n\n\tif channel == \"\" || chatID == \"\" {\n\t\treturn ErrorResult(\"no session context (channel/chat_id not set). Use this tool in an active conversation.\")\n\t}\n\n\tmessage, ok := args[\"message\"].(string)\n\tif !ok || message == \"\" {\n\t\treturn ErrorResult(\"message is required for add\")\n\t}\n\n\tvar schedule cron.CronSchedule\n\n\t// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr\n\tatSeconds, hasAt := args[\"at_seconds\"].(float64)\n\teverySeconds, hasEvery := args[\"every_seconds\"].(float64)\n\tcronExpr, hasCron := args[\"cron_expr\"].(string)\n\n\t// Fix: type assertions return true for zero values, need additional validity checks\n\t// This prevents LLMs that fill unused optional parameters with defaults (0) from triggering wrong type\n\thasAt = hasAt && atSeconds > 0\n\thasEvery = hasEvery && everySeconds > 0\n\thasCron = hasCron && cronExpr != \"\"\n\n\t// Priority: at_seconds > every_seconds > cron_expr\n\tif hasAt {\n\t\tatMS := time.Now().UnixMilli() + int64(atSeconds)*1000\n\t\tschedule = cron.CronSchedule{\n\t\t\tKind: \"at\",\n\t\t\tAtMS: &atMS,\n\t\t}\n\t} else if hasEvery {\n\t\teveryMS := int64(everySeconds) * 1000\n\t\tschedule = cron.CronSchedule{\n\t\t\tKind:    \"every\",\n\t\t\tEveryMS: &everyMS,\n\t\t}\n\t} else if hasCron {\n\t\tschedule = cron.CronSchedule{\n\t\t\tKind: \"cron\",\n\t\t\tExpr: cronExpr,\n\t\t}\n\t} else {\n\t\treturn ErrorResult(\"one of at_seconds, every_seconds, or cron_expr is required\")\n\t}\n\n\t// Read deliver parameter, default to false so scheduled tasks execute through the agent\n\tdeliver := false\n\tif d, ok := args[\"deliver\"].(bool); ok {\n\t\tdeliver = d\n\t}\n\n\t// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When\n\t// allow_command is disabled, explicit confirmation is required as an override.\n\t// Non-command reminders remain open to all channels.\n\tcommand, _ := args[\"command\"].(string)\n\tcommandConfirm, _ := args[\"command_confirm\"].(bool)\n\tif command != \"\" {\n\t\tif !t.execEnabled {\n\t\t\treturn ErrorResult(\"command execution is disabled\")\n\t\t}\n\t\tif !constants.IsInternalChannel(channel) {\n\t\t\treturn ErrorResult(\"scheduling command execution is restricted to internal channels\")\n\t\t}\n\t\tif !t.allowCommand && !commandConfirm {\n\t\t\treturn ErrorResult(\"command_confirm=true is required when allow_command is disabled\")\n\t\t}\n\t\tdeliver = false\n\t}\n\n\t// Truncate message for job name (max 30 chars)\n\tmessagePreview := utils.Truncate(message, 30)\n\n\tjob, err := t.cronService.AddJob(\n\t\tmessagePreview,\n\t\tschedule,\n\t\tmessage,\n\t\tdeliver,\n\t\tchannel,\n\t\tchatID,\n\t)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"Error adding job: %v\", err))\n\t}\n\n\tif command != \"\" {\n\t\tjob.Payload.Command = command\n\t\t// Need to save the updated payload\n\t\tt.cronService.UpdateJob(job)\n\t}\n\n\treturn SilentResult(fmt.Sprintf(\"Cron job added: %s (id: %s)\", job.Name, job.ID))\n}\n\nfunc (t *CronTool) listJobs() *ToolResult {\n\tjobs := t.cronService.ListJobs(false)\n\n\tif len(jobs) == 0 {\n\t\treturn SilentResult(\"No scheduled jobs\")\n\t}\n\n\tvar result strings.Builder\n\tresult.WriteString(\"Scheduled jobs:\\n\")\n\tfor _, j := range jobs {\n\t\tvar scheduleInfo string\n\t\tif j.Schedule.Kind == \"every\" && j.Schedule.EveryMS != nil {\n\t\t\tscheduleInfo = fmt.Sprintf(\"every %ds\", *j.Schedule.EveryMS/1000)\n\t\t} else if j.Schedule.Kind == \"cron\" {\n\t\t\tscheduleInfo = j.Schedule.Expr\n\t\t} else if j.Schedule.Kind == \"at\" {\n\t\t\tscheduleInfo = \"one-time\"\n\t\t} else {\n\t\t\tscheduleInfo = \"unknown\"\n\t\t}\n\t\tresult.WriteString(fmt.Sprintf(\"- %s (id: %s, %s)\\n\", j.Name, j.ID, scheduleInfo))\n\t}\n\n\treturn SilentResult(result.String())\n}\n\nfunc (t *CronTool) removeJob(args map[string]any) *ToolResult {\n\tjobID, ok := args[\"job_id\"].(string)\n\tif !ok || jobID == \"\" {\n\t\treturn ErrorResult(\"job_id is required for remove\")\n\t}\n\n\tif t.cronService.RemoveJob(jobID) {\n\t\treturn SilentResult(fmt.Sprintf(\"Cron job removed: %s\", jobID))\n\t}\n\treturn ErrorResult(fmt.Sprintf(\"Job %s not found\", jobID))\n}\n\nfunc (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {\n\tjobID, ok := args[\"job_id\"].(string)\n\tif !ok || jobID == \"\" {\n\t\treturn ErrorResult(\"job_id is required for enable/disable\")\n\t}\n\n\tjob := t.cronService.EnableJob(jobID, enable)\n\tif job == nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"Job %s not found\", jobID))\n\t}\n\n\tstatus := \"enabled\"\n\tif !enable {\n\t\tstatus = \"disabled\"\n\t}\n\treturn SilentResult(fmt.Sprintf(\"Cron job '%s' %s\", job.Name, status))\n}\n\n// ExecuteJob executes a cron job through the agent\nfunc (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {\n\t// Get channel/chatID from job payload\n\tchannel := job.Payload.Channel\n\tchatID := job.Payload.To\n\n\t// Default values if not set\n\tif channel == \"\" {\n\t\tchannel = \"cli\"\n\t}\n\tif chatID == \"\" {\n\t\tchatID = \"direct\"\n\t}\n\n\t// Execute command if present\n\tif job.Payload.Command != \"\" {\n\t\tif !t.execEnabled || t.execTool == nil {\n\t\t\toutput := \"Error executing scheduled command: command execution is disabled\"\n\t\t\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\tdefer pubCancel()\n\t\t\tt.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\t\t\tChannel: channel,\n\t\t\t\tChatID:  chatID,\n\t\t\t\tContent: output,\n\t\t\t})\n\t\t\treturn \"ok\"\n\t\t}\n\n\t\targs := map[string]any{\n\t\t\t\"command\":   job.Payload.Command,\n\t\t\t\"__channel\": channel,\n\t\t\t\"__chat_id\": chatID,\n\t\t}\n\n\t\tresult := t.execTool.Execute(ctx, args)\n\t\tvar output string\n\t\tif result.IsError {\n\t\t\toutput = fmt.Sprintf(\"Error executing scheduled command: %s\", result.ForLLM)\n\t\t} else {\n\t\t\toutput = fmt.Sprintf(\"Scheduled command '%s' executed:\\n%s\", job.Payload.Command, result.ForLLM)\n\t\t}\n\n\t\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer pubCancel()\n\t\tt.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\t\tChannel: channel,\n\t\t\tChatID:  chatID,\n\t\t\tContent: output,\n\t\t})\n\t\treturn \"ok\"\n\t}\n\n\t// If deliver=true, send message directly without agent processing\n\tif job.Payload.Deliver {\n\t\tpubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer pubCancel()\n\t\tt.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{\n\t\t\tChannel: channel,\n\t\t\tChatID:  chatID,\n\t\t\tContent: job.Payload.Message,\n\t\t})\n\t\treturn \"ok\"\n\t}\n\n\t// For deliver=false, process through agent (for complex tasks)\n\tsessionKey := fmt.Sprintf(\"cron-%s\", job.ID)\n\n\t// Call agent with job's message\n\tresponse, err := t.executor.ProcessDirectWithChannel(\n\t\tctx,\n\t\tjob.Payload.Message,\n\t\tsessionKey,\n\t\tchannel,\n\t\tchatID,\n\t)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"Error: %v\", err)\n\t}\n\n\t// Response is automatically sent via MessageBus by AgentLoop\n\t_ = response // Will be sent by AgentLoop\n\treturn \"ok\"\n}\n"
  },
  {
    "path": "pkg/tools/cron_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/bus\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/cron\"\n)\n\nfunc newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool {\n\tt.Helper()\n\tstorePath := filepath.Join(t.TempDir(), \"cron.json\")\n\tcronService := cron.NewCronService(storePath, nil)\n\tmsgBus := bus.NewMessageBus()\n\ttool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewCronTool() error: %v\", err)\n\t}\n\treturn tool\n}\n\nfunc newTestCronTool(t *testing.T) *CronTool {\n\tt.Helper()\n\treturn newTestCronToolWithConfig(t, config.DefaultConfig())\n}\n\n// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels\nfunc TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-1\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":          \"add\",\n\t\t\"message\":         \"check disk\",\n\t\t\"command\":         \"df -h\",\n\t\t\"command_confirm\": true,\n\t\t\"at_seconds\":      float64(60),\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected command scheduling to be blocked from remote channel\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"restricted to internal channels\") {\n\t\tt.Errorf(\"expected 'restricted to internal channels', got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":     \"add\",\n\t\t\"message\":    \"check disk\",\n\t\t\"command\":    \"df -h\",\n\t\t\"at_seconds\": float64(60),\n\t})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected command scheduling without confirm to succeed by default, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Cron job added\") {\n\t\tt.Errorf(\"expected 'Cron job added', got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Tools.Cron.AllowCommand = false\n\n\ttool := newTestCronToolWithConfig(t, cfg)\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":     \"add\",\n\t\t\"message\":    \"check disk\",\n\t\t\"command\":    \"df -h\",\n\t\t\"at_seconds\": float64(60),\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected command scheduling to require confirm when allow_command is disabled\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"command_confirm=true\") {\n\t\tt.Errorf(\"expected command_confirm requirement message, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Tools.Cron.AllowCommand = false\n\n\ttool := newTestCronToolWithConfig(t, cfg)\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":          \"add\",\n\t\t\"message\":         \"check disk\",\n\t\t\"command\":         \"df -h\",\n\t\t\"command_confirm\": true,\n\t\t\"at_seconds\":      float64(60),\n\t})\n\n\tif result.IsError {\n\t\tt.Fatalf(\n\t\t\t\"expected command scheduling with confirm to succeed when allow_command is disabled, got: %s\",\n\t\t\tresult.ForLLM,\n\t\t)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Cron job added\") {\n\t\tt.Errorf(\"expected 'Cron job added', got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Tools.Exec.Enabled = false\n\n\ttool := newTestCronToolWithConfig(t, cfg)\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":          \"add\",\n\t\t\"message\":         \"check disk\",\n\t\t\"command\":         \"df -h\",\n\t\t\"command_confirm\": true,\n\t\t\"at_seconds\":      float64(60),\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected command scheduling to be blocked when exec is disabled\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"command execution is disabled\") {\n\t\tt.Errorf(\"expected exec disabled message, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels\nfunc TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":          \"add\",\n\t\t\"message\":         \"check disk\",\n\t\t\"command\":         \"df -h\",\n\t\t\"command_confirm\": true,\n\t\t\"at_seconds\":      float64(60),\n\t})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected command scheduling to succeed from internal channel, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Cron job added\") {\n\t\tt.Errorf(\"expected 'Cron job added', got: %s\", result.ForLLM)\n\t}\n}\n\n// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing\nfunc TestCronTool_AddJobRequiresSessionContext(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"action\":     \"add\",\n\t\t\"message\":    \"reminder\",\n\t\t\"at_seconds\": float64(60),\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error when session context is missing\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"no session context\") {\n\t\tt.Errorf(\"expected 'no session context' message, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel\nfunc TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-1\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":     \"add\",\n\t\t\"message\":    \"time to stretch\",\n\t\t\"at_seconds\": float64(600),\n\t})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected non-command reminder to succeed from remote channel, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {\n\ttool := newTestCronTool(t)\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-1\")\n\tresult := tool.Execute(ctx, map[string]any{\n\t\t\"action\":     \"add\",\n\t\t\"message\":    \"send me a poem\",\n\t\t\"at_seconds\": float64(600),\n\t})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected non-command reminder to succeed, got: %s\", result.ForLLM)\n\t}\n\n\tjobs := tool.cronService.ListJobs(false)\n\tif len(jobs) != 1 {\n\t\tt.Fatalf(\"expected 1 job, got %d\", len(jobs))\n\t}\n\tif jobs[0].Payload.Deliver {\n\t\tt.Fatal(\"expected deliver=false by default for non-command jobs\")\n\t}\n}\n\nfunc TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tcfg.Tools.Exec.Enabled = false\n\n\ttool := newTestCronToolWithConfig(t, cfg)\n\tjob := &cron.CronJob{}\n\tjob.Payload.Channel = \"cli\"\n\tjob.Payload.To = \"direct\"\n\tjob.Payload.Command = \"df -h\"\n\n\tif got := tool.ExecuteJob(context.Background(), job); got != \"ok\" {\n\t\tt.Fatalf(\"ExecuteJob() = %q, want ok\", got)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), time.Second)\n\tdefer cancel()\n\n\tvar msg bus.OutboundMessage\n\tselect {\n\tcase msg = <-tool.msgBus.OutboundChan():\n\t\t// got message\n\tcase <-ctx.Done():\n\t\tt.Fatal(\"timeout waiting for outbound message\")\n\t}\n\tif !strings.Contains(msg.Content, \"command execution is disabled\") {\n\t\tt.Fatalf(\"expected exec disabled message, got: %s\", msg.Content)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/edit.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// EditFileTool edits a file by replacing old_text with new_text.\n// The old_text must exist exactly in the file.\ntype EditFileTool struct {\n\tfs fileSystem\n}\n\n// NewEditFileTool creates a new EditFileTool with optional directory restriction.\nfunc NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool {\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\treturn &EditFileTool{fs: buildFs(workspace, restrict, patterns)}\n}\n\nfunc (t *EditFileTool) Name() string {\n\treturn \"edit_file\"\n}\n\nfunc (t *EditFileTool) Description() string {\n\treturn \"Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file.\"\n}\n\nfunc (t *EditFileTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The file path to edit\",\n\t\t\t},\n\t\t\t\"old_text\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The exact text to find and replace\",\n\t\t\t},\n\t\t\t\"new_text\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The text to replace with\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\", \"old_text\", \"new_text\"},\n\t}\n}\n\nfunc (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, ok := args[\"path\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"path is required\")\n\t}\n\n\toldText, ok := args[\"old_text\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"old_text is required\")\n\t}\n\n\tnewText, ok := args[\"new_text\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"new_text is required\")\n\t}\n\n\tif err := editFile(t.fs, path, oldText, newText); err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\treturn SilentResult(fmt.Sprintf(\"File edited: %s\", path))\n}\n\ntype AppendFileTool struct {\n\tfs fileSystem\n}\n\nfunc NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool {\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\treturn &AppendFileTool{fs: buildFs(workspace, restrict, patterns)}\n}\n\nfunc (t *AppendFileTool) Name() string {\n\treturn \"append_file\"\n}\n\nfunc (t *AppendFileTool) Description() string {\n\treturn \"Append content to the end of a file\"\n}\n\nfunc (t *AppendFileTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The file path to append to\",\n\t\t\t},\n\t\t\t\"content\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The content to append\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\", \"content\"},\n\t}\n}\n\nfunc (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, ok := args[\"path\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"path is required\")\n\t}\n\n\tcontent, ok := args[\"content\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"content is required\")\n\t}\n\n\tif err := appendFile(t.fs, path, content); err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\treturn SilentResult(fmt.Sprintf(\"Appended to %s\", path))\n}\n\n// editFile reads the file via sysFs, performs the replacement, and writes back.\n// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.\nfunc editFile(sysFs fileSystem, path, oldText, newText string) error {\n\tcontent, err := sysFs.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnewContent, err := replaceEditContent(content, oldText, newText)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn sysFs.WriteFile(path, newContent)\n}\n\n// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.\nfunc appendFile(sysFs fileSystem, path, appendContent string) error {\n\tcontent, err := sysFs.ReadFile(path)\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\treturn err\n\t}\n\n\tnewContent := append(content, []byte(appendContent)...)\n\treturn sysFs.WriteFile(path, newContent)\n}\n\n// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.\nfunc replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {\n\tcontentStr := string(content)\n\n\tif !strings.Contains(contentStr, oldText) {\n\t\treturn nil, fmt.Errorf(\"old_text not found in file. Make sure it matches exactly\")\n\t}\n\n\tcount := strings.Count(contentStr, oldText)\n\tif count > 1 {\n\t\treturn nil, fmt.Errorf(\"old_text appears %d times. Please provide more context to make it unique\", count)\n\t}\n\n\tnewContent := strings.Replace(contentStr, oldText, newText, 1)\n\treturn []byte(newContent), nil\n}\n"
  },
  {
    "path": "pkg/tools/edit_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// TestEditTool_EditFile_Success verifies successful file editing\nfunc TestEditTool_EditFile_Success(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"Hello World\\nThis is a test\"), 0o644)\n\n\ttool := NewEditFileTool(tmpDir, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"World\",\n\t\t\"new_text\": \"Universe\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// Should return SilentResult\n\tif !result.Silent {\n\t\tt.Errorf(\"Expected Silent=true for EditFile, got false\")\n\t}\n\n\t// ForUser should be empty (silent result)\n\tif result.ForUser != \"\" {\n\t\tt.Errorf(\"Expected ForUser to be empty for SilentResult, got: %s\", result.ForUser)\n\t}\n\n\t// Verify file was actually edited\n\tcontent, err := os.ReadFile(testFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read edited file: %v\", err)\n\t}\n\tcontentStr := string(content)\n\tif !strings.Contains(contentStr, \"Hello Universe\") {\n\t\tt.Errorf(\"Expected file to contain 'Hello Universe', got: %s\", contentStr)\n\t}\n\tif strings.Contains(contentStr, \"Hello World\") {\n\t\tt.Errorf(\"Expected 'Hello World' to be replaced, got: %s\", contentStr)\n\t}\n}\n\n// TestEditTool_EditFile_NotFound verifies error handling for non-existent file\nfunc TestEditTool_EditFile_NotFound(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"nonexistent.txt\")\n\n\ttool := NewEditFileTool(tmpDir, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"old\",\n\t\t\"new_text\": \"new\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for non-existent file\")\n\t}\n\n\t// Should mention file not found\n\tif !strings.Contains(result.ForLLM, \"not found\") && !strings.Contains(result.ForUser, \"not found\") {\n\t\tt.Errorf(\"Expected 'file not found' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist\nfunc TestEditTool_EditFile_OldTextNotFound(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"Hello World\"), 0o644)\n\n\ttool := NewEditFileTool(tmpDir, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"Goodbye\",\n\t\t\"new_text\": \"Hello\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when old_text not found\")\n\t}\n\n\t// Should mention old_text not found\n\tif !strings.Contains(result.ForLLM, \"not found\") && !strings.Contains(result.ForUser, \"not found\") {\n\t\tt.Errorf(\"Expected 'not found' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times\nfunc TestEditTool_EditFile_MultipleMatches(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"test test test\"), 0o644)\n\n\ttool := NewEditFileTool(tmpDir, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"test\",\n\t\t\"new_text\": \"done\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when old_text appears multiple times\")\n\t}\n\n\t// Should mention multiple occurrences\n\tif !strings.Contains(result.ForLLM, \"times\") && !strings.Contains(result.ForUser, \"times\") {\n\t\tt.Errorf(\"Expected 'multiple times' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory\nfunc TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\totherDir := t.TempDir()\n\ttestFile := filepath.Join(otherDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"content\"), 0o644)\n\n\ttool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"content\",\n\t\t\"new_text\": \"new\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tassert.True(t, result.IsError, \"Expected error when path is outside allowed directory\")\n\n\t// Should mention outside allowed directory\n\t// Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.\n\t// We check ForLLM as it's the primary error channel.\n\tassert.True(\n\t\tt,\n\t\tstrings.Contains(result.ForLLM, \"outside\") || strings.Contains(result.ForLLM, \"access denied\") ||\n\t\t\tstrings.Contains(result.ForLLM, \"escapes\"),\n\t\t\"Expected 'outside allowed' or 'access denied' message, got ForLLM: %s\",\n\t\tresult.ForLLM,\n\t)\n}\n\n// TestEditTool_EditFile_MissingPath verifies error handling for missing path\nfunc TestEditTool_EditFile_MissingPath(t *testing.T) {\n\ttool := NewEditFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"old_text\": \"old\",\n\t\t\"new_text\": \"new\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when path is missing\")\n\t}\n}\n\n// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text\nfunc TestEditTool_EditFile_MissingOldText(t *testing.T) {\n\ttool := NewEditFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     \"/tmp/test.txt\",\n\t\t\"new_text\": \"new\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when old_text is missing\")\n\t}\n}\n\n// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text\nfunc TestEditTool_EditFile_MissingNewText(t *testing.T) {\n\ttool := NewEditFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     \"/tmp/test.txt\",\n\t\t\"old_text\": \"old\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when new_text is missing\")\n\t}\n}\n\n// TestEditTool_AppendFile_Success verifies successful file appending\nfunc TestEditTool_AppendFile_Success(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"Initial content\"), 0o644)\n\n\ttool := NewAppendFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"\\nAppended content\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// Should return SilentResult\n\tif !result.Silent {\n\t\tt.Errorf(\"Expected Silent=true for AppendFile, got false\")\n\t}\n\n\t// ForUser should be empty (silent result)\n\tif result.ForUser != \"\" {\n\t\tt.Errorf(\"Expected ForUser to be empty for SilentResult, got: %s\", result.ForUser)\n\t}\n\n\t// Verify content was actually appended\n\tcontent, err := os.ReadFile(testFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read file: %v\", err)\n\t}\n\tcontentStr := string(content)\n\tif !strings.Contains(contentStr, \"Initial content\") {\n\t\tt.Errorf(\"Expected original content to remain, got: %s\", contentStr)\n\t}\n\tif !strings.Contains(contentStr, \"Appended content\") {\n\t\tt.Errorf(\"Expected appended content, got: %s\", contentStr)\n\t}\n}\n\n// TestEditTool_AppendFile_MissingPath verifies error handling for missing path\nfunc TestEditTool_AppendFile_MissingPath(t *testing.T) {\n\ttool := NewAppendFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"content\": \"test\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when path is missing\")\n\t}\n}\n\n// TestEditTool_AppendFile_MissingContent verifies error handling for missing content\nfunc TestEditTool_AppendFile_MissingContent(t *testing.T) {\n\ttool := NewAppendFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": \"/tmp/test.txt\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when content is missing\")\n\t}\n}\n\n// TestReplaceEditContent verifies the helper function replaceEditContent\nfunc TestReplaceEditContent(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcontent     []byte\n\t\toldText     string\n\t\tnewText     string\n\t\texpected    []byte\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"successful replacement\",\n\t\t\tcontent:     []byte(\"hello world\"),\n\t\t\toldText:     \"world\",\n\t\t\tnewText:     \"universe\",\n\t\t\texpected:    []byte(\"hello universe\"),\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"old text not found\",\n\t\t\tcontent:     []byte(\"hello world\"),\n\t\t\toldText:     \"golang\",\n\t\t\tnewText:     \"rust\",\n\t\t\texpected:    nil,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"multiple matches found\",\n\t\t\tcontent:     []byte(\"test text test\"),\n\t\t\toldText:     \"test\",\n\t\t\tnewText:     \"done\",\n\t\t\texpected:    nil,\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := replaceEditContent(tt.content, tt.oldText, tt.newText)\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode\n// can append to a file that does not yet exist — it should silently create the file.\n// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.\nfunc TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttool := NewAppendFileTool(workspace, true)\n\tctx := context.Background()\n\n\targs := map[string]any{\n\t\t\"path\":    \"brand_new_file.txt\",\n\t\t\"content\": \"first content\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tassert.False(\n\t\tt,\n\t\tresult.IsError,\n\t\t\"Expected success when appending to non-existent file in restricted mode, got: %s\",\n\t\tresult.ForLLM,\n\t)\n\n\t// Verify the file was created with correct content\n\tdata, err := os.ReadFile(filepath.Join(workspace, \"brand_new_file.txt\"))\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"first content\", string(data))\n}\n\n// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode\n// correctly appends to an existing file within the sandbox.\nfunc TestAppendFileTool_Restricted_Success(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttestFile := \"existing.txt\"\n\terr := os.WriteFile(filepath.Join(workspace, testFile), []byte(\"initial\"), 0o644)\n\tassert.NoError(t, err)\n\n\ttool := NewAppendFileTool(workspace, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \" appended\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tassert.False(t, result.IsError, \"Expected success, got: %s\", result.ForLLM)\n\tassert.True(t, result.Silent)\n\n\tdata, err := os.ReadFile(filepath.Join(workspace, testFile))\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"initial appended\", string(data))\n}\n\n// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode\n// correctly edits a file using the single-open editFileInRoot path.\nfunc TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttestFile := \"edit_target.txt\"\n\terr := os.WriteFile(filepath.Join(workspace, testFile), []byte(\"Hello World\"), 0o644)\n\tassert.NoError(t, err)\n\n\ttool := NewEditFileTool(workspace, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"old_text\": \"World\",\n\t\t\"new_text\": \"Go\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tassert.False(t, result.IsError, \"Expected success, got: %s\", result.ForLLM)\n\tassert.True(t, result.Silent)\n\n\tdata, err := os.ReadFile(filepath.Join(workspace, testFile))\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"Hello Go\", string(data))\n}\n\n// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper\n// error message when the target file does not exist.\nfunc TestEditFileTool_Restricted_FileNotFound(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttool := NewEditFileTool(workspace, true)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":     \"no_such_file.txt\",\n\t\t\"old_text\": \"old\",\n\t\t\"new_text\": \"new\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"not found\")\n}\n"
  },
  {
    "path": "pkg/tools/filesystem.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"math\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nconst MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow\n\nfunc validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {\n\tif workspace == \"\" {\n\t\treturn path, fmt.Errorf(\"workspace is not defined\")\n\t}\n\n\tabsWorkspace, err := filepath.Abs(workspace)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve workspace path: %w\", err)\n\t}\n\n\tvar absPath string\n\tif filepath.IsAbs(path) {\n\t\tabsPath = filepath.Clean(path)\n\t} else {\n\t\tabsPath, err = filepath.Abs(filepath.Join(absWorkspace, path))\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to resolve file path: %w\", err)\n\t\t}\n\t}\n\n\tif restrict {\n\t\tif isAllowedPath(absPath, patterns) {\n\t\t\treturn absPath, nil\n\t\t}\n\n\t\tif !isWithinWorkspace(absPath, absWorkspace) {\n\t\t\treturn \"\", fmt.Errorf(\"access denied: path is outside the workspace\")\n\t\t}\n\n\t\tvar resolved string\n\t\tworkspaceReal := absWorkspace\n\t\tif resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {\n\t\t\tworkspaceReal = resolved\n\t\t}\n\n\t\tif resolved, err = filepath.EvalSymlinks(absPath); err == nil {\n\t\t\tif !isWithinWorkspace(resolved, workspaceReal) {\n\t\t\t\treturn \"\", fmt.Errorf(\"access denied: symlink resolves outside workspace\")\n\t\t\t}\n\t\t} else if os.IsNotExist(err) {\n\t\t\tvar parentResolved string\n\t\t\tif parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {\n\t\t\t\tif !isWithinWorkspace(parentResolved, workspaceReal) {\n\t\t\t\t\treturn \"\", fmt.Errorf(\"access denied: symlink resolves outside workspace\")\n\t\t\t\t}\n\t\t\t} else if !os.IsNotExist(err) {\n\t\t\t\treturn \"\", fmt.Errorf(\"failed to resolve path: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\treturn \"\", fmt.Errorf(\"failed to resolve path: %w\", err)\n\t\t}\n\t}\n\n\treturn absPath, nil\n}\n\nfunc isAllowedPath(path string, patterns []*regexp.Regexp) bool {\n\tif len(patterns) == 0 {\n\t\treturn false\n\t}\n\n\tcleaned := filepath.Clean(path)\n\tif !filepath.IsAbs(cleaned) {\n\t\treturn false\n\t}\n\tif !matchesAllowedPath(cleaned, patterns) {\n\t\treturn false\n\t}\n\n\tresolved, err := resolvePathAgainstExistingAncestor(cleaned)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\treturn matchesAllowedPath(resolved, patterns)\n}\n\nfunc matchesAllowedPath(path string, patterns []*regexp.Regexp) bool {\n\tcleaned := filepath.Clean(path)\n\tfor _, pattern := range patterns {\n\t\tif pattern.MatchString(cleaned) {\n\t\t\treturn true\n\t\t}\n\t\tif root, ok := extractAllowedPathRoot(pattern); ok && isWithinAllowedRoot(cleaned, root) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc extractAllowedPathRoot(pattern *regexp.Regexp) (string, bool) {\n\traw := pattern.String()\n\tif !strings.HasPrefix(raw, \"^\") {\n\t\treturn \"\", false\n\t}\n\n\tliteral := strings.TrimPrefix(raw, \"^\")\n\n\t// Recognize the common \"directory prefix\" form: ^<literal>(?:/|$)\n\tliteral = strings.TrimSuffix(literal, \"(?:/|$)\")\n\tliteral = strings.TrimSuffix(literal, `(?:\\\\|$)`)\n\n\t// Reject patterns that still contain regex operators after removing the\n\t// optional anchored-directory suffix. That keeps arbitrary regex behavior\n\t// unchanged and only enables normalized prefix matching for literal paths.\n\tif containsUnescapedRegexMeta(literal) {\n\t\treturn \"\", false\n\t}\n\n\tunescaped, ok := unescapeRegexLiteral(literal)\n\tif !ok || unescaped == \"\" {\n\t\treturn \"\", false\n\t}\n\n\treturn filepath.Clean(unescaped), filepath.IsAbs(unescaped)\n}\n\nfunc appendUniquePath(paths []string, path string) []string {\n\tfor _, existing := range paths {\n\t\tif existing == path {\n\t\t\treturn paths\n\t\t}\n\t}\n\treturn append(paths, path)\n}\n\nfunc containsUnescapedRegexMeta(s string) bool {\n\tescaped := false\n\tfor _, r := range s {\n\t\tif escaped {\n\t\t\tescaped = false\n\t\t\tcontinue\n\t\t}\n\t\tif r == '\\\\' {\n\t\t\tescaped = true\n\t\t\tcontinue\n\t\t}\n\t\tswitch r {\n\t\tcase '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '|':\n\t\t\treturn true\n\t\t}\n\t}\n\treturn escaped\n}\n\nfunc unescapeRegexLiteral(s string) (string, bool) {\n\tvar b strings.Builder\n\tb.Grow(len(s))\n\n\tescaped := false\n\tfor _, r := range s {\n\t\tif escaped {\n\t\t\tb.WriteRune(r)\n\t\t\tescaped = false\n\t\t\tcontinue\n\t\t}\n\t\tif r == '\\\\' {\n\t\t\tescaped = true\n\t\t\tcontinue\n\t\t}\n\t\tb.WriteRune(r)\n\t}\n\n\tif escaped {\n\t\treturn \"\", false\n\t}\n\n\treturn b.String(), true\n}\n\nfunc isWithinAllowedRoot(path, root string) bool {\n\tcandidate := filepath.Clean(path)\n\tallowedVariants := []string{filepath.Clean(root)}\n\n\tif resolvedRoot, err := resolvePathAgainstExistingAncestor(root); err == nil {\n\t\tallowedVariants = appendUniquePath(allowedVariants, filepath.Clean(resolvedRoot))\n\t}\n\n\tfor _, allowedRoot := range allowedVariants {\n\t\tif isWithinWorkspace(candidate, allowedRoot) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc resolveExistingAncestor(path string) (string, error) {\n\tfor current := filepath.Clean(path); ; current = filepath.Dir(current) {\n\t\tif resolved, err := filepath.EvalSymlinks(current); err == nil {\n\t\t\treturn resolved, nil\n\t\t} else if !os.IsNotExist(err) {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif filepath.Dir(current) == current {\n\t\t\treturn \"\", os.ErrNotExist\n\t\t}\n\t}\n}\n\nfunc resolvePathAgainstExistingAncestor(path string) (string, error) {\n\tcleaned := filepath.Clean(path)\n\tfor current := cleaned; ; current = filepath.Dir(current) {\n\t\tresolved, err := filepath.EvalSymlinks(current)\n\t\tif err == nil {\n\t\t\tsuffix, relErr := filepath.Rel(current, cleaned)\n\t\t\tif relErr != nil {\n\t\t\t\treturn \"\", relErr\n\t\t\t}\n\t\t\tif suffix == \".\" {\n\t\t\t\treturn filepath.Clean(resolved), nil\n\t\t\t}\n\t\t\treturn filepath.Clean(filepath.Join(resolved, suffix)), nil\n\t\t}\n\t\tif !os.IsNotExist(err) {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif filepath.Dir(current) == current {\n\t\t\treturn \"\", os.ErrNotExist\n\t\t}\n\t}\n}\n\nfunc isWithinWorkspace(candidate, workspace string) bool {\n\trel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))\n\treturn err == nil && (rel == \".\" || filepath.IsLocal(rel))\n}\n\ntype ReadFileTool struct {\n\tfs      fileSystem\n\tmaxSize int64\n}\n\nfunc NewReadFileTool(\n\tworkspace string,\n\trestrict bool,\n\tmaxReadFileSize int,\n\tallowPaths ...[]*regexp.Regexp,\n) *ReadFileTool {\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\n\tmaxSize := int64(maxReadFileSize)\n\tif maxSize <= 0 {\n\t\tmaxSize = MaxReadFileSize\n\t}\n\n\treturn &ReadFileTool{\n\t\tfs:      buildFs(workspace, restrict, patterns),\n\t\tmaxSize: maxSize,\n\t}\n}\n\nfunc (t *ReadFileTool) Name() string {\n\treturn \"read_file\"\n}\n\nfunc (t *ReadFileTool) Description() string {\n\treturn \"Read the contents of a file. Supports pagination via `offset` and `length`.\"\n}\n\nfunc (t *ReadFileTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Path to the file to read.\",\n\t\t\t},\n\t\t\t\"offset\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Byte offset to start reading from.\",\n\t\t\t\t\"default\":     0,\n\t\t\t},\n\t\t\t\"length\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Maximum number of bytes to read.\",\n\t\t\t\t\"default\":     t.maxSize,\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\"},\n\t}\n}\n\nfunc (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, ok := args[\"path\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"path is required\")\n\t}\n\n\t// offset (optional, default 0)\n\toffset, err := getInt64Arg(args, \"offset\", 0)\n\tif err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\tif offset < 0 {\n\t\treturn ErrorResult(\"offset must be >= 0\")\n\t}\n\n\t// length (optional, capped at MaxReadFileSize)\n\tlength, err := getInt64Arg(args, \"length\", t.maxSize)\n\tif err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\tif length <= 0 {\n\t\treturn ErrorResult(\"length must be > 0\")\n\t}\n\tif length > t.maxSize {\n\t\tlength = t.maxSize\n\t}\n\n\tfile, err := t.fs.Open(path)\n\tif err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\tdefer file.Close()\n\n\t// measure total size\n\ttotalSize := int64(-1) // -1 means unknown\n\tif info, statErr := file.Stat(); statErr == nil {\n\t\ttotalSize = info.Size()\n\t}\n\n\t// sniff the first 512 bytes to detect binary content before loading\n\t// it into the LLM context. Seeking back to 0 afterwards restores state.\n\tsniff := make([]byte, 512)\n\tsniffN, _ := file.Read(sniff)\n\n\t// Reset read position to beginning before applying the caller's offset.\n\tif seeker, ok := file.(io.Seeker); ok {\n\t\t_, err = seeker.Seek(0, io.SeekStart)\n\t\tif err != nil {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to reset file position after sniff: %v\", err))\n\t\t}\n\t} else {\n\t\t// Non-seekable: we consumed sniffN bytes above; account for them when\n\t\t// discarding to reach the requested offset below.\n\t\t// If offset < sniffN the data we already read covers it, which we\n\t\t// cannot replay on a non-seekable stream — return a clear error.\n\t\tif offset < int64(sniffN) && offset > 0 {\n\t\t\treturn ErrorResult(\n\t\t\t\t\"non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection\",\n\t\t\t)\n\t\t}\n\t}\n\n\t// Seek to the requested offset.\n\tif seeker, ok := file.(io.Seeker); ok {\n\t\t_, err = seeker.Seek(offset, io.SeekStart)\n\t\tif err != nil {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to seek to offset %d: %v\", offset, err))\n\t\t}\n\t} else if offset > 0 {\n\t\t// Fallback for non-seekable streams: discard leading bytes.\n\t\t// sniffN bytes were already consumed above, so subtract them.\n\t\tremaining := offset - int64(sniffN)\n\t\tif remaining > 0 {\n\t\t\t_, err = io.CopyN(io.Discard, file, remaining)\n\t\t\tif err != nil {\n\t\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to advance to offset %d: %v\", offset, err))\n\t\t\t}\n\t\t}\n\t}\n\n\t// read length+1 bytes to reliably detect whether more content exists\n\t// without relying on totalSize (which may be -1 for non-seekable streams).\n\t// This avoids the false-positive TRUNCATED message on the last page.\n\tprobe := make([]byte, length+1)\n\tn, err := io.ReadFull(file, probe)\n\t// FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len),\n\t// and io.EOF only when n == 0. Both are normal terminal conditions — only\n\t// other errors are genuine failures.\n\tif err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to read file content: %v\", err))\n\t}\n\n\t// hasMore is true only when we actually got the extra probe byte.\n\thasMore := int64(n) > length\n\tdata := probe[:min(int64(n), length)]\n\n\tif len(data) == 0 {\n\t\treturn NewToolResult(\"[END OF FILE - no content at this offset]\")\n\t}\n\n\t// Build metadata header.\n\t// use filepath.Base(path) instead of the raw path to avoid leaking\n\t// internal filesystem structure into the LLM context.\n\treadEnd := offset + int64(len(data))\n\t// use ASCII hyphen-minus instead of en-dash (U+2013) to keep the\n\t// header parseable by downstream tools and log processors.\n\treadRange := fmt.Sprintf(\"bytes %d-%d\", offset, readEnd-1)\n\n\tdisplayPath := filepath.Base(path)\n\tvar header string\n\tif totalSize >= 0 {\n\t\theader = fmt.Sprintf(\n\t\t\t\"[file: %s | total: %d bytes | read: %s]\",\n\t\t\tdisplayPath, totalSize, readRange,\n\t\t)\n\t} else {\n\t\theader = fmt.Sprintf(\n\t\t\t\"[file: %s | read: %s | total size unknown]\",\n\t\t\tdisplayPath, readRange,\n\t\t)\n\t}\n\n\tif hasMore {\n\t\theader += fmt.Sprintf(\n\t\t\t\"\\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]\",\n\t\t\treadEnd,\n\t\t)\n\t} else {\n\t\theader += \"\\n[END OF FILE - no further content.]\"\n\t}\n\n\tlogger.DebugCF(\"tool\", \"ReadFileTool execution completed successfully\",\n\t\tmap[string]any{\n\t\t\t\"path\":       path,\n\t\t\t\"bytes_read\": len(data),\n\t\t\t\"has_more\":   hasMore,\n\t\t})\n\n\treturn NewToolResult(header + \"\\n\\n\" + string(data))\n}\n\n// getInt64Arg extracts an integer argument from the args map, returning the\n// provided default if the key is absent.\nfunc getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {\n\traw, exists := args[key]\n\tif !exists {\n\t\treturn defaultVal, nil\n\t}\n\n\tswitch v := raw.(type) {\n\tcase float64:\n\t\tif v != math.Trunc(v) {\n\t\t\treturn 0, fmt.Errorf(\"%s must be an integer, got float %v\", key, v)\n\t\t}\n\t\tif v > math.MaxInt64 || v < math.MinInt64 {\n\t\t\treturn 0, fmt.Errorf(\"%s value %v overflows int64\", key, v)\n\t\t}\n\t\treturn int64(v), nil\n\tcase int:\n\t\treturn int64(v), nil\n\tcase int64:\n\t\treturn v, nil\n\tcase string:\n\t\tparsed, err := strconv.ParseInt(v, 10, 64)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"invalid integer format for %s parameter: %w\", key, err)\n\t\t}\n\t\treturn parsed, nil\n\tdefault:\n\t\treturn 0, fmt.Errorf(\"unsupported type %T for %s parameter\", raw, key)\n\t}\n}\n\ntype WriteFileTool struct {\n\tfs fileSystem\n}\n\nfunc NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\treturn &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}\n}\n\nfunc (t *WriteFileTool) Name() string {\n\treturn \"write_file\"\n}\n\nfunc (t *WriteFileTool) Description() string {\n\treturn \"Write content to a file. If the file already exists, you must set overwrite=true to replace it.\"\n}\n\nfunc (t *WriteFileTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Path to the file to write\",\n\t\t\t},\n\t\t\t\"content\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Content to write to the file\",\n\t\t\t},\n\t\t\t\"overwrite\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"Must be set to true to overwrite an existing file.\",\n\t\t\t\t\"default\":     false,\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\", \"content\"},\n\t}\n}\n\nfunc (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, ok := args[\"path\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"path is required\")\n\t}\n\n\tcontent, ok := args[\"content\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"content is required\")\n\t}\n\n\toverwrite, _ := args[\"overwrite\"].(bool)\n\n\tif !overwrite {\n\t\tif _, err := t.fs.Open(path); err == nil {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"file: %s already exists. Set overwrite=true to replace.\", path))\n\t\t}\n\t}\n\n\tif err := t.fs.WriteFile(path, []byte(content)); err != nil {\n\t\treturn ErrorResult(err.Error())\n\t}\n\n\treturn SilentResult(fmt.Sprintf(\"File written: %s\", path))\n}\n\ntype ListDirTool struct {\n\tfs fileSystem\n}\n\nfunc NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool {\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\treturn &ListDirTool{fs: buildFs(workspace, restrict, patterns)}\n}\n\nfunc (t *ListDirTool) Name() string {\n\treturn \"list_dir\"\n}\n\nfunc (t *ListDirTool) Description() string {\n\treturn \"List files and directories in a path\"\n}\n\nfunc (t *ListDirTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Path to list\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\"},\n\t}\n}\n\nfunc (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, ok := args[\"path\"].(string)\n\tif !ok {\n\t\tpath = \".\"\n\t}\n\n\tentries, err := t.fs.ReadDir(path)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to read directory: %v\", err))\n\t}\n\treturn formatDirEntries(entries)\n}\n\nfunc formatDirEntries(entries []os.DirEntry) *ToolResult {\n\tvar result strings.Builder\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tresult.WriteString(\"DIR:  \" + entry.Name() + \"\\n\")\n\t\t} else {\n\t\t\tresult.WriteString(\"FILE: \" + entry.Name() + \"\\n\")\n\t\t}\n\t}\n\treturn NewToolResult(result.String())\n}\n\n// fileSystem abstracts reading, writing, and listing files, allowing both\n// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.\ntype fileSystem interface {\n\tReadFile(path string) ([]byte, error)\n\tWriteFile(path string, data []byte) error\n\tReadDir(path string) ([]os.DirEntry, error)\n\tOpen(path string) (fs.File, error)\n}\n\n// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.\ntype hostFs struct{}\n\nfunc (h *hostFs) ReadFile(path string) ([]byte, error) {\n\tcontent, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, fmt.Errorf(\"failed to read file: file not found: %w\", err)\n\t\t}\n\t\tif os.IsPermission(err) {\n\t\t\treturn nil, fmt.Errorf(\"failed to read file: access denied: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to read file: %w\", err)\n\t}\n\treturn content, nil\n}\n\nfunc (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {\n\treturn os.ReadDir(path)\n}\n\nfunc (h *hostFs) WriteFile(path string, data []byte) error {\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\t// Using 0o600 (owner read/write only) for secure default permissions.\n\treturn fileutil.WriteFileAtomic(path, data, 0o600)\n}\n\nfunc (h *hostFs) Open(path string) (fs.File, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, fmt.Errorf(\"failed to open file: file not found: %w\", err)\n\t\t}\n\t\tif os.IsPermission(err) {\n\t\t\treturn nil, fmt.Errorf(\"failed to open file: access denied: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to open file: %w\", err)\n\t}\n\treturn f, nil\n}\n\n// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.\ntype sandboxFs struct {\n\tworkspace string\n}\n\nfunc (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {\n\tif r.workspace == \"\" {\n\t\treturn fmt.Errorf(\"workspace is not defined\")\n\t}\n\n\troot, err := os.OpenRoot(r.workspace)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open workspace: %w\", err)\n\t}\n\tdefer root.Close()\n\n\trelPath, err := getSafeRelPath(r.workspace, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn fn(root, relPath)\n}\n\nfunc (r *sandboxFs) ReadFile(path string) ([]byte, error) {\n\tvar content []byte\n\terr := r.execute(path, func(root *os.Root, relPath string) error {\n\t\tfileContent, err := root.ReadFile(relPath)\n\t\tif err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\treturn fmt.Errorf(\"failed to read file: file not found: %w\", err)\n\t\t\t}\n\t\t\t// os.Root returns \"escapes from parent\" for paths outside the root\n\t\t\tif os.IsPermission(err) || strings.Contains(err.Error(), \"escapes from parent\") ||\n\t\t\t\tstrings.Contains(err.Error(), \"permission denied\") {\n\t\t\t\treturn fmt.Errorf(\"failed to read file: access denied: %w\", err)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"failed to read file: %w\", err)\n\t\t}\n\t\tcontent = fileContent\n\t\treturn nil\n\t})\n\treturn content, err\n}\n\nfunc (r *sandboxFs) WriteFile(path string, data []byte) error {\n\treturn r.execute(path, func(root *os.Root, relPath string) error {\n\t\tdir := filepath.Dir(relPath)\n\t\tif dir != \".\" && dir != \"/\" {\n\t\t\tif err := root.MkdirAll(dir, 0o755); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create parent directories: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\t// Use atomic write pattern with explicit sync for flash storage reliability.\n\t\t// Using 0o600 (owner read/write only) for secure default permissions.\n\t\ttmpRelPath := fmt.Sprintf(\".tmp-%d-%d\", os.Getpid(), time.Now().UnixNano())\n\n\t\ttmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)\n\t\tif err != nil {\n\t\t\troot.Remove(tmpRelPath)\n\t\t\treturn fmt.Errorf(\"failed to open temp file: %w\", err)\n\t\t}\n\n\t\tif _, err := tmpFile.Write(data); err != nil {\n\t\t\ttmpFile.Close()\n\t\t\troot.Remove(tmpRelPath)\n\t\t\treturn fmt.Errorf(\"failed to write temp file: %w\", err)\n\t\t}\n\n\t\t// CRITICAL: Force sync to storage medium before rename.\n\t\t// This ensures data is physically written to disk, not just cached.\n\t\tif err := tmpFile.Sync(); err != nil {\n\t\t\ttmpFile.Close()\n\t\t\troot.Remove(tmpRelPath)\n\t\t\treturn fmt.Errorf(\"failed to sync temp file: %w\", err)\n\t\t}\n\n\t\tif err := tmpFile.Close(); err != nil {\n\t\t\troot.Remove(tmpRelPath)\n\t\t\treturn fmt.Errorf(\"failed to close temp file: %w\", err)\n\t\t}\n\n\t\tif err := root.Rename(tmpRelPath, relPath); err != nil {\n\t\t\troot.Remove(tmpRelPath)\n\t\t\treturn fmt.Errorf(\"failed to rename temp file over target: %w\", err)\n\t\t}\n\n\t\t// Sync directory to ensure rename is durable\n\t\tif dirFile, err := root.Open(\".\"); err == nil {\n\t\t\t_ = dirFile.Sync()\n\t\t\tdirFile.Close()\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {\n\tvar entries []os.DirEntry\n\terr := r.execute(path, func(root *os.Root, relPath string) error {\n\t\tdirEntries, err := fs.ReadDir(root.FS(), relPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tentries = dirEntries\n\t\treturn nil\n\t})\n\treturn entries, err\n}\n\nfunc (r *sandboxFs) Open(path string) (fs.File, error) {\n\tvar f fs.File\n\terr := r.execute(path, func(root *os.Root, relPath string) error {\n\t\tfile, err := root.Open(relPath)\n\t\tif err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\treturn fmt.Errorf(\"failed to open file: file not found: %w\", err)\n\t\t\t}\n\t\t\tif os.IsPermission(err) || strings.Contains(err.Error(), \"escapes from parent\") ||\n\t\t\t\tstrings.Contains(err.Error(), \"permission denied\") {\n\t\t\t\treturn fmt.Errorf(\"failed to open file: access denied: %w\", err)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"failed to open file: %w\", err)\n\t\t}\n\t\tf = file\n\t\treturn nil\n\t})\n\treturn f, err\n}\n\n// whitelistFs wraps a sandboxFs and allows access to specific paths outside\n// the workspace when they match any of the provided patterns.\ntype whitelistFs struct {\n\tsandbox  *sandboxFs\n\thost     hostFs\n\tpatterns []*regexp.Regexp\n}\n\nfunc (w *whitelistFs) matches(path string) bool {\n\treturn isAllowedPath(path, w.patterns)\n}\n\nfunc (w *whitelistFs) ReadFile(path string) ([]byte, error) {\n\tif w.matches(path) {\n\t\treturn w.host.ReadFile(path)\n\t}\n\treturn w.sandbox.ReadFile(path)\n}\n\nfunc (w *whitelistFs) WriteFile(path string, data []byte) error {\n\tif w.matches(path) {\n\t\treturn w.host.WriteFile(path, data)\n\t}\n\treturn w.sandbox.WriteFile(path, data)\n}\n\nfunc (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) {\n\tif w.matches(path) {\n\t\treturn w.host.ReadDir(path)\n\t}\n\treturn w.sandbox.ReadDir(path)\n}\n\nfunc (w *whitelistFs) Open(path string) (fs.File, error) {\n\tif w.matches(path) {\n\t\treturn w.host.Open(path)\n\t}\n\treturn w.sandbox.Open(path)\n}\n\n// buildFs returns the appropriate fileSystem implementation based on restriction\n// settings and optional path whitelist patterns.\nfunc buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {\n\tif !restrict {\n\t\treturn &hostFs{}\n\t}\n\tsandbox := &sandboxFs{workspace: workspace}\n\tif len(patterns) > 0 {\n\t\treturn &whitelistFs{sandbox: sandbox, patterns: patterns}\n\t}\n\treturn sandbox\n}\n\n// Helper to get a safe relative path for os.Root usage\nfunc getSafeRelPath(workspace, path string) (string, error) {\n\tif workspace == \"\" {\n\t\treturn \"\", fmt.Errorf(\"workspace is not defined\")\n\t}\n\n\trel := filepath.Clean(path)\n\tif filepath.IsAbs(rel) {\n\t\tvar err error\n\t\trel, err = filepath.Rel(workspace, rel)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to calculate relative path: %w\", err)\n\t\t}\n\t}\n\n\tif !filepath.IsLocal(rel) {\n\t\treturn \"\", fmt.Errorf(\"path escapes workspace: %s\", path)\n\t}\n\n\treturn rel, nil\n}\n"
  },
  {
    "path": "pkg/tools/filesystem_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// TestFilesystemTool_ReadFile_Success verifies successful file reading\nfunc TestFilesystemTool_ReadFile_Success(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"test content\"), 0o644)\n\n\ttool := NewReadFileTool(\"\", false, MaxReadFileSize)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": testFile,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should contain file content\n\tif !strings.Contains(result.ForLLM, \"test content\") {\n\t\tt.Errorf(\"Expected ForLLM to contain 'test content', got: %s\", result.ForLLM)\n\t}\n\n\t// ReadFile returns NewToolResult which only sets ForLLM, not ForUser\n\t// This is the expected behavior - file content goes to LLM, not directly to user\n\tif result.ForUser != \"\" {\n\t\tt.Errorf(\"Expected ForUser to be empty for NewToolResult, got: %s\", result.ForUser)\n\t}\n}\n\n// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file\nfunc TestFilesystemTool_ReadFile_NotFound(t *testing.T) {\n\ttool := NewReadFileTool(\"\", false, MaxReadFileSize)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": \"/nonexistent_file_12345.txt\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Failure should be marked as error\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for missing file, got IsError=false\")\n\t}\n\n\t// Should contain error message\n\tif !strings.Contains(result.ForLLM, \"failed to open file\") && !strings.Contains(result.ForUser, \"failed to read\") {\n\t\tt.Errorf(\"Expected error message, got ForLLM: %s, ForUser: %s\", result.ForLLM, result.ForUser)\n\t}\n}\n\n// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path\nfunc TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {\n\ttool := &ReadFileTool{}\n\tctx := context.Background()\n\targs := map[string]any{}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when path is missing\")\n\t}\n\n\t// Should mention required parameter\n\tif !strings.Contains(result.ForLLM, \"path is required\") && !strings.Contains(result.ForUser, \"path is required\") {\n\t\tt.Errorf(\"Expected 'path is required' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestFilesystemTool_WriteFile_Success verifies successful file writing\nfunc TestFilesystemTool_WriteFile_Success(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"newfile.txt\")\n\n\ttool := NewWriteFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"hello world\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// WriteFile returns SilentResult\n\tif !result.Silent {\n\t\tt.Errorf(\"Expected Silent=true for WriteFile, got false\")\n\t}\n\n\t// ForUser should be empty (silent result)\n\tif result.ForUser != \"\" {\n\t\tt.Errorf(\"Expected ForUser to be empty for SilentResult, got: %s\", result.ForUser)\n\t}\n\n\t// Verify file was actually written\n\tcontent, err := os.ReadFile(testFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read written file: %v\", err)\n\t}\n\tif string(content) != \"hello world\" {\n\t\tt.Errorf(\"Expected file content 'hello world', got: %s\", string(content))\n\t}\n}\n\n// TestFilesystemTool_WriteFile_CreateDir verifies directory creation\nfunc TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"subdir\", \"newfile.txt\")\n\n\ttool := NewWriteFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"test\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success with directory creation, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// Verify directory was created and file written\n\tcontent, err := os.ReadFile(testFile)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read written file: %v\", err)\n\t}\n\tif string(content) != \"test\" {\n\t\tt.Errorf(\"Expected file content 'test', got: %s\", string(content))\n\t}\n}\n\n// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path\nfunc TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {\n\ttool := NewWriteFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"content\": \"test\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when path is missing\")\n\t}\n}\n\n// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content\nfunc TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {\n\ttool := NewWriteFileTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": \"/tmp/test.txt\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when content is missing\")\n\t}\n\n\t// Should mention required parameter\n\tif !strings.Contains(result.ForLLM, \"content is required\") &&\n\t\t!strings.Contains(result.ForUser, \"content is required\") {\n\t\tt.Errorf(\"Expected 'content is required' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestFilesystemTool_WriteFile_OverwriteDefaultBlocked verifies that writing to an\n// existing file without overwrite=true returns an error.\nfunc TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"existing.txt\")\n\tos.WriteFile(testFile, []byte(\"original\"), 0o644)\n\n\ttool := NewWriteFileTool(\"\", false)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"new content\",\n\t})\n\n\tassert.True(t, result.IsError, \"expected error when overwriting without overwrite=true\")\n\tassert.Contains(t, result.ForLLM, \"already exists\")\n\tassert.Contains(t, result.ForLLM, \"overwrite=true\")\n\n\t// Original content must be untouched\n\tdata, err := os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"original\", string(data))\n}\n\n// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting\n// overwrite=true replaces the existing file.\nfunc TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"existing.txt\")\n\tos.WriteFile(testFile, []byte(\"original\"), 0o644)\n\n\ttool := NewWriteFileTool(\"\", false)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":      testFile,\n\t\t\"content\":   \"replaced\",\n\t\t\"overwrite\": true,\n\t})\n\n\tassert.False(t, result.IsError, \"expected success with overwrite=true, got: %s\", result.ForLLM)\n\n\tdata, err := os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"replaced\", string(data))\n}\n\n// TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag verifies that a new (non-existing)\n// file can be written without setting overwrite=true.\nfunc TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"newfile.txt\")\n\n\ttool := NewWriteFileTool(\"\", false)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"brand new\",\n\t})\n\n\tassert.False(t, result.IsError, \"expected success for new file, got: %s\", result.ForLLM)\n\n\tdata, err := os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"brand new\", string(data))\n}\n\n// TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked verifies that\n// explicitly passing overwrite=false also blocks overwriting.\nfunc TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"existing.txt\")\n\tos.WriteFile(testFile, []byte(\"original\"), 0o644)\n\n\ttool := NewWriteFileTool(\"\", false)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":      testFile,\n\t\t\"content\":   \"new content\",\n\t\t\"overwrite\": false,\n\t})\n\n\tassert.True(t, result.IsError, \"expected error when overwrite=false\")\n\tassert.Contains(t, result.ForLLM, \"already exists\")\n\n\tdata, err := os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"original\", string(data))\n}\n\n// TestFilesystemTool_WriteFile_OverwriteSandboxed verifies the overwrite guard\n// works correctly in restricted (sandbox) mode.\nfunc TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttestFile := \"file.txt\"\n\tos.WriteFile(filepath.Join(workspace, testFile), []byte(\"original\"), 0o644)\n\n\ttool := NewWriteFileTool(workspace, true)\n\n\t// Without overwrite=true → blocked\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": \"new content\",\n\t})\n\tassert.True(t, result.IsError, \"expected error in sandbox mode without overwrite=true\")\n\tassert.Contains(t, result.ForLLM, \"already exists\")\n\n\t// With overwrite=true → allowed\n\tresult = tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":      testFile,\n\t\t\"content\":   \"replaced in sandbox\",\n\t\t\"overwrite\": true,\n\t})\n\tassert.False(t, result.IsError, \"expected success in sandbox mode with overwrite=true, got: %s\", result.ForLLM)\n\n\tdata, err := os.ReadFile(filepath.Join(workspace, testFile))\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"replaced in sandbox\", string(data))\n}\n\n// TestFilesystemTool_ListDir_Success verifies successful directory listing\nfunc TestFilesystemTool_ListDir_Success(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tos.WriteFile(filepath.Join(tmpDir, \"file1.txt\"), []byte(\"content\"), 0o644)\n\tos.WriteFile(filepath.Join(tmpDir, \"file2.txt\"), []byte(\"content\"), 0o644)\n\tos.Mkdir(filepath.Join(tmpDir, \"subdir\"), 0o755)\n\n\ttool := NewListDirTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": tmpDir,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// Should list files and directories\n\tif !strings.Contains(result.ForLLM, \"file1.txt\") || !strings.Contains(result.ForLLM, \"file2.txt\") {\n\t\tt.Errorf(\"Expected files in listing, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"subdir\") {\n\t\tt.Errorf(\"Expected subdir in listing, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory\nfunc TestFilesystemTool_ListDir_NotFound(t *testing.T) {\n\ttool := NewListDirTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"path\": \"/nonexistent_directory_12345\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Failure should be marked as error\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for non-existent directory, got IsError=false\")\n\t}\n\n\t// Should contain error message\n\tif !strings.Contains(result.ForLLM, \"failed to read\") && !strings.Contains(result.ForUser, \"failed to read\") {\n\t\tt.Errorf(\"Expected error message, got ForLLM: %s, ForUser: %s\", result.ForLLM, result.ForUser)\n\t}\n}\n\n// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory\nfunc TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {\n\ttool := NewListDirTool(\"\", false)\n\tctx := context.Background()\n\targs := map[string]any{}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should use \".\" as default path\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success with default path '.', got IsError=true: %s\", result.ForLLM)\n\t}\n}\n\n// Block paths that look inside workspace but point outside via symlink.\nfunc TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {\n\troot := t.TempDir()\n\tworkspace := filepath.Join(root, \"workspace\")\n\tif err := os.MkdirAll(workspace, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create workspace: %v\", err)\n\t}\n\n\tsecret := filepath.Join(root, \"secret.txt\")\n\tif err := os.WriteFile(secret, []byte(\"top secret\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write secret file: %v\", err)\n\t}\n\n\tlink := filepath.Join(workspace, \"leak.txt\")\n\tif err := os.Symlink(secret, link); err != nil {\n\t\tt.Skipf(\"symlink not supported in this environment: %v\", err)\n\t}\n\n\ttool := NewReadFileTool(workspace, true, MaxReadFileSize)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\": link,\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatalf(\"expected symlink escape to be blocked\")\n\t}\n\t// os.Root might return different errors depending on platform/implementation\n\t// but it definitely should error.\n\t// Our wrapper returns \"access denied or file not found\"\n\tif !strings.Contains(result.ForLLM, \"access denied\") && !strings.Contains(result.ForLLM, \"file not found\") &&\n\t\t!strings.Contains(result.ForLLM, \"no such file\") {\n\t\tt.Fatalf(\"expected symlink escape error, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {\n\ttool := NewReadFileTool(\"\", true, MaxReadFileSize) // restrict=true but workspace=\"\"\n\n\t// Try to read a sensitive file (simulated by a temp file outside workspace)\n\ttmpDir := t.TempDir()\n\tsecretFile := filepath.Join(tmpDir, \"shadow\")\n\tos.WriteFile(secretFile, []byte(\"secret data\"), 0o600)\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\": secretFile,\n\t})\n\n\t// We EXPECT IsError=true (access blocked due to empty workspace)\n\tassert.True(t, result.IsError, \"Security Regression: Empty workspace allowed access! content: %s\", result.ForLLM)\n\n\t// Verify it failed for the right reason\n\tassert.Contains(t, result.ForLLM, \"workspace is not defined\", \"Expected 'workspace is not defined' error\")\n}\n\n// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:\n// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.\nfunc TestRootMkdirAll(t *testing.T) {\n\tworkspace := t.TempDir()\n\troot, err := os.OpenRoot(workspace)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open root: %v\", err)\n\t}\n\tdefer root.Close()\n\n\t// Case 1: Single directory\n\terr = root.MkdirAll(\"dir1\", 0o755)\n\tassert.NoError(t, err)\n\t_, err = os.Stat(filepath.Join(workspace, \"dir1\"))\n\tassert.NoError(t, err)\n\n\t// Case 2: Deeply nested directory\n\terr = root.MkdirAll(\"a/b/c/d\", 0o755)\n\tassert.NoError(t, err)\n\t_, err = os.Stat(filepath.Join(workspace, \"a/b/c/d\"))\n\tassert.NoError(t, err)\n\n\t// Case 3: Already exists — must be idempotent\n\terr = root.MkdirAll(\"a/b/c/d\", 0o755)\n\tassert.NoError(t, err)\n\n\t// Case 4: A regular file blocks directory creation — must error\n\terr = os.WriteFile(filepath.Join(workspace, \"file_exists\"), []byte(\"data\"), 0o644)\n\tassert.NoError(t, err)\n\terr = root.MkdirAll(\"file_exists\", 0o755)\n\tassert.Error(t, err, \"expected error when a file exists at the directory path\")\n}\n\nfunc TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttool := NewWriteFileTool(workspace, true)\n\tctx := context.Background()\n\n\ttestFile := \"deep/nested/path/to/file.txt\"\n\tcontent := \"deep content\"\n\targs := map[string]any{\n\t\t\"path\":    testFile,\n\t\t\"content\": content,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tassert.False(t, result.IsError, \"Expected success, got: %s\", result.ForLLM)\n\n\t// Verify file content\n\tactualPath := filepath.Join(workspace, testFile)\n\tdata, err := os.ReadFile(actualPath)\n\tassert.NoError(t, err)\n\tassert.Equal(t, content, string(data))\n}\n\n// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.\nfunc TestHostRW_Read_PermissionDenied(t *testing.T) {\n\tif os.Getuid() == 0 {\n\t\tt.Skip(\"skipping permission test: running as root\")\n\t}\n\ttmpDir := t.TempDir()\n\tprotected := filepath.Join(tmpDir, \"protected.txt\")\n\terr := os.WriteFile(protected, []byte(\"secret\"), 0o000)\n\tassert.NoError(t, err)\n\tdefer os.Chmod(protected, 0o644) // ensure cleanup\n\n\t_, err = (&hostFs{}).ReadFile(protected)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"access denied\")\n}\n\n// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.\nfunc TestHostRW_Read_Directory(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t_, err := (&hostFs{}).ReadFile(tmpDir)\n\tassert.Error(t, err, \"expected error when reading a directory as a file\")\n}\n\n// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.\nfunc TestRootRW_Read_Directory(t *testing.T) {\n\tworkspace := t.TempDir()\n\troot, err := os.OpenRoot(workspace)\n\tassert.NoError(t, err)\n\tdefer root.Close()\n\n\t// Create a subdirectory\n\terr = root.Mkdir(\"subdir\", 0o755)\n\tassert.NoError(t, err)\n\n\t_, err = (&sandboxFs{workspace: workspace}).ReadFile(\"subdir\")\n\tassert.Error(t, err, \"expected error when reading a directory as a file\")\n}\n\n// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.\nfunc TestHostRW_Write_ParentDirMissing(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttarget := filepath.Join(tmpDir, \"a\", \"b\", \"c\", \"file.txt\")\n\n\terr := (&hostFs{}).WriteFile(target, []byte(\"hello\"))\n\tassert.NoError(t, err)\n\n\tdata, err := os.ReadFile(target)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"hello\", string(data))\n}\n\n// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates\n// nested parent directories automatically within the sandbox.\nfunc TestRootRW_Write_ParentDirMissing(t *testing.T) {\n\tworkspace := t.TempDir()\n\n\trelPath := \"x/y/z/file.txt\"\n\terr := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte(\"nested\"))\n\tassert.NoError(t, err)\n\n\tdata, err := os.ReadFile(filepath.Join(workspace, relPath))\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"nested\", string(data))\n}\n\n// TestHostRW_Write verifies the hostRW.Write helper function\nfunc TestHostRW_Write(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"atomic_test.txt\")\n\ttestData := []byte(\"atomic test content\")\n\n\terr := (&hostFs{}).WriteFile(testFile, testData)\n\tassert.NoError(t, err)\n\n\tcontent, err := os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, testData, content)\n\n\t// Verify it overwrites correctly\n\tnewData := []byte(\"new atomic content\")\n\terr = (&hostFs{}).WriteFile(testFile, newData)\n\tassert.NoError(t, err)\n\n\tcontent, err = os.ReadFile(testFile)\n\tassert.NoError(t, err)\n\tassert.Equal(t, newData, content)\n}\n\n// TestRootRW_Write verifies the rootRW.Write helper function\nfunc TestRootRW_Write(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\trelPath := \"atomic_root_test.txt\"\n\ttestData := []byte(\"atomic root test content\")\n\n\terw := &sandboxFs{workspace: tmpDir}\n\terr := erw.WriteFile(relPath, testData)\n\tassert.NoError(t, err)\n\n\troot, err := os.OpenRoot(tmpDir)\n\tassert.NoError(t, err)\n\tdefer root.Close()\n\n\tf, err := root.Open(relPath)\n\tassert.NoError(t, err)\n\tdefer f.Close()\n\n\tcontent, err := io.ReadAll(f)\n\tassert.NoError(t, err)\n\tassert.Equal(t, testData, content)\n\n\t// Verify it overwrites correctly\n\tnewData := []byte(\"new root atomic content\")\n\terr = erw.WriteFile(relPath, newData)\n\tassert.NoError(t, err)\n\n\tf2, err := root.Open(relPath)\n\tassert.NoError(t, err)\n\tdefer f2.Close()\n\n\tcontent, err = io.ReadAll(f2)\n\tassert.NoError(t, err)\n\tassert.Equal(t, newData, content)\n}\n\n// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to\n// paths matching the whitelist patterns while blocking non-matching paths.\nfunc TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {\n\tworkspace := t.TempDir()\n\toutsideDir := t.TempDir()\n\toutsideFile := filepath.Join(outsideDir, \"allowed.txt\")\n\tos.WriteFile(outsideFile, []byte(\"outside content\"), 0o644)\n\n\t// Pattern allows access to the outsideDir.\n\tpatterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))}\n\n\ttool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)\n\n\t// Read from whitelisted path should succeed.\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": outsideFile})\n\tif result.IsError {\n\t\tt.Errorf(\"expected whitelisted path to be readable, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"outside content\") {\n\t\tt.Errorf(\"expected file content, got: %s\", result.ForLLM)\n\t}\n\n\t// Read from non-whitelisted path outside workspace should fail.\n\totherDir := t.TempDir()\n\totherFile := filepath.Join(otherDir, \"blocked.txt\")\n\tos.WriteFile(otherFile, []byte(\"blocked\"), 0o644)\n\n\tresult = tool.Execute(context.Background(), map[string]any{\"path\": otherFile})\n\tif !result.IsError {\n\t\tt.Errorf(\"expected non-whitelisted path to be blocked, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {\n\tworkspace := t.TempDir()\n\tallowedDir := t.TempDir()\n\tsecretDir := t.TempDir()\n\tsecretFile := filepath.Join(secretDir, \"secret.txt\")\n\tif err := os.WriteFile(secretFile, []byte(\"top secret\"), 0o644); err != nil {\n\t\tt.Fatalf(\"WriteFile(secretFile) error = %v\", err)\n\t}\n\n\tlinkPath := filepath.Join(allowedDir, \"link_out\")\n\tif err := os.Symlink(secretDir, linkPath); err != nil {\n\t\tt.Skipf(\"symlink not supported in this environment: %v\", err)\n\t}\n\n\tpatterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}\n\ttool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": filepath.Join(linkPath, \"secret.txt\")})\n\tif !result.IsError {\n\t\tt.Fatalf(\"expected symlink escape from allowed dir to be blocked, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestWhitelistFs_WriteAllowsNewFileUnderAllowedDir(t *testing.T) {\n\tworkspace := t.TempDir()\n\trootDir := t.TempDir()\n\tallowedDir := filepath.Join(rootDir, \"allowed\")\n\ttargetFile := filepath.Join(allowedDir, \"nested\", \"file.txt\")\n\n\tpatterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}\n\ttool := NewWriteFileTool(workspace, true, patterns)\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":    targetFile,\n\t\t\"content\": \"outside write\",\n\t})\n\tif result.IsError {\n\t\tt.Fatalf(\"expected whitelisted write to succeed, got: %s\", result.ForLLM)\n\t}\n\n\tdata, err := os.ReadFile(targetFile)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadFile(targetFile) error = %v\", err)\n\t}\n\tif string(data) != \"outside write\" {\n\t\tt.Fatalf(\"target file content = %q, want %q\", string(data), \"outside write\")\n\t}\n}\n\nfunc TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) {\n\tworkspace := t.TempDir()\n\trealDir := t.TempDir()\n\tlinkParent := t.TempDir()\n\tallowedAlias := filepath.Join(linkParent, \"allowed-link\")\n\n\tif err := os.Symlink(realDir, allowedAlias); err != nil {\n\t\tt.Skipf(\"symlink not supported in this environment: %v\", err)\n\t}\n\n\ttargetFile := filepath.Join(allowedAlias, \"nested\", \"alias.txt\")\n\tif err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll(targetFile dir) error = %v\", err)\n\t}\n\tif err := os.WriteFile(targetFile, []byte(\"through alias\"), 0o644); err != nil {\n\t\tt.Fatalf(\"WriteFile(targetFile) error = %v\", err)\n\t}\n\n\tpatterns := []*regexp.Regexp{\n\t\tregexp.MustCompile(\n\t\t\t\"^\" + regexp.QuoteMeta(filepath.Clean(allowedAlias)) +\n\t\t\t\t\"(?:\" + regexp.QuoteMeta(string(os.PathSeparator)) + \"|$)\",\n\t\t),\n\t}\n\ttool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": targetFile})\n\tif result.IsError {\n\t\tt.Fatalf(\"expected symlink-backed allowed root to be readable, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"through alias\") {\n\t\tt.Fatalf(\"expected file content, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool\n// by reading a file in multiple chunks using 'offset' and 'length'.\nfunc TestReadFileTool_ChunkedReading(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"pagination_test.txt\")\n\n\t// Create a test file with exactly 26 bytes of content\n\tfullContent := \"abcdefghijklmnopqrstuvwxyz\"\n\terr := os.WriteFile(testFile, []byte(fullContent), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to write test file: %v\", err)\n\t}\n\n\ttool := NewReadFileTool(tmpDir, false, MaxReadFileSize)\n\tctx := context.Background()\n\n\t// --- Step 1: Read the first chunk (10 bytes) ---\n\targs1 := map[string]any{\n\t\t\"path\":   testFile,\n\t\t\"offset\": 0,\n\t\t\"length\": 10,\n\t}\n\tresult1 := tool.Execute(ctx, args1)\n\n\tif result1.IsError {\n\t\tt.Fatalf(\"Chunk 1 failed: %s\", result1.ForLLM)\n\t}\n\n\t// Expect the first 10 characters\n\tif !strings.Contains(result1.ForLLM, \"abcdefghij\") {\n\t\tt.Errorf(\"Chunk 1 should contain 'abcdefghij', got: %s\", result1.ForLLM)\n\t}\n\t// Expect the header to indicate the file is truncated\n\tif !strings.Contains(result1.ForLLM, \"[TRUNCATED\") {\n\t\tt.Errorf(\"Chunk 1 header should indicate truncation, got: %s\", result1.ForLLM)\n\t}\n\t// Expect the header to suggest the next offset (10)\n\tif !strings.Contains(result1.ForLLM, \"offset=10\") {\n\t\tt.Errorf(\"Chunk 1 header should suggest next offset=10, got: %s\", result1.ForLLM)\n\t}\n\n\t// Step 2: Read the second chunk (10 bytes) ---\n\targs2 := map[string]any{\n\t\t\"path\":   testFile,\n\t\t\"offset\": 10,\n\t\t\"length\": 10,\n\t}\n\tresult2 := tool.Execute(ctx, args2)\n\n\tif result2.IsError {\n\t\tt.Fatalf(\"Chunk 2 failed: %s\", result2.ForLLM)\n\t}\n\n\t// Expect the next 10 characters\n\tif !strings.Contains(result2.ForLLM, \"klmnopqrst\") {\n\t\tt.Errorf(\"Chunk 2 should contain 'klmnopqrst', got: %s\", result2.ForLLM)\n\t}\n\t// Expect the header to suggest the next offset (20)\n\tif !strings.Contains(result2.ForLLM, \"offset=20\") {\n\t\tt.Errorf(\"Chunk 2 header should suggest next offset=20, got: %s\", result2.ForLLM)\n\t}\n\n\t// Step 3: Read the final chunk (remaining 6 bytes) ---\n\t// We ask for 10 bytes, but only 6 are left in the file\n\targs3 := map[string]any{\n\t\t\"path\":   testFile,\n\t\t\"offset\": 20,\n\t\t\"length\": 10,\n\t}\n\tresult3 := tool.Execute(ctx, args3)\n\n\tif result3.IsError {\n\t\tt.Fatalf(\"Chunk 3 failed: %s\", result3.ForLLM)\n\t}\n\n\t// Expect the last 6 characters\n\tif !strings.Contains(result3.ForLLM, \"uvwxyz\") {\n\t\tt.Errorf(\"Chunk 3 should contain 'uvwxyz', got: %s\", result3.ForLLM)\n\t}\n\t// Expect the header to indicate the end of the file\n\tif !strings.Contains(result3.ForLLM, \"[END OF FILE\") {\n\t\tt.Errorf(\"Chunk 3 header should indicate end of file, got: %s\", result3.ForLLM)\n\t}\n\n\t// Ensure no TRUNCATED message is present in the final chunk\n\tif strings.Contains(result3.ForLLM, \"[TRUNCATED\") {\n\t\tt.Errorf(\"Chunk 3 header should NOT indicate truncation, got: %s\", result3.ForLLM)\n\t}\n}\n\n// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting\n// An offset that exceeds the total file size.\nfunc TestReadFileTool_OffsetBeyondEOF(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"short.txt\")\n\n\t// create a file of only 5 bytes\n\terr := os.WriteFile(testFile, []byte(\"12345\"), 0o644)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to write test file: %v\", err)\n\t}\n\n\ttool := NewReadFileTool(tmpDir, false, MaxReadFileSize)\n\tctx := context.Background()\n\n\targs := map[string]any{\n\t\t\"path\":   testFile,\n\t\t\"offset\": int64(100), // Offset beyond the end of the file\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// It should not be classified as a tool execution error\n\tif result.IsError {\n\t\tt.Errorf(\"A mistake was not expected, obtained IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// Must return EXACTLY the string provided in the code\n\texpectedMsg := \"[END OF FILE - no content at this offset]\"\n\tif result.ForLLM != expectedMsg {\n\t\tt.Errorf(\"The message %q was expected, obtained: %q\", expectedMsg, result.ForLLM)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/i2c.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n)\n\n// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.\ntype I2CTool struct{}\n\nfunc NewI2CTool() *I2CTool {\n\treturn &I2CTool{}\n}\n\nfunc (t *I2CTool) Name() string {\n\treturn \"i2c\"\n}\n\nfunc (t *I2CTool) Description() string {\n\treturn \"Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only.\"\n}\n\nfunc (t *I2CTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"action\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"enum\":        []string{\"detect\", \"scan\", \"read\", \"write\"},\n\t\t\t\t\"description\": \"Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)\",\n\t\t\t},\n\t\t\t\"bus\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"I2C bus number (e.g. \\\"1\\\" for /dev/i2c-1). Required for scan/read/write.\",\n\t\t\t},\n\t\t\t\"address\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"7-bit I2C device address (0x03-0x77). Required for read/write.\",\n\t\t\t},\n\t\t\t\"register\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Register address to read from or write to. If set, sends register byte before read/write.\",\n\t\t\t},\n\t\t\t\"data\": map[string]any{\n\t\t\t\t\"type\":        \"array\",\n\t\t\t\t\"items\":       map[string]any{\"type\": \"integer\"},\n\t\t\t\t\"description\": \"Bytes to write (0-255 each). Required for write action.\",\n\t\t\t},\n\t\t\t\"length\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Number of bytes to read (1-256). Default: 1. Used with read action.\",\n\t\t\t},\n\t\t\t\"confirm\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"Must be true for write operations. Safety guard to prevent accidental writes.\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"action\"},\n\t}\n}\n\nfunc (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tif runtime.GOOS != \"linux\" {\n\t\treturn ErrorResult(\"I2C is only supported on Linux. This tool requires /dev/i2c-* device files.\")\n\t}\n\n\taction, ok := args[\"action\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"action is required\")\n\t}\n\n\tswitch action {\n\tcase \"detect\":\n\t\treturn t.detect()\n\tcase \"scan\":\n\t\treturn t.scan(args)\n\tcase \"read\":\n\t\treturn t.readDevice(args)\n\tcase \"write\":\n\t\treturn t.writeDevice(args)\n\tdefault:\n\t\treturn ErrorResult(fmt.Sprintf(\"unknown action: %s (valid: detect, scan, read, write)\", action))\n\t}\n}\n\n// detect lists available I2C buses by globbing /dev/i2c-*\nfunc (t *I2CTool) detect() *ToolResult {\n\tmatches, err := filepath.Glob(\"/dev/i2c-*\")\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to scan for I2C buses: %v\", err))\n\t}\n\n\tif len(matches) == 0 {\n\t\treturn SilentResult(\n\t\t\t\"No I2C buses found. You may need to:\\n1. Load the i2c-dev module: modprobe i2c-dev\\n2. Check that I2C is enabled in device tree\\n3. Configure pinmux for your board (see hardware skill)\",\n\t\t)\n\t}\n\n\ttype busInfo struct {\n\t\tPath string `json:\"path\"`\n\t\tBus  string `json:\"bus\"`\n\t}\n\n\tbuses := make([]busInfo, 0, len(matches))\n\tre := regexp.MustCompile(`/dev/i2c-(\\d+)`)\n\tfor _, m := range matches {\n\t\tif sub := re.FindStringSubmatch(m); sub != nil {\n\t\t\tbuses = append(buses, busInfo{Path: m, Bus: sub[1]})\n\t\t}\n\t}\n\n\tresult, _ := json.MarshalIndent(buses, \"\", \"  \")\n\treturn SilentResult(fmt.Sprintf(\"Found %d I2C bus(es):\\n%s\", len(buses), string(result)))\n}\n\n// Helper functions for I2C operations (used by platform-specific implementations)\n\n// isValidBusID checks that a bus identifier is a simple number (prevents path injection)\n//\n//nolint:unused // Used by i2c_linux.go\nfunc isValidBusID(id string) bool {\n\tmatched, _ := regexp.MatchString(`^\\d+$`, id)\n\treturn matched\n}\n\n// parseI2CAddress extracts and validates an I2C address from args\n//\n//nolint:unused // Used by i2c_linux.go\nfunc parseI2CAddress(args map[string]any) (int, *ToolResult) {\n\taddrFloat, ok := args[\"address\"].(float64)\n\tif !ok {\n\t\treturn 0, ErrorResult(\"address is required (e.g. 0x38 for AHT20)\")\n\t}\n\taddr := int(addrFloat)\n\tif addr < 0x03 || addr > 0x77 {\n\t\treturn 0, ErrorResult(\"address must be in valid 7-bit range (0x03-0x77)\")\n\t}\n\treturn addr, nil\n}\n\n// parseI2CBus extracts and validates an I2C bus from args\n//\n//nolint:unused // Used by i2c_linux.go\nfunc parseI2CBus(args map[string]any) (string, *ToolResult) {\n\tbus, ok := args[\"bus\"].(string)\n\tif !ok || bus == \"\" {\n\t\treturn \"\", ErrorResult(\"bus is required (e.g. \\\"1\\\" for /dev/i2c-1)\")\n\t}\n\tif !isValidBusID(bus) {\n\t\treturn \"\", ErrorResult(\"invalid bus identifier: must be a number (e.g. \\\"1\\\")\")\n\t}\n\treturn bus, nil\n}\n"
  },
  {
    "path": "pkg/tools/i2c_linux.go",
    "content": "package tools\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"syscall\"\n\t\"unsafe\"\n)\n\n// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)\nconst (\n\ti2cSlave = 0x0703 // Set slave address (fails if in use by driver)\n\ti2cFuncs = 0x0705 // Query adapter functionality bitmask\n\ti2cSmbus = 0x0720 // Perform SMBus transaction\n\n\t// I2C_FUNC capability bits\n\ti2cFuncSmbusQuick    = 0x00010000\n\ti2cFuncSmbusReadByte = 0x00020000\n\n\t// SMBus transaction types\n\ti2cSmbusRead  = 0\n\ti2cSmbusWrite = 1\n\n\t// SMBus protocol sizes\n\ti2cSmbusQuick = 0\n\ti2cSmbusByte  = 1\n)\n\n// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max).\n// For quick and byte transactions only the first byte is used (if at all).\ntype i2cSmbusData [34]byte\n\n// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data.\ntype i2cSmbusArgs struct {\n\treadWrite uint8\n\tcommand   uint8\n\tsize      uint32\n\tdata      *i2cSmbusData\n}\n\n// smbusProbe performs a single SMBus probe at the given address.\n// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for\n// EEPROM address ranges where quick write can corrupt AT24RF08 chips.\n// This matches i2cdetect's MODE_AUTO behavior.\nfunc smbusProbe(fd int, addr int, hasQuick bool) bool {\n\t// EEPROM ranges: use read byte (quick write can corrupt AT24RF08)\n\tuseReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F)\n\n\tif !useReadByte && hasQuick {\n\t\t// SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP]\n\t\t// Safest probe — no data transferred\n\t\targs := i2cSmbusArgs{\n\t\t\treadWrite: i2cSmbusWrite,\n\t\t\tcommand:   0,\n\t\t\tsize:      i2cSmbusQuick,\n\t\t\tdata:      nil,\n\t\t}\n\t\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))\n\t\treturn errno == 0\n\t}\n\n\t// SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP]\n\tvar data i2cSmbusData\n\targs := i2cSmbusArgs{\n\t\treadWrite: i2cSmbusRead,\n\t\tcommand:   0,\n\t\tsize:      i2cSmbusByte,\n\t\tdata:      &data,\n\t}\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))\n\treturn errno == 0\n}\n\n// scan probes valid 7-bit addresses on a bus for connected devices.\n// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:\n// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.\nfunc (t *I2CTool) scan(args map[string]any) *ToolResult {\n\tbus, errResult := parseI2CBus(args)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\n\tdevPath := fmt.Sprintf(\"/dev/i2c-%s\", bus)\n\tfd, err := syscall.Open(devPath, syscall.O_RDWR, 0)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to open %s: %v (check permissions and i2c-dev module)\", devPath, err))\n\t}\n\tdefer syscall.Close(fd)\n\n\t// Query adapter capabilities to determine available probe methods.\n\t// I2C_FUNCS writes an unsigned long, which is word-sized on Linux.\n\tvar funcs uintptr\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs)))\n\tif errno != 0 {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to query I2C adapter capabilities on %s: %v\", devPath, errno))\n\t}\n\n\thasQuick := funcs&i2cFuncSmbusQuick != 0\n\thasReadByte := funcs&i2cFuncSmbusReadByte != 0\n\n\tif !hasQuick && !hasReadByte {\n\t\treturn ErrorResult(\n\t\t\tfmt.Sprintf(\"I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely\", devPath),\n\t\t)\n\t}\n\n\ttype deviceEntry struct {\n\t\tAddress string `json:\"address\"`\n\t\tStatus  string `json:\"status,omitempty\"`\n\t}\n\n\tvar found []deviceEntry\n\t// Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07\n\tfor addr := 0x08; addr <= 0x77; addr++ {\n\t\t// Set slave address — EBUSY means a kernel driver owns this address\n\t\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))\n\t\tif errno != 0 {\n\t\t\tif errno == syscall.EBUSY {\n\t\t\t\tfound = append(found, deviceEntry{\n\t\t\t\t\tAddress: fmt.Sprintf(\"0x%02x\", addr),\n\t\t\t\t\tStatus:  \"busy (in use by kernel driver)\",\n\t\t\t\t})\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif smbusProbe(fd, addr, hasQuick) {\n\t\t\tfound = append(found, deviceEntry{\n\t\t\t\tAddress: fmt.Sprintf(\"0x%02x\", addr),\n\t\t\t})\n\t\t}\n\t}\n\n\tif len(found) == 0 {\n\t\treturn SilentResult(fmt.Sprintf(\"No devices found on %s. Check wiring and pull-up resistors.\", devPath))\n\t}\n\n\tresult, _ := json.MarshalIndent(map[string]any{\n\t\t\"bus\":     devPath,\n\t\t\"devices\": found,\n\t\t\"count\":   len(found),\n\t}, \"\", \"  \")\n\treturn SilentResult(fmt.Sprintf(\"Scan of %s:\\n%s\", devPath, string(result)))\n}\n\n// readDevice reads bytes from an I2C device, optionally at a specific register\nfunc (t *I2CTool) readDevice(args map[string]any) *ToolResult {\n\tbus, errResult := parseI2CBus(args)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\n\taddr, errResult := parseI2CAddress(args)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\n\tlength := 1\n\tif l, ok := args[\"length\"].(float64); ok {\n\t\tlength = int(l)\n\t}\n\tif length < 1 || length > 256 {\n\t\treturn ErrorResult(\"length must be between 1 and 256\")\n\t}\n\n\tdevPath := fmt.Sprintf(\"/dev/i2c-%s\", bus)\n\tfd, err := syscall.Open(devPath, syscall.O_RDWR, 0)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to open %s: %v\", devPath, err))\n\t}\n\tdefer syscall.Close(fd)\n\n\t// Set slave address\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))\n\tif errno != 0 {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to set I2C address 0x%02x: %v\", addr, errno))\n\t}\n\n\t// If register is specified, write it first\n\tif regFloat, ok := args[\"register\"].(float64); ok {\n\t\treg := int(regFloat)\n\t\tif reg < 0 || reg > 255 {\n\t\t\treturn ErrorResult(\"register must be between 0x00 and 0xFF\")\n\t\t}\n\t\t_, err = syscall.Write(fd, []byte{byte(reg)})\n\t\tif err != nil {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to write register 0x%02x: %v\", reg, err))\n\t\t}\n\t}\n\n\t// Read data\n\tbuf := make([]byte, length)\n\tn, err := syscall.Read(fd, buf)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to read from device 0x%02x: %v\", addr, err))\n\t}\n\n\t// Format as hex bytes\n\thexBytes := make([]string, n)\n\tintBytes := make([]int, n)\n\tfor i := 0; i < n; i++ {\n\t\thexBytes[i] = fmt.Sprintf(\"0x%02x\", buf[i])\n\t\tintBytes[i] = int(buf[i])\n\t}\n\n\tresult, _ := json.MarshalIndent(map[string]any{\n\t\t\"bus\":     devPath,\n\t\t\"address\": fmt.Sprintf(\"0x%02x\", addr),\n\t\t\"bytes\":   intBytes,\n\t\t\"hex\":     hexBytes,\n\t\t\"length\":  n,\n\t}, \"\", \"  \")\n\treturn SilentResult(string(result))\n}\n\n// writeDevice writes bytes to an I2C device, optionally at a specific register\nfunc (t *I2CTool) writeDevice(args map[string]any) *ToolResult {\n\tconfirm, _ := args[\"confirm\"].(bool)\n\tif !confirm {\n\t\treturn ErrorResult(\n\t\t\t\"write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.\",\n\t\t)\n\t}\n\n\tbus, errResult := parseI2CBus(args)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\n\taddr, errResult := parseI2CAddress(args)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\n\tdataRaw, ok := args[\"data\"].([]any)\n\tif !ok || len(dataRaw) == 0 {\n\t\treturn ErrorResult(\"data is required for write (array of byte values 0-255)\")\n\t}\n\tif len(dataRaw) > 256 {\n\t\treturn ErrorResult(\"data too long: maximum 256 bytes per I2C transaction\")\n\t}\n\n\tdata := make([]byte, 0, len(dataRaw)+1)\n\n\t// If register is specified, prepend it to the data\n\tif regFloat, ok := args[\"register\"].(float64); ok {\n\t\treg := int(regFloat)\n\t\tif reg < 0 || reg > 255 {\n\t\t\treturn ErrorResult(\"register must be between 0x00 and 0xFF\")\n\t\t}\n\t\tdata = append(data, byte(reg))\n\t}\n\n\tfor i, v := range dataRaw {\n\t\tf, ok := v.(float64)\n\t\tif !ok {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"data[%d] is not a valid byte value\", i))\n\t\t}\n\t\tb := int(f)\n\t\tif b < 0 || b > 255 {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"data[%d] = %d is out of byte range (0-255)\", i, b))\n\t\t}\n\t\tdata = append(data, byte(b))\n\t}\n\n\tdevPath := fmt.Sprintf(\"/dev/i2c-%s\", bus)\n\tfd, err := syscall.Open(devPath, syscall.O_RDWR, 0)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to open %s: %v\", devPath, err))\n\t}\n\tdefer syscall.Close(fd)\n\n\t// Set slave address\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))\n\tif errno != 0 {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to set I2C address 0x%02x: %v\", addr, errno))\n\t}\n\n\t// Write data\n\tn, err := syscall.Write(fd, data)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to write to device 0x%02x: %v\", addr, err))\n\t}\n\n\treturn SilentResult(fmt.Sprintf(\"Wrote %d byte(s) to device 0x%02x on %s\", n, addr, devPath))\n}\n"
  },
  {
    "path": "pkg/tools/i2c_other.go",
    "content": "//go:build !linux\n\npackage tools\n\n// scan is a stub for non-Linux platforms.\nfunc (t *I2CTool) scan(args map[string]any) *ToolResult {\n\treturn ErrorResult(\"I2C is only supported on Linux\")\n}\n\n// readDevice is a stub for non-Linux platforms.\nfunc (t *I2CTool) readDevice(args map[string]any) *ToolResult {\n\treturn ErrorResult(\"I2C is only supported on Linux\")\n}\n\n// writeDevice is a stub for non-Linux platforms.\nfunc (t *I2CTool) writeDevice(args map[string]any) *ToolResult {\n\treturn ErrorResult(\"I2C is only supported on Linux\")\n}\n"
  },
  {
    "path": "pkg/tools/mcp_tool.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"strings\"\n\n\t\"github.com/modelcontextprotocol/go-sdk/mcp\"\n)\n\n// MCPManager defines the interface for MCP manager operations\n// This allows for easier testing with mock implementations\ntype MCPManager interface {\n\tCallTool(\n\t\tctx context.Context,\n\t\tserverName, toolName string,\n\t\targuments map[string]any,\n\t) (*mcp.CallToolResult, error)\n}\n\n// MCPTool wraps an MCP tool to implement the Tool interface\ntype MCPTool struct {\n\tmanager    MCPManager\n\tserverName string\n\ttool       *mcp.Tool\n}\n\n// NewMCPTool creates a new MCP tool wrapper\nfunc NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {\n\treturn &MCPTool{\n\t\tmanager:    manager,\n\t\tserverName: serverName,\n\t\ttool:       tool,\n\t}\n}\n\n// sanitizeIdentifierComponent normalizes a string so it can be safely used\n// as part of a tool/function identifier for downstream providers.\n// It:\n//   - lowercases the string\n//   - replaces any character not in [a-z0-9_-] with '_'\n//   - collapses multiple consecutive '_' into a single '_'\n//   - trims leading/trailing '_'\n//   - falls back to \"unnamed\" if the result is empty\n//   - truncates overly long components to a reasonable length\nfunc sanitizeIdentifierComponent(s string) string {\n\tconst maxLen = 64\n\n\ts = strings.ToLower(s)\n\tvar b strings.Builder\n\tb.Grow(len(s))\n\n\tprevUnderscore := false\n\tfor _, r := range s {\n\t\tisAllowed := (r >= 'a' && r <= 'z') ||\n\t\t\t(r >= '0' && r <= '9') ||\n\t\t\tr == '_' || r == '-'\n\n\t\tif !isAllowed {\n\t\t\t// Normalize any disallowed character to '_'\n\t\t\tif !prevUnderscore {\n\t\t\t\tb.WriteRune('_')\n\t\t\t\tprevUnderscore = true\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif r == '_' {\n\t\t\tif prevUnderscore {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tprevUnderscore = true\n\t\t} else {\n\t\t\tprevUnderscore = false\n\t\t}\n\n\t\tb.WriteRune(r)\n\t}\n\n\tresult := strings.Trim(b.String(), \"_\")\n\tif result == \"\" {\n\t\tresult = \"unnamed\"\n\t}\n\n\tif len(result) > maxLen {\n\t\tresult = result[:maxLen]\n\t}\n\n\treturn result\n}\n\n// Name returns the tool name, prefixed with the server name.\n// The total length is capped at 64 characters (OpenAI-compatible API limit).\n// A short hash of the original (unsanitized) server and tool names is appended\n// whenever sanitization is lossy or the name is truncated, ensuring that two\n// names which differ only in disallowed characters remain distinct after sanitization.\nfunc (t *MCPTool) Name() string {\n\t// Prefix with server name to avoid conflicts, and sanitize components\n\tsanitizedServer := sanitizeIdentifierComponent(t.serverName)\n\tsanitizedTool := sanitizeIdentifierComponent(t.tool.Name)\n\tfull := fmt.Sprintf(\"mcp_%s_%s\", sanitizedServer, sanitizedTool)\n\n\t// Check if sanitization was lossless (only lowercasing, no char replacement/truncation)\n\tlossless := strings.ToLower(t.serverName) == sanitizedServer &&\n\t\tstrings.ToLower(t.tool.Name) == sanitizedTool\n\n\tconst maxTotal = 64\n\tif lossless && len(full) <= maxTotal {\n\t\treturn full\n\t}\n\n\t// Sanitization was lossy or name too long: append hash of the ORIGINAL names\n\t// (not the sanitized names) so different originals always yield different hashes.\n\th := fnv.New32a()\n\t_, _ = h.Write([]byte(t.serverName + \"\\x00\" + t.tool.Name))\n\tsuffix := fmt.Sprintf(\"%08x\", h.Sum32()) // 8 chars\n\n\tbase := full\n\tif len(base) > maxTotal-9 {\n\t\tbase = strings.TrimRight(full[:maxTotal-9], \"_\")\n\t}\n\treturn base + \"_\" + suffix\n}\n\n// Description returns the tool description\nfunc (t *MCPTool) Description() string {\n\tdesc := t.tool.Description\n\tif desc == \"\" {\n\t\tdesc = fmt.Sprintf(\"MCP tool from %s server\", t.serverName)\n\t}\n\t// Add server info to description\n\treturn fmt.Sprintf(\"[MCP:%s] %s\", t.serverName, desc)\n}\n\n// Parameters returns the tool parameters schema\nfunc (t *MCPTool) Parameters() map[string]any {\n\t// The InputSchema is already a JSON Schema object\n\tschema := t.tool.InputSchema\n\n\t// Handle nil schema\n\tif schema == nil {\n\t\treturn map[string]any{\n\t\t\t\"type\":       \"object\",\n\t\t\t\"properties\": map[string]any{},\n\t\t\t\"required\":   []string{},\n\t\t}\n\t}\n\n\t// Try direct conversion first (fast path)\n\tif schemaMap, ok := schema.(map[string]any); ok {\n\t\treturn schemaMap\n\t}\n\n\t// Handle json.RawMessage and []byte - unmarshal directly\n\tvar jsonData []byte\n\tif rawMsg, ok := schema.(json.RawMessage); ok {\n\t\tjsonData = rawMsg\n\t} else if bytes, ok := schema.([]byte); ok {\n\t\tjsonData = bytes\n\t}\n\n\tif jsonData != nil {\n\t\tvar result map[string]any\n\t\tif err := json.Unmarshal(jsonData, &result); err == nil {\n\t\t\treturn result\n\t\t}\n\t\t// Fallback on error\n\t\treturn map[string]any{\n\t\t\t\"type\":       \"object\",\n\t\t\t\"properties\": map[string]any{},\n\t\t\t\"required\":   []string{},\n\t\t}\n\t}\n\n\t// For other types (structs, etc.), convert via JSON marshal/unmarshal\n\tvar err error\n\tjsonData, err = json.Marshal(schema)\n\tif err != nil {\n\t\t// Fallback to empty schema if marshaling fails\n\t\treturn map[string]any{\n\t\t\t\"type\":       \"object\",\n\t\t\t\"properties\": map[string]any{},\n\t\t\t\"required\":   []string{},\n\t\t}\n\t}\n\n\tvar result map[string]any\n\tif err := json.Unmarshal(jsonData, &result); err != nil {\n\t\t// Fallback to empty schema if unmarshaling fails\n\t\treturn map[string]any{\n\t\t\t\"type\":       \"object\",\n\t\t\t\"properties\": map[string]any{},\n\t\t\t\"required\":   []string{},\n\t\t}\n\t}\n\n\treturn result\n}\n\n// Execute executes the MCP tool\nfunc (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tresult, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"MCP tool execution failed: %v\", err)).WithError(err)\n\t}\n\n\tif result == nil {\n\t\tnilErr := fmt.Errorf(\"MCP tool returned nil result without error\")\n\t\treturn ErrorResult(\"MCP tool execution failed: nil result\").WithError(nilErr)\n\t}\n\n\t// Handle error result from server\n\tif result.IsError {\n\t\terrMsg := extractContentText(result.Content)\n\t\treturn ErrorResult(fmt.Sprintf(\"MCP tool returned error: %s\", errMsg)).\n\t\t\tWithError(fmt.Errorf(\"MCP tool error: %s\", errMsg))\n\t}\n\n\t// Extract text content from result\n\toutput := extractContentText(result.Content)\n\n\treturn &ToolResult{\n\t\tForLLM:  output,\n\t\tIsError: false,\n\t}\n}\n\n// extractContentText extracts text from MCP content array\nfunc extractContentText(content []mcp.Content) string {\n\tvar parts []string\n\tfor _, c := range content {\n\t\tswitch v := c.(type) {\n\t\tcase *mcp.TextContent:\n\t\t\tparts = append(parts, v.Text)\n\t\tcase *mcp.ImageContent:\n\t\t\t// For images, just indicate that an image was returned\n\t\t\tparts = append(parts, fmt.Sprintf(\"[Image: %s]\", v.MIMEType))\n\t\tdefault:\n\t\t\t// For other content types, use string representation\n\t\t\tparts = append(parts, fmt.Sprintf(\"[Content: %T]\", v))\n\t\t}\n\t}\n\treturn strings.Join(parts, \"\\n\")\n}\n"
  },
  {
    "path": "pkg/tools/mcp_tool_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/modelcontextprotocol/go-sdk/mcp\"\n)\n\n// MockMCPManager is a mock implementation of MCPManager interface for testing\ntype MockMCPManager struct {\n\tcallToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error)\n}\n\nfunc (m *MockMCPManager) CallTool(\n\tctx context.Context,\n\tserverName, toolName string,\n\targuments map[string]any,\n) (*mcp.CallToolResult, error) {\n\tif m.callToolFunc != nil {\n\t\treturn m.callToolFunc(ctx, serverName, toolName, arguments)\n\t}\n\treturn &mcp.CallToolResult{\n\t\tContent: []mcp.Content{\n\t\t\t&mcp.TextContent{Text: \"mock result\"},\n\t\t},\n\t\tIsError: false,\n\t}, nil\n}\n\n// TestNewMCPTool verifies MCP tool creation\nfunc TestNewMCPTool(t *testing.T) {\n\tmanager := &MockMCPManager{}\n\ttool := &mcp.Tool{\n\t\tName:        \"test_tool\",\n\t\tDescription: \"A test tool\",\n\t\tInputSchema: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"input\": map[string]any{\n\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\"description\": \"Test input\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\tif mcpTool == nil {\n\t\tt.Fatal(\"NewMCPTool should not return nil\")\n\t}\n\t// Verify tool properties we can access\n\tif mcpTool.Name() != \"mcp_test_server_test_tool\" {\n\t\tt.Errorf(\"Expected tool name with prefix, got '%s'\", mcpTool.Name())\n\t}\n}\n\n// TestMCPTool_Name verifies tool name with server prefix\nfunc TestMCPTool_Name(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tserverName string\n\t\ttoolName   string\n\t\texpected   string\n\t}{\n\t\t{\n\t\t\tname:       \"simple name\",\n\t\t\tserverName: \"github\",\n\t\t\ttoolName:   \"create_issue\",\n\t\t\texpected:   \"mcp_github_create_issue\",\n\t\t},\n\t\t{\n\t\t\tname:       \"filesystem server\",\n\t\t\tserverName: \"filesystem\",\n\t\t\ttoolName:   \"read_file\",\n\t\t\texpected:   \"mcp_filesystem_read_file\",\n\t\t},\n\t\t{\n\t\t\tname:       \"remote server\",\n\t\t\tserverName: \"remote-api\",\n\t\t\ttoolName:   \"fetch_data\",\n\t\t\texpected:   \"mcp_remote-api_fetch_data\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmanager := &MockMCPManager{}\n\t\t\ttool := &mcp.Tool{Name: tt.toolName}\n\t\t\tmcpTool := NewMCPTool(manager, tt.serverName, tool)\n\n\t\t\tresult := mcpTool.Name()\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected name '%s', got '%s'\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMCPTool_Description verifies tool description generation\nfunc TestMCPTool_Description(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tserverName      string\n\t\ttoolDescription string\n\t\texpectContains  []string\n\t}{\n\t\t{\n\t\t\tname:            \"with description\",\n\t\t\tserverName:      \"github\",\n\t\t\ttoolDescription: \"Create a GitHub issue\",\n\t\t\texpectContains:  []string{\"[MCP:github]\", \"Create a GitHub issue\"},\n\t\t},\n\t\t{\n\t\t\tname:            \"empty description\",\n\t\t\tserverName:      \"filesystem\",\n\t\t\ttoolDescription: \"\",\n\t\t\texpectContains:  []string{\"[MCP:filesystem]\", \"MCP tool from filesystem server\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmanager := &MockMCPManager{}\n\t\t\ttool := &mcp.Tool{\n\t\t\t\tName:        \"test_tool\",\n\t\t\t\tDescription: tt.toolDescription,\n\t\t\t}\n\t\t\tmcpTool := NewMCPTool(manager, tt.serverName, tool)\n\n\t\t\tresult := mcpTool.Description()\n\n\t\t\tfor _, expected := range tt.expectContains {\n\t\t\t\tif !strings.Contains(result, expected) {\n\t\t\t\t\tt.Errorf(\"Description should contain '%s', got: %s\", expected, result)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMCPTool_Parameters verifies parameter schema conversion\nfunc TestMCPTool_Parameters(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinputSchema    any\n\t\texpectType     string\n\t\tcheckProperty  string\n\t\texpectProperty bool\n\t}{\n\t\t{\n\t\t\tname: \"map schema\",\n\t\t\tinputSchema: map[string]any{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": map[string]any{\n\t\t\t\t\t\"query\": map[string]any{\n\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\"description\": \"Search query\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"required\": []string{\"query\"},\n\t\t\t},\n\t\t\texpectType:     \"object\",\n\t\t\tcheckProperty:  \"query\",\n\t\t\texpectProperty: true,\n\t\t},\n\t\t{\n\t\t\tname:           \"nil schema\",\n\t\t\tinputSchema:    nil,\n\t\t\texpectType:     \"object\",\n\t\t\texpectProperty: false,\n\t\t},\n\t\t{\n\t\t\tname: \"json.RawMessage schema\",\n\t\t\tinputSchema: []byte(`{\n\t\t\t\t\"type\": \"object\",\n\t\t\t\t\"properties\": {\n\t\t\t\t\t\"repo\": {\n\t\t\t\t\t\t\"type\": \"string\",\n\t\t\t\t\t\t\"description\": \"Repository name\"\n\t\t\t\t\t},\n\t\t\t\t\t\"stars\": {\n\t\t\t\t\t\t\"type\": \"integer\",\n\t\t\t\t\t\t\"description\": \"Minimum stars\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"required\": [\"repo\"]\n\t\t\t}`),\n\t\t\texpectType:     \"object\",\n\t\t\tcheckProperty:  \"repo\",\n\t\t\texpectProperty: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmanager := &MockMCPManager{}\n\t\t\ttool := &mcp.Tool{\n\t\t\t\tName:        \"test_tool\",\n\t\t\t\tInputSchema: tt.inputSchema,\n\t\t\t}\n\t\t\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\t\t\tparams := mcpTool.Parameters()\n\n\t\t\tif params == nil {\n\t\t\t\tt.Fatal(\"Parameters should not be nil\")\n\t\t\t}\n\n\t\t\tif params[\"type\"] != tt.expectType {\n\t\t\t\tt.Errorf(\"Expected type '%s', got '%v'\", tt.expectType, params[\"type\"])\n\t\t\t}\n\n\t\t\t// Check if property exists when expected\n\t\t\tif tt.checkProperty != \"\" {\n\t\t\t\tproperties, ok := params[\"properties\"].(map[string]any)\n\t\t\t\tif !ok && tt.expectProperty {\n\t\t\t\t\tt.Errorf(\"Expected properties to be a map\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif ok {\n\t\t\t\t\t_, hasProperty := properties[tt.checkProperty]\n\t\t\t\t\tif hasProperty != tt.expectProperty {\n\t\t\t\t\t\tt.Errorf(\"Expected property '%s' existence: %v, got: %v\",\n\t\t\t\t\t\t\ttt.checkProperty, tt.expectProperty, hasProperty)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestMCPTool_Execute_Success tests successful tool execution\nfunc TestMCPTool_Execute_Success(t *testing.T) {\n\tmanager := &MockMCPManager{\n\t\tcallToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {\n\t\t\t// Verify correct parameters passed\n\t\t\tif serverName != \"github\" {\n\t\t\t\tt.Errorf(\"Expected serverName 'github', got '%s'\", serverName)\n\t\t\t}\n\t\t\tif toolName != \"search_repos\" {\n\t\t\t\tt.Errorf(\"Expected toolName 'search_repos', got '%s'\", toolName)\n\t\t\t}\n\n\t\t\treturn &mcp.CallToolResult{\n\t\t\t\tContent: []mcp.Content{\n\t\t\t\t\t&mcp.TextContent{Text: \"Found 3 repositories\"},\n\t\t\t\t},\n\t\t\t\tIsError: false,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\ttool := &mcp.Tool{\n\t\tName:        \"search_repos\",\n\t\tDescription: \"Search GitHub repositories\",\n\t}\n\tmcpTool := NewMCPTool(manager, \"github\", tool)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"query\": \"golang mcp\",\n\t}\n\n\tresult := mcpTool.Execute(ctx, args)\n\n\tif result == nil {\n\t\tt.Fatal(\"Result should not be nil\")\n\t}\n\tif result.IsError {\n\t\tt.Errorf(\"Expected no error, got error: %s\", result.ForLLM)\n\t}\n\tif result.ForLLM != \"Found 3 repositories\" {\n\t\tt.Errorf(\"Expected 'Found 3 repositories', got '%s'\", result.ForLLM)\n\t}\n}\n\n// TestMCPTool_Execute_ManagerError tests execution when manager returns error\nfunc TestMCPTool_Execute_ManagerError(t *testing.T) {\n\tmanager := &MockMCPManager{\n\t\tcallToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {\n\t\t\treturn nil, fmt.Errorf(\"connection failed\")\n\t\t},\n\t}\n\n\ttool := &mcp.Tool{Name: \"test_tool\"}\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\tctx := context.Background()\n\tresult := mcpTool.Execute(ctx, map[string]any{})\n\n\tif result == nil {\n\t\tt.Fatal(\"Result should not be nil\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError to be true\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"MCP tool execution failed\") {\n\t\tt.Errorf(\"Error message should mention execution failure, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"connection failed\") {\n\t\tt.Errorf(\"Error message should include original error, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestMCPTool_Execute_ServerError tests execution when server returns error\nfunc TestMCPTool_Execute_ServerError(t *testing.T) {\n\tmanager := &MockMCPManager{\n\t\tcallToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {\n\t\t\treturn &mcp.CallToolResult{\n\t\t\t\tContent: []mcp.Content{\n\t\t\t\t\t&mcp.TextContent{Text: \"Invalid API key\"},\n\t\t\t\t},\n\t\t\t\tIsError: true,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\ttool := &mcp.Tool{Name: \"test_tool\"}\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\tctx := context.Background()\n\tresult := mcpTool.Execute(ctx, map[string]any{})\n\n\tif result == nil {\n\t\tt.Fatal(\"Result should not be nil\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError to be true\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"MCP tool returned error\") {\n\t\tt.Errorf(\"Error message should mention server error, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Invalid API key\") {\n\t\tt.Errorf(\"Error message should include server message, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestMCPTool_Execute_MultipleContent tests execution with multiple content items\nfunc TestMCPTool_Execute_MultipleContent(t *testing.T) {\n\tmanager := &MockMCPManager{\n\t\tcallToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {\n\t\t\treturn &mcp.CallToolResult{\n\t\t\t\tContent: []mcp.Content{\n\t\t\t\t\t&mcp.TextContent{Text: \"First line\"},\n\t\t\t\t\t&mcp.TextContent{Text: \"Second line\"},\n\t\t\t\t\t&mcp.TextContent{Text: \"Third line\"},\n\t\t\t\t},\n\t\t\t\tIsError: false,\n\t\t\t}, nil\n\t\t},\n\t}\n\n\ttool := &mcp.Tool{Name: \"multi_output\"}\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\tctx := context.Background()\n\tresult := mcpTool.Execute(ctx, map[string]any{})\n\n\tif result.IsError {\n\t\tt.Errorf(\"Expected no error, got: %s\", result.ForLLM)\n\t}\n\n\texpected := \"First line\\nSecond line\\nThird line\"\n\tif result.ForLLM != expected {\n\t\tt.Errorf(\"Expected '%s', got '%s'\", expected, result.ForLLM)\n\t}\n}\n\n// TestExtractContentText_TextContent tests text content extraction\nfunc TestExtractContentText_TextContent(t *testing.T) {\n\tcontent := []mcp.Content{\n\t\t&mcp.TextContent{Text: \"Hello World\"},\n\t\t&mcp.TextContent{Text: \"Second message\"},\n\t}\n\n\tresult := extractContentText(content)\n\texpected := \"Hello World\\nSecond message\"\n\n\tif result != expected {\n\t\tt.Errorf(\"Expected '%s', got '%s'\", expected, result)\n\t}\n}\n\n// TestExtractContentText_ImageContent tests image content extraction\nfunc TestExtractContentText_ImageContent(t *testing.T) {\n\tcontent := []mcp.Content{\n\t\t&mcp.ImageContent{\n\t\t\tData:     []byte(\"base64data\"),\n\t\t\tMIMEType: \"image/png\",\n\t\t},\n\t}\n\n\tresult := extractContentText(content)\n\n\tif !strings.Contains(result, \"[Image:\") {\n\t\tt.Errorf(\"Expected image indicator, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"image/png\") {\n\t\tt.Errorf(\"Expected MIME type in output, got: %s\", result)\n\t}\n}\n\n// TestExtractContentText_MixedContent tests mixed content types\nfunc TestExtractContentText_MixedContent(t *testing.T) {\n\tcontent := []mcp.Content{\n\t\t&mcp.TextContent{Text: \"Description\"},\n\t\t&mcp.ImageContent{\n\t\t\tData:     []byte(\"data\"),\n\t\t\tMIMEType: \"image/jpeg\",\n\t\t},\n\t\t&mcp.TextContent{Text: \"More text\"},\n\t}\n\n\tresult := extractContentText(content)\n\n\tif !strings.Contains(result, \"Description\") {\n\t\tt.Errorf(\"Should contain text content, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"[Image:\") {\n\t\tt.Errorf(\"Should contain image indicator, got: %s\", result)\n\t}\n\tif !strings.Contains(result, \"More text\") {\n\t\tt.Errorf(\"Should contain second text, got: %s\", result)\n\t}\n}\n\n// TestExtractContentText_EmptyContent tests empty content array\nfunc TestExtractContentText_EmptyContent(t *testing.T) {\n\tcontent := []mcp.Content{}\n\n\tresult := extractContentText(content)\n\n\tif result != \"\" {\n\t\tt.Errorf(\"Expected empty string for empty content, got: %s\", result)\n\t}\n}\n\n// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface\nfunc TestMCPTool_InterfaceCompliance(t *testing.T) {\n\tmanager := &MockMCPManager{}\n\ttool := &mcp.Tool{Name: \"test\"}\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\t// Verify it implements Tool interface\n\tvar _ Tool = mcpTool\n}\n\n// TestMCPTool_Parameters_MapSchema tests schema that's already a map\nfunc TestMCPTool_Parameters_MapSchema(t *testing.T) {\n\tmanager := &MockMCPManager{}\n\tschema := map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"name\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The name parameter\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"name\"},\n\t}\n\n\ttool := &mcp.Tool{\n\t\tName:        \"test_tool\",\n\t\tInputSchema: schema,\n\t}\n\tmcpTool := NewMCPTool(manager, \"test_server\", tool)\n\n\tparams := mcpTool.Parameters()\n\n\t// Should return the schema as-is when it's already a map\n\tif params[\"type\"] != \"object\" {\n\t\tt.Errorf(\"Expected type 'object', got '%v'\", params[\"type\"])\n\t}\n\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Error(\"Properties should be a map\")\n\t}\n\n\tnameParam, ok := props[\"name\"].(map[string]any)\n\tif !ok {\n\t\tt.Error(\"Name parameter should exist\")\n\t}\n\n\tif nameParam[\"type\"] != \"string\" {\n\t\tt.Errorf(\"Name type should be 'string', got '%v'\", nameParam[\"type\"])\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/message.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync/atomic\"\n)\n\ntype SendCallback func(channel, chatID, content string) error\n\ntype MessageTool struct {\n\tsendCallback SendCallback\n\tsentInRound  atomic.Bool // Tracks whether a message was sent in the current processing round\n}\n\nfunc NewMessageTool() *MessageTool {\n\treturn &MessageTool{}\n}\n\nfunc (t *MessageTool) Name() string {\n\treturn \"message\"\n}\n\nfunc (t *MessageTool) Description() string {\n\treturn \"Send a message to user on a chat channel. Use this when you want to communicate something.\"\n}\n\nfunc (t *MessageTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"content\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The message content to send\",\n\t\t\t},\n\t\t\t\"channel\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional: target channel (telegram, whatsapp, etc.)\",\n\t\t\t},\n\t\t\t\"chat_id\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional: target chat/user ID\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"content\"},\n\t}\n}\n\n// ResetSentInRound resets the per-round send tracker.\n// Called by the agent loop at the start of each inbound message processing round.\nfunc (t *MessageTool) ResetSentInRound() {\n\tt.sentInRound.Store(false)\n}\n\n// HasSentInRound returns true if the message tool sent a message during the current round.\nfunc (t *MessageTool) HasSentInRound() bool {\n\treturn t.sentInRound.Load()\n}\n\nfunc (t *MessageTool) SetSendCallback(callback SendCallback) {\n\tt.sendCallback = callback\n}\n\nfunc (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tcontent, ok := args[\"content\"].(string)\n\tif !ok {\n\t\treturn &ToolResult{ForLLM: \"content is required\", IsError: true}\n\t}\n\n\tchannel, _ := args[\"channel\"].(string)\n\tchatID, _ := args[\"chat_id\"].(string)\n\n\tif channel == \"\" {\n\t\tchannel = ToolChannel(ctx)\n\t}\n\tif chatID == \"\" {\n\t\tchatID = ToolChatID(ctx)\n\t}\n\n\tif channel == \"\" || chatID == \"\" {\n\t\treturn &ToolResult{ForLLM: \"No target channel/chat specified\", IsError: true}\n\t}\n\n\tif t.sendCallback == nil {\n\t\treturn &ToolResult{ForLLM: \"Message sending not configured\", IsError: true}\n\t}\n\n\tif err := t.sendCallback(channel, chatID, content); err != nil {\n\t\treturn &ToolResult{\n\t\t\tForLLM:  fmt.Sprintf(\"sending message: %v\", err),\n\t\t\tIsError: true,\n\t\t\tErr:     err,\n\t\t}\n\t}\n\n\tt.sentInRound.Store(true)\n\t// Silent: user already received the message directly\n\treturn &ToolResult{\n\t\tForLLM: fmt.Sprintf(\"Message sent to %s:%s\", channel, chatID),\n\t\tSilent: true,\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/message_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestMessageTool_Execute_Success(t *testing.T) {\n\ttool := NewMessageTool()\n\n\tvar sentChannel, sentChatID, sentContent string\n\ttool.SetSendCallback(func(channel, chatID, content string) error {\n\t\tsentChannel = channel\n\t\tsentChatID = chatID\n\t\tsentContent = content\n\t\treturn nil\n\t})\n\n\tctx := WithToolContext(context.Background(), \"test-channel\", \"test-chat-id\")\n\targs := map[string]any{\n\t\t\"content\": \"Hello, world!\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify message was sent with correct parameters\n\tif sentChannel != \"test-channel\" {\n\t\tt.Errorf(\"Expected channel 'test-channel', got '%s'\", sentChannel)\n\t}\n\tif sentChatID != \"test-chat-id\" {\n\t\tt.Errorf(\"Expected chatID 'test-chat-id', got '%s'\", sentChatID)\n\t}\n\tif sentContent != \"Hello, world!\" {\n\t\tt.Errorf(\"Expected content 'Hello, world!', got '%s'\", sentContent)\n\t}\n\n\t// Verify ToolResult meets US-011 criteria:\n\t// - Send success returns SilentResult (Silent=true)\n\tif !result.Silent {\n\t\tt.Error(\"Expected Silent=true for successful send\")\n\t}\n\n\t// - ForLLM contains send status description\n\tif result.ForLLM != \"Message sent to test-channel:test-chat-id\" {\n\t\tt.Errorf(\"Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'\", result.ForLLM)\n\t}\n\n\t// - ForUser is empty (user already received message directly)\n\tif result.ForUser != \"\" {\n\t\tt.Errorf(\"Expected ForUser to be empty, got '%s'\", result.ForUser)\n\t}\n\n\t// - IsError should be false\n\tif result.IsError {\n\t\tt.Error(\"Expected IsError=false for successful send\")\n\t}\n}\n\nfunc TestMessageTool_Execute_WithCustomChannel(t *testing.T) {\n\ttool := NewMessageTool()\n\n\tvar sentChannel, sentChatID string\n\ttool.SetSendCallback(func(channel, chatID, content string) error {\n\t\tsentChannel = channel\n\t\tsentChatID = chatID\n\t\treturn nil\n\t})\n\n\tctx := WithToolContext(context.Background(), \"default-channel\", \"default-chat-id\")\n\targs := map[string]any{\n\t\t\"content\": \"Test message\",\n\t\t\"channel\": \"custom-channel\",\n\t\t\"chat_id\": \"custom-chat-id\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify custom channel/chatID were used instead of defaults\n\tif sentChannel != \"custom-channel\" {\n\t\tt.Errorf(\"Expected channel 'custom-channel', got '%s'\", sentChannel)\n\t}\n\tif sentChatID != \"custom-chat-id\" {\n\t\tt.Errorf(\"Expected chatID 'custom-chat-id', got '%s'\", sentChatID)\n\t}\n\n\tif !result.Silent {\n\t\tt.Error(\"Expected Silent=true\")\n\t}\n\tif result.ForLLM != \"Message sent to custom-channel:custom-chat-id\" {\n\t\tt.Errorf(\"Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'\", result.ForLLM)\n\t}\n}\n\nfunc TestMessageTool_Execute_SendFailure(t *testing.T) {\n\ttool := NewMessageTool()\n\n\tsendErr := errors.New(\"network error\")\n\ttool.SetSendCallback(func(channel, chatID, content string) error {\n\t\treturn sendErr\n\t})\n\n\tctx := WithToolContext(context.Background(), \"test-channel\", \"test-chat-id\")\n\targs := map[string]any{\n\t\t\"content\": \"Test message\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify ToolResult for send failure:\n\t// - Send failure returns ErrorResult (IsError=true)\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError=true for failed send\")\n\t}\n\n\t// - ForLLM contains error description\n\texpectedErrMsg := \"sending message: network error\"\n\tif result.ForLLM != expectedErrMsg {\n\t\tt.Errorf(\"Expected ForLLM '%s', got '%s'\", expectedErrMsg, result.ForLLM)\n\t}\n\n\t// - Err field should contain original error\n\tif result.Err == nil {\n\t\tt.Error(\"Expected Err to be set\")\n\t}\n\tif result.Err != sendErr {\n\t\tt.Errorf(\"Expected Err to be sendErr, got %v\", result.Err)\n\t}\n}\n\nfunc TestMessageTool_Execute_MissingContent(t *testing.T) {\n\ttool := NewMessageTool()\n\n\tctx := WithToolContext(context.Background(), \"test-channel\", \"test-chat-id\")\n\targs := map[string]any{} // content missing\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify error result for missing content\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError=true for missing content\")\n\t}\n\tif result.ForLLM != \"content is required\" {\n\t\tt.Errorf(\"Expected ForLLM 'content is required', got '%s'\", result.ForLLM)\n\t}\n}\n\nfunc TestMessageTool_Execute_NoTargetChannel(t *testing.T) {\n\ttool := NewMessageTool()\n\t// No WithToolContext — channel/chatID are empty\n\n\ttool.SetSendCallback(func(channel, chatID, content string) error {\n\t\treturn nil\n\t})\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"content\": \"Test message\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify error when no target channel specified\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError=true when no target channel\")\n\t}\n\tif result.ForLLM != \"No target channel/chat specified\" {\n\t\tt.Errorf(\"Expected ForLLM 'No target channel/chat specified', got '%s'\", result.ForLLM)\n\t}\n}\n\nfunc TestMessageTool_Execute_NotConfigured(t *testing.T) {\n\ttool := NewMessageTool()\n\t// No SetSendCallback called\n\n\tctx := WithToolContext(context.Background(), \"test-channel\", \"test-chat-id\")\n\targs := map[string]any{\n\t\t\"content\": \"Test message\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify error when send callback not configured\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError=true when send callback not configured\")\n\t}\n\tif result.ForLLM != \"Message sending not configured\" {\n\t\tt.Errorf(\"Expected ForLLM 'Message sending not configured', got '%s'\", result.ForLLM)\n\t}\n}\n\nfunc TestMessageTool_Name(t *testing.T) {\n\ttool := NewMessageTool()\n\tif tool.Name() != \"message\" {\n\t\tt.Errorf(\"Expected name 'message', got '%s'\", tool.Name())\n\t}\n}\n\nfunc TestMessageTool_Description(t *testing.T) {\n\ttool := NewMessageTool()\n\tdesc := tool.Description()\n\tif desc == \"\" {\n\t\tt.Error(\"Description should not be empty\")\n\t}\n}\n\nfunc TestMessageTool_Parameters(t *testing.T) {\n\ttool := NewMessageTool()\n\tparams := tool.Parameters()\n\n\t// Verify parameters structure\n\ttyp, ok := params[\"type\"].(string)\n\tif !ok || typ != \"object\" {\n\t\tt.Error(\"Expected type 'object'\")\n\t}\n\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Expected properties to be a map\")\n\t}\n\n\t// Check required properties\n\trequired, ok := params[\"required\"].([]string)\n\tif !ok || len(required) != 1 || required[0] != \"content\" {\n\t\tt.Error(\"Expected 'content' to be required\")\n\t}\n\n\t// Check content property\n\tcontentProp, ok := props[\"content\"].(map[string]any)\n\tif !ok {\n\t\tt.Error(\"Expected 'content' property\")\n\t}\n\tif contentProp[\"type\"] != \"string\" {\n\t\tt.Error(\"Expected content type to be 'string'\")\n\t}\n\n\t// Check channel property (optional)\n\tchannelProp, ok := props[\"channel\"].(map[string]any)\n\tif !ok {\n\t\tt.Error(\"Expected 'channel' property\")\n\t}\n\tif channelProp[\"type\"] != \"string\" {\n\t\tt.Error(\"Expected channel type to be 'string'\")\n\t}\n\n\t// Check chat_id property (optional)\n\tchatIDProp, ok := props[\"chat_id\"].(map[string]any)\n\tif !ok {\n\t\tt.Error(\"Expected 'chat_id' property\")\n\t}\n\tif chatIDProp[\"type\"] != \"string\" {\n\t\tt.Error(\"Expected chat_id type to be 'string'\")\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/registry.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\ntype ToolEntry struct {\n\tTool   Tool\n\tIsCore bool\n\tTTL    int\n}\n\ntype ToolRegistry struct {\n\ttools   map[string]*ToolEntry\n\tmu      sync.RWMutex\n\tversion atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation\n}\n\nfunc NewToolRegistry() *ToolRegistry {\n\treturn &ToolRegistry{\n\t\ttools: make(map[string]*ToolEntry),\n\t}\n}\n\nfunc (r *ToolRegistry) Register(tool Tool) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tname := tool.Name()\n\tif _, exists := r.tools[name]; exists {\n\t\tlogger.WarnCF(\"tools\", \"Tool registration overwrites existing tool\",\n\t\t\tmap[string]any{\"name\": name})\n\t}\n\tr.tools[name] = &ToolEntry{\n\t\tTool:   tool,\n\t\tIsCore: true,\n\t\tTTL:    0, // Core tools do not use TTL\n\t}\n\tr.version.Add(1)\n\tlogger.DebugCF(\"tools\", \"Registered core tool\", map[string]any{\"name\": name})\n}\n\n// RegisterHidden saves hidden tools (visible only via TTL)\nfunc (r *ToolRegistry) RegisterHidden(tool Tool) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tname := tool.Name()\n\tif _, exists := r.tools[name]; exists {\n\t\tlogger.WarnCF(\"tools\", \"Hidden tool registration overwrites existing tool\",\n\t\t\tmap[string]any{\"name\": name})\n\t}\n\tr.tools[name] = &ToolEntry{\n\t\tTool:   tool,\n\t\tIsCore: false,\n\t\tTTL:    0,\n\t}\n\tr.version.Add(1)\n\tlogger.DebugCF(\"tools\", \"Registered hidden tool\", map[string]any{\"name\": name})\n}\n\n// PromoteTools atomically sets the TTL for multiple non-core tools.\n// This prevents a concurrent TickTTL from decrementing between promotions.\nfunc (r *ToolRegistry) PromoteTools(names []string, ttl int) {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tpromoted := 0\n\tfor _, name := range names {\n\t\tif entry, exists := r.tools[name]; exists {\n\t\t\tif !entry.IsCore {\n\t\t\t\tentry.TTL = ttl\n\t\t\t\tpromoted++\n\t\t\t}\n\t\t}\n\t}\n\tlogger.DebugCF(\n\t\t\"tools\",\n\t\t\"PromoteTools completed\",\n\t\tmap[string]any{\"requested\": len(names), \"promoted\": promoted, \"ttl\": ttl},\n\t)\n}\n\n// TickTTL decreases TTL only for non-core tools\nfunc (r *ToolRegistry) TickTTL() {\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tfor _, entry := range r.tools {\n\t\tif !entry.IsCore && entry.TTL > 0 {\n\t\t\tentry.TTL--\n\t\t}\n\t}\n}\n\n// Version returns the current registry version (atomically).\nfunc (r *ToolRegistry) Version() uint64 {\n\treturn r.version.Load()\n}\n\n// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the\n// registry version at which it was taken. Used by BM25SearchTool cache.\ntype HiddenToolSnapshot struct {\n\tDocs    []HiddenToolDoc\n\tVersion uint64\n}\n\n// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing.\ntype HiddenToolDoc struct {\n\tName        string\n\tDescription string\n}\n\n// SnapshotHiddenTools returns all non-core tools and the current registry\n// version under a single read-lock, guaranteeing consistency between the\n// two values.\nfunc (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tdocs := make([]HiddenToolDoc, 0, len(r.tools))\n\tfor name, entry := range r.tools {\n\t\tif !entry.IsCore {\n\t\t\tdocs = append(docs, HiddenToolDoc{\n\t\t\t\tName:        name,\n\t\t\t\tDescription: entry.Tool.Description(),\n\t\t\t})\n\t\t}\n\t}\n\treturn HiddenToolSnapshot{\n\t\tDocs:    docs,\n\t\tVersion: r.version.Load(),\n\t}\n}\n\nfunc (r *ToolRegistry) Get(name string) (Tool, bool) {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tentry, ok := r.tools[name]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\t// Hidden tools with expired TTL are not callable.\n\tif !entry.IsCore && entry.TTL <= 0 {\n\t\treturn nil, false\n\t}\n\treturn entry.Tool, true\n}\n\nfunc (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {\n\treturn r.ExecuteWithContext(ctx, name, args, \"\", \"\", nil)\n}\n\n// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.\n// If the tool implements AsyncExecutor and a non-nil callback is provided,\n// ExecuteAsync is called instead of Execute — the callback is a parameter,\n// never stored as mutable state on the tool.\nfunc (r *ToolRegistry) ExecuteWithContext(\n\tctx context.Context,\n\tname string,\n\targs map[string]any,\n\tchannel, chatID string,\n\tasyncCallback AsyncCallback,\n) *ToolResult {\n\tlogger.InfoCF(\"tool\", \"Tool execution started\",\n\t\tmap[string]any{\n\t\t\t\"tool\": name,\n\t\t\t\"args\": args,\n\t\t})\n\n\ttool, ok := r.Get(name)\n\tif !ok {\n\t\tlogger.ErrorCF(\"tool\", \"Tool not found\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tool\": name,\n\t\t\t})\n\t\treturn ErrorResult(fmt.Sprintf(\"tool %q not found\", name)).WithError(fmt.Errorf(\"tool not found\"))\n\t}\n\n\t// Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).\n\t// Always inject — tools validate what they require.\n\tctx = WithToolContext(ctx, channel, chatID)\n\n\t// If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.\n\t// The callback is a call parameter, not mutable state on the tool instance.\n\tvar result *ToolResult\n\tstart := time.Now()\n\n\t// Use recover to catch any panics during tool execution\n\t// This prevents tool crashes from killing the entire agent\n\tfunc() {\n\t\tdefer func() {\n\t\t\tif re := recover(); re != nil {\n\t\t\t\terrMsg := fmt.Sprintf(\"Tool '%s' crashed with panic: %v\", name, re)\n\t\t\t\tlogger.ErrorCF(\"tool\", \"Tool execution panic recovered\",\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"tool\":  name,\n\t\t\t\t\t\t\"panic\": fmt.Sprintf(\"%v\", re),\n\t\t\t\t\t})\n\t\t\t\tresult = &ToolResult{\n\t\t\t\t\tForLLM:  errMsg,\n\t\t\t\t\tForUser: errMsg,\n\t\t\t\t\tIsError: true,\n\t\t\t\t\tErr:     fmt.Errorf(\"panic: %v\", re),\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\tif asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {\n\t\t\tlogger.DebugCF(\"tool\", \"Executing async tool via ExecuteAsync\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"tool\": name,\n\t\t\t\t})\n\t\t\tresult = asyncExec.ExecuteAsync(ctx, args, asyncCallback)\n\t\t} else {\n\t\t\tresult = tool.Execute(ctx, args)\n\t\t}\n\t}()\n\n\t// Handle nil result (should not happen, but defensive)\n\tif result == nil {\n\t\tresult = &ToolResult{\n\t\t\tForLLM:  fmt.Sprintf(\"Tool '%s' returned nil result unexpectedly\", name),\n\t\t\tForUser: fmt.Sprintf(\"Tool '%s' returned nil result unexpectedly\", name),\n\t\t\tIsError: true,\n\t\t\tErr:     fmt.Errorf(\"nil result from tool\"),\n\t\t}\n\t}\n\n\tduration := time.Since(start)\n\n\t// Log based on result type\n\tif result.IsError {\n\t\tlogger.ErrorCF(\"tool\", \"Tool execution failed\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tool\":     name,\n\t\t\t\t\"duration\": duration.Milliseconds(),\n\t\t\t\t\"error\":    result.ForLLM,\n\t\t\t})\n\t} else if result.Async {\n\t\tlogger.InfoCF(\"tool\", \"Tool started (async)\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tool\":     name,\n\t\t\t\t\"duration\": duration.Milliseconds(),\n\t\t\t})\n\t} else {\n\t\tlogger.InfoCF(\"tool\", \"Tool execution completed\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tool\":          name,\n\t\t\t\t\"duration_ms\":   duration.Milliseconds(),\n\t\t\t\t\"result_length\": len(result.ForLLM),\n\t\t\t})\n\t}\n\n\treturn result\n}\n\n// sortedToolNames returns tool names in sorted order for deterministic iteration.\n// This is critical for KV cache stability: non-deterministic map iteration would\n// produce different system prompts and tool definitions on each call, invalidating\n// the LLM's prefix cache even when no tools have changed.\nfunc (r *ToolRegistry) sortedToolNames() []string {\n\tnames := make([]string, 0, len(r.tools))\n\tfor name := range r.tools {\n\t\tnames = append(names, name)\n\t}\n\tsort.Strings(names)\n\treturn names\n}\n\nfunc (r *ToolRegistry) GetDefinitions() []map[string]any {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tsorted := r.sortedToolNames()\n\tdefinitions := make([]map[string]any, 0, len(sorted))\n\tfor _, name := range sorted {\n\t\tentry := r.tools[name]\n\n\t\tif !entry.IsCore && entry.TTL <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdefinitions = append(definitions, ToolToSchema(r.tools[name].Tool))\n\t}\n\treturn definitions\n}\n\n// ToProviderDefs converts tool definitions to provider-compatible format.\n// This is the format expected by LLM provider APIs.\nfunc (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tsorted := r.sortedToolNames()\n\tdefinitions := make([]providers.ToolDefinition, 0, len(sorted))\n\tfor _, name := range sorted {\n\t\tentry := r.tools[name]\n\n\t\tif !entry.IsCore && entry.TTL <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tschema := ToolToSchema(entry.Tool)\n\n\t\t// Safely extract nested values with type checks\n\t\tfn, ok := schema[\"function\"].(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tname, _ := fn[\"name\"].(string)\n\t\tdesc, _ := fn[\"description\"].(string)\n\t\tparams, _ := fn[\"parameters\"].(map[string]any)\n\n\t\tdefinitions = append(definitions, providers.ToolDefinition{\n\t\t\tType: \"function\",\n\t\t\tFunction: providers.ToolFunctionDefinition{\n\t\t\t\tName:        name,\n\t\t\t\tDescription: desc,\n\t\t\t\tParameters:  params,\n\t\t\t},\n\t\t})\n\t}\n\treturn definitions\n}\n\n// List returns a list of all registered tool names.\nfunc (r *ToolRegistry) List() []string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\treturn r.sortedToolNames()\n}\n\n// Clone creates an independent copy of the registry containing the same tool\n// entries (shallow copy of each ToolEntry). This is used to give subagents a\n// snapshot of the parent agent's tools without sharing the same registry —\n// tools registered on the parent after cloning (e.g. spawn, spawn_status)\n// will NOT be visible to the clone, preventing recursive subagent spawning.\n// The version counter is reset to 0 in the clone as it's a new independent registry.\nfunc (r *ToolRegistry) Clone() *ToolRegistry {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\tclone := &ToolRegistry{\n\t\ttools: make(map[string]*ToolEntry, len(r.tools)),\n\t}\n\tfor name, entry := range r.tools {\n\t\tclone.tools[name] = &ToolEntry{\n\t\t\tTool:   entry.Tool,\n\t\t\tIsCore: entry.IsCore,\n\t\t\tTTL:    entry.TTL,\n\t\t}\n\t}\n\treturn clone\n}\n\n// Count returns the number of registered tools.\nfunc (r *ToolRegistry) Count() int {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\treturn len(r.tools)\n}\n\n// GetSummaries returns human-readable summaries of all registered tools.\n// Returns a slice of \"name - description\" strings.\nfunc (r *ToolRegistry) GetSummaries() []string {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tsorted := r.sortedToolNames()\n\tsummaries := make([]string, 0, len(sorted))\n\tfor _, name := range sorted {\n\t\tentry := r.tools[name]\n\n\t\tif !entry.IsCore && entry.TTL <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tsummaries = append(summaries, fmt.Sprintf(\"- `%s` - %s\", entry.Tool.Name(), entry.Tool.Description()))\n\t}\n\treturn summaries\n}\n"
  },
  {
    "path": "pkg/tools/registry_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// --- mock types ---\n\ntype mockRegistryTool struct {\n\tname   string\n\tdesc   string\n\tparams map[string]any\n\tresult *ToolResult\n}\n\nfunc (m *mockRegistryTool) Name() string               { return m.name }\nfunc (m *mockRegistryTool) Description() string        { return m.desc }\nfunc (m *mockRegistryTool) Parameters() map[string]any { return m.params }\nfunc (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {\n\treturn m.result\n}\n\ntype mockContextAwareTool struct {\n\tmockRegistryTool\n\tlastCtx context.Context\n}\n\nfunc (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult {\n\tm.lastCtx = ctx\n\treturn m.result\n}\n\ntype mockAsyncRegistryTool struct {\n\tmockRegistryTool\n\tlastCB AsyncCallback\n}\n\nfunc (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult {\n\tm.lastCB = cb\n\treturn m.result\n}\n\n// --- helpers ---\n\nfunc newMockTool(name, desc string) *mockRegistryTool {\n\treturn &mockRegistryTool{\n\t\tname:   name,\n\t\tdesc:   desc,\n\t\tparams: map[string]any{\"type\": \"object\"},\n\t\tresult: SilentResult(\"ok\"),\n\t}\n}\n\n// --- tests ---\n\nfunc TestNewToolRegistry(t *testing.T) {\n\tr := NewToolRegistry()\n\tif r.Count() != 0 {\n\t\tt.Errorf(\"expected empty registry, got count %d\", r.Count())\n\t}\n\tif len(r.List()) != 0 {\n\t\tt.Errorf(\"expected empty list, got %v\", r.List())\n\t}\n}\n\nfunc TestToolRegistry_RegisterAndGet(t *testing.T) {\n\tr := NewToolRegistry()\n\ttool := newMockTool(\"echo\", \"echoes input\")\n\tr.Register(tool)\n\n\tgot, ok := r.Get(\"echo\")\n\tif !ok {\n\t\tt.Fatal(\"expected to find registered tool\")\n\t}\n\tif got.Name() != \"echo\" {\n\t\tt.Errorf(\"expected name 'echo', got %q\", got.Name())\n\t}\n}\n\nfunc TestToolRegistry_Get_NotFound(t *testing.T) {\n\tr := NewToolRegistry()\n\t_, ok := r.Get(\"nonexistent\")\n\tif ok {\n\t\tt.Error(\"expected ok=false for unregistered tool\")\n\t}\n}\n\nfunc TestToolRegistry_RegisterOverwrite(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(newMockTool(\"dup\", \"first\"))\n\tr.Register(newMockTool(\"dup\", \"second\"))\n\n\tif r.Count() != 1 {\n\t\tt.Errorf(\"expected count 1 after overwrite, got %d\", r.Count())\n\t}\n\ttool, _ := r.Get(\"dup\")\n\tif tool.Description() != \"second\" {\n\t\tt.Errorf(\"expected overwritten description 'second', got %q\", tool.Description())\n\t}\n}\n\nfunc TestToolRegistry_Execute_Success(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(&mockRegistryTool{\n\t\tname:   \"greet\",\n\t\tdesc:   \"says hello\",\n\t\tparams: map[string]any{},\n\t\tresult: SilentResult(\"hello\"),\n\t})\n\n\tresult := r.Execute(context.Background(), \"greet\", nil)\n\tif result.IsError {\n\t\tt.Errorf(\"expected success, got error: %s\", result.ForLLM)\n\t}\n\tif result.ForLLM != \"hello\" {\n\t\tt.Errorf(\"expected ForLLM 'hello', got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestToolRegistry_Execute_NotFound(t *testing.T) {\n\tr := NewToolRegistry()\n\tresult := r.Execute(context.Background(), \"missing\", nil)\n\tif !result.IsError {\n\t\tt.Error(\"expected error for missing tool\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"not found\") {\n\t\tt.Errorf(\"expected 'not found' in error, got %q\", result.ForLLM)\n\t}\n\tif result.Err == nil {\n\t\tt.Error(\"expected Err to be set via WithError\")\n\t}\n}\n\nfunc TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) {\n\tr := NewToolRegistry()\n\tct := &mockContextAwareTool{\n\t\tmockRegistryTool: *newMockTool(\"ctx_tool\", \"needs context\"),\n\t}\n\tr.Register(ct)\n\n\tr.ExecuteWithContext(context.Background(), \"ctx_tool\", nil, \"telegram\", \"chat-42\", nil)\n\n\tif ct.lastCtx == nil {\n\t\tt.Fatal(\"expected Execute to be called\")\n\t}\n\tif got := ToolChannel(ct.lastCtx); got != \"telegram\" {\n\t\tt.Errorf(\"expected channel 'telegram', got %q\", got)\n\t}\n\tif got := ToolChatID(ct.lastCtx); got != \"chat-42\" {\n\t\tt.Errorf(\"expected chatID 'chat-42', got %q\", got)\n\t}\n}\n\nfunc TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {\n\tr := NewToolRegistry()\n\tct := &mockContextAwareTool{\n\t\tmockRegistryTool: *newMockTool(\"ctx_tool\", \"needs context\"),\n\t}\n\tr.Register(ct)\n\n\tr.ExecuteWithContext(context.Background(), \"ctx_tool\", nil, \"\", \"\", nil)\n\n\tif ct.lastCtx == nil {\n\t\tt.Fatal(\"expected Execute to be called\")\n\t}\n\t// Empty values are still injected; tools decide what to do with them.\n\tif got := ToolChannel(ct.lastCtx); got != \"\" {\n\t\tt.Errorf(\"expected empty channel, got %q\", got)\n\t}\n\tif got := ToolChatID(ct.lastCtx); got != \"\" {\n\t\tt.Errorf(\"expected empty chatID, got %q\", got)\n\t}\n}\n\nfunc TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {\n\tr := NewToolRegistry()\n\tat := &mockAsyncRegistryTool{\n\t\tmockRegistryTool: *newMockTool(\"async_tool\", \"async work\"),\n\t}\n\tat.result = AsyncResult(\"started\")\n\tr.Register(at)\n\n\tcalled := false\n\tcb := func(_ context.Context, _ *ToolResult) { called = true }\n\n\tresult := r.ExecuteWithContext(context.Background(), \"async_tool\", nil, \"\", \"\", cb)\n\tif at.lastCB == nil {\n\t\tt.Error(\"expected ExecuteAsync to have received a callback\")\n\t}\n\tif !result.Async {\n\t\tt.Error(\"expected async result\")\n\t}\n\n\tat.lastCB(context.Background(), SilentResult(\"done\"))\n\tif !called {\n\t\tt.Error(\"expected callback to be invoked\")\n\t}\n}\n\nfunc TestToolRegistry_GetDefinitions(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(newMockTool(\"alpha\", \"tool A\"))\n\n\tdefs := r.GetDefinitions()\n\tif len(defs) != 1 {\n\t\tt.Fatalf(\"expected 1 definition, got %d\", len(defs))\n\t}\n\tif defs[0][\"type\"] != \"function\" {\n\t\tt.Errorf(\"expected type 'function', got %v\", defs[0][\"type\"])\n\t}\n\tfn, ok := defs[0][\"function\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"expected 'function' key to be a map\")\n\t}\n\tif fn[\"name\"] != \"alpha\" {\n\t\tt.Errorf(\"expected name 'alpha', got %v\", fn[\"name\"])\n\t}\n\tif fn[\"description\"] != \"tool A\" {\n\t\tt.Errorf(\"expected description 'tool A', got %v\", fn[\"description\"])\n\t}\n}\n\nfunc TestToolRegistry_ToProviderDefs(t *testing.T) {\n\tr := NewToolRegistry()\n\tparams := map[string]any{\"type\": \"object\", \"properties\": map[string]any{}}\n\tr.Register(&mockRegistryTool{\n\t\tname:   \"beta\",\n\t\tdesc:   \"tool B\",\n\t\tparams: params,\n\t\tresult: SilentResult(\"ok\"),\n\t})\n\n\tdefs := r.ToProviderDefs()\n\tif len(defs) != 1 {\n\t\tt.Fatalf(\"expected 1 provider def, got %d\", len(defs))\n\t}\n\n\twant := providers.ToolDefinition{\n\t\tType: \"function\",\n\t\tFunction: providers.ToolFunctionDefinition{\n\t\t\tName:        \"beta\",\n\t\t\tDescription: \"tool B\",\n\t\t\tParameters:  params,\n\t\t},\n\t}\n\tgot := defs[0]\n\tif got.Type != want.Type {\n\t\tt.Errorf(\"Type: want %q, got %q\", want.Type, got.Type)\n\t}\n\tif got.Function.Name != want.Function.Name {\n\t\tt.Errorf(\"Name: want %q, got %q\", want.Function.Name, got.Function.Name)\n\t}\n\tif got.Function.Description != want.Function.Description {\n\t\tt.Errorf(\"Description: want %q, got %q\", want.Function.Description, got.Function.Description)\n\t}\n}\n\nfunc TestToolRegistry_List(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(newMockTool(\"x\", \"\"))\n\tr.Register(newMockTool(\"y\", \"\"))\n\n\tnames := r.List()\n\tif len(names) != 2 {\n\t\tt.Fatalf(\"expected 2 names, got %d\", len(names))\n\t}\n\n\tnameSet := map[string]bool{}\n\tfor _, n := range names {\n\t\tnameSet[n] = true\n\t}\n\tif !nameSet[\"x\"] || !nameSet[\"y\"] {\n\t\tt.Errorf(\"expected names {x, y}, got %v\", names)\n\t}\n}\n\nfunc TestToolRegistry_Count(t *testing.T) {\n\tr := NewToolRegistry()\n\tif r.Count() != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", r.Count())\n\t}\n\n\tr.Register(newMockTool(\"a\", \"\"))\n\tr.Register(newMockTool(\"b\", \"\"))\n\tif r.Count() != 2 {\n\t\tt.Errorf(\"expected 2, got %d\", r.Count())\n\t}\n\n\tr.Register(newMockTool(\"a\", \"replaced\"))\n\tif r.Count() != 2 {\n\t\tt.Errorf(\"expected 2 after overwrite, got %d\", r.Count())\n\t}\n}\n\nfunc TestToolRegistry_GetSummaries(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(newMockTool(\"read_file\", \"Reads a file\"))\n\n\tsummaries := r.GetSummaries()\n\tif len(summaries) != 1 {\n\t\tt.Fatalf(\"expected 1 summary, got %d\", len(summaries))\n\t}\n\tif !strings.Contains(summaries[0], \"`read_file`\") {\n\t\tt.Errorf(\"expected backtick-quoted name in summary, got %q\", summaries[0])\n\t}\n\tif !strings.Contains(summaries[0], \"Reads a file\") {\n\t\tt.Errorf(\"expected description in summary, got %q\", summaries[0])\n\t}\n}\n\nfunc TestToolToSchema(t *testing.T) {\n\ttool := newMockTool(\"demo\", \"demo tool\")\n\tschema := ToolToSchema(tool)\n\n\tif schema[\"type\"] != \"function\" {\n\t\tt.Errorf(\"expected type 'function', got %v\", schema[\"type\"])\n\t}\n\tfn, ok := schema[\"function\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"expected 'function' to be a map\")\n\t}\n\tif fn[\"name\"] != \"demo\" {\n\t\tt.Errorf(\"expected name 'demo', got %v\", fn[\"name\"])\n\t}\n\tif fn[\"description\"] != \"demo tool\" {\n\t\tt.Errorf(\"expected description 'demo tool', got %v\", fn[\"description\"])\n\t}\n\tif fn[\"parameters\"] == nil {\n\t\tt.Error(\"expected parameters to be set\")\n\t}\n}\n\nfunc TestToolRegistry_Clone(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(newMockTool(\"read_file\", \"reads files\"))\n\tr.Register(newMockTool(\"exec\", \"runs commands\"))\n\tr.Register(newMockTool(\"web_search\", \"searches the web\"))\n\n\tclone := r.Clone()\n\n\t// Clone should have the same tools\n\tif clone.Count() != 3 {\n\t\tt.Errorf(\"expected clone to have 3 tools, got %d\", clone.Count())\n\t}\n\tfor _, name := range []string{\"read_file\", \"exec\", \"web_search\"} {\n\t\tif _, ok := clone.Get(name); !ok {\n\t\t\tt.Errorf(\"expected clone to have tool %q\", name)\n\t\t}\n\t}\n\n\t// Registering on parent should NOT affect clone\n\tr.Register(newMockTool(\"spawn\", \"spawns subagent\"))\n\tif r.Count() != 4 {\n\t\tt.Errorf(\"expected parent to have 4 tools, got %d\", r.Count())\n\t}\n\tif clone.Count() != 3 {\n\t\tt.Errorf(\"expected clone to still have 3 tools after parent mutation, got %d\", clone.Count())\n\t}\n\tif _, ok := clone.Get(\"spawn\"); ok {\n\t\tt.Error(\"expected clone NOT to have 'spawn' tool registered on parent after cloning\")\n\t}\n\n\t// Registering on clone should NOT affect parent\n\tclone.Register(newMockTool(\"custom\", \"custom tool\"))\n\tif clone.Count() != 4 {\n\t\tt.Errorf(\"expected clone to have 4 tools, got %d\", clone.Count())\n\t}\n\tif _, ok := r.Get(\"custom\"); ok {\n\t\tt.Error(\"expected parent NOT to have 'custom' tool registered on clone\")\n\t}\n}\n\nfunc TestToolRegistry_Clone_Empty(t *testing.T) {\n\tr := NewToolRegistry()\n\tclone := r.Clone()\n\tif clone.Count() != 0 {\n\t\tt.Errorf(\"expected empty clone, got count %d\", clone.Count())\n\t}\n}\n\nfunc TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.RegisterHidden(newMockTool(\"mcp_tool\", \"dynamic MCP tool\"))\n\n\tclone := r.Clone()\n\n\t// Hidden tools with TTL=0 should not be gettable (same behavior as parent)\n\tif _, ok := clone.Get(\"mcp_tool\"); ok {\n\t\tt.Error(\"expected hidden tool with TTL=0 to be invisible in clone\")\n\t}\n\n\t// But the entry should exist (count includes hidden tools)\n\tif clone.Count() != 1 {\n\t\tt.Errorf(\"expected clone count 1 (hidden entry exists), got %d\", clone.Count())\n\t}\n}\n\nfunc TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.RegisterHidden(newMockTool(\"ttl_tool\", \"tool with TTL\"))\n\n\t// Manually set a non-zero TTL on the entry\n\tr.mu.RLock()\n\tif entry, ok := r.tools[\"ttl_tool\"]; ok {\n\t\tentry.TTL = 5\n\t}\n\tr.mu.RUnlock()\n\n\tclone := r.Clone()\n\n\t// Verify TTL value is preserved in the clone\n\tclone.mu.RLock()\n\tdefer clone.mu.RUnlock()\n\tentry, ok := clone.tools[\"ttl_tool\"]\n\tif !ok {\n\t\tt.Fatal(\"expected ttl_tool to exist in clone\")\n\t}\n\tif entry.TTL != 5 {\n\t\tt.Errorf(\"expected TTL=5 in clone, got %d\", entry.TTL)\n\t}\n}\n\nfunc TestToolRegistry_ConcurrentAccess(t *testing.T) {\n\tr := NewToolRegistry()\n\tvar wg sync.WaitGroup\n\n\tfor i := range 50 {\n\t\twg.Add(1)\n\t\tgo func(n int) {\n\t\t\tdefer wg.Done()\n\t\t\tname := string(rune('A' + n%26))\n\t\t\tr.Register(newMockTool(name, \"concurrent\"))\n\t\t\tr.Get(name)\n\t\t\tr.Count()\n\t\t\tr.List()\n\t\t\tr.GetDefinitions()\n\t\t}(i)\n\t}\n\n\twg.Wait()\n\n\tif r.Count() == 0 {\n\t\tt.Error(\"expected tools to be registered after concurrent access\")\n\t}\n}\n\n// --- Panic and abnormal exit tests ---\n\n// mockPanicTool is a tool that panics during execution\ntype mockPanicTool struct {\n\tname       string\n\tpanicValue any\n}\n\nfunc (m *mockPanicTool) Name() string               { return m.name }\nfunc (m *mockPanicTool) Description() string        { return \"a tool that panics\" }\nfunc (m *mockPanicTool) Parameters() map[string]any { return map[string]any{\"type\": \"object\"} }\nfunc (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult {\n\tpanic(m.panicValue)\n}\n\n// mockNilResultTool is a tool that returns nil\ntype mockNilResultTool struct {\n\tname string\n}\n\nfunc (m *mockNilResultTool) Name() string               { return m.name }\nfunc (m *mockNilResultTool) Description() string        { return \"a tool that returns nil\" }\nfunc (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{\"type\": \"object\"} }\nfunc (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult {\n\treturn nil\n}\n\nfunc TestToolRegistry_Execute_PanicRecovery(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(&mockPanicTool{\n\t\tname:       \"panic_tool\",\n\t\tpanicValue: \"something went terribly wrong\",\n\t})\n\n\t// Should not panic, should return error result\n\tresult := r.Execute(context.Background(), \"panic_tool\", nil)\n\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil result after panic recovery\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"expected IsError=true after panic\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"panic\") {\n\t\tt.Errorf(\"expected 'panic' in error message, got %q\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"panic_tool\") {\n\t\tt.Errorf(\"expected tool name in error message, got %q\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"something went terribly wrong\") {\n\t\tt.Errorf(\"expected panic value in error message, got %q\", result.ForLLM)\n\t}\n\tif result.Err == nil {\n\t\tt.Error(\"expected Err to be set\")\n\t}\n}\n\nfunc TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) {\n\tr := NewToolRegistry()\n\n\t// Test with error type panic\n\tr.Register(&mockPanicTool{\n\t\tname:       \"error_panic_tool\",\n\t\tpanicValue: errors.New(\"custom error panic\"),\n\t})\n\n\tresult := r.Execute(context.Background(), \"error_panic_tool\", nil)\n\n\tif !result.IsError {\n\t\tt.Error(\"expected IsError=true\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"custom error panic\") {\n\t\tt.Errorf(\"expected error message in ForLLM, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) {\n\tr := NewToolRegistry()\n\n\t// Test with int type panic\n\tr.Register(&mockPanicTool{\n\t\tname:       \"int_panic_tool\",\n\t\tpanicValue: 42,\n\t})\n\n\tresult := r.Execute(context.Background(), \"int_panic_tool\", nil)\n\n\tif !result.IsError {\n\t\tt.Error(\"expected IsError=true\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"42\") {\n\t\tt.Errorf(\"expected panic value '42' in ForLLM, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestToolRegistry_Execute_NilResultHandling(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(&mockNilResultTool{name: \"nil_tool\"})\n\n\tresult := r.Execute(context.Background(), \"nil_tool\", nil)\n\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil result when tool returns nil\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"expected IsError=true for nil result\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"nil_tool\") {\n\t\tt.Errorf(\"expected tool name in error message, got %q\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"nil result\") {\n\t\tt.Errorf(\"expected 'nil result' in error message, got %q\", result.ForLLM)\n\t}\n\tif result.Err == nil {\n\t\tt.Error(\"expected Err to be set\")\n\t}\n}\n\nfunc TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(&mockPanicTool{\n\t\tname:       \"ctx_panic_tool\",\n\t\tpanicValue: \"context panic test\",\n\t})\n\n\t// Should not panic even with context\n\tresult := r.ExecuteWithContext(\n\t\tcontext.Background(),\n\t\t\"ctx_panic_tool\",\n\t\tmap[string]any{\"key\": \"value\"},\n\t\t\"telegram\",\n\t\t\"chat-123\",\n\t\tnil,\n\t)\n\n\tif result == nil {\n\t\tt.Fatal(\"expected non-nil result\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"expected IsError=true\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"context panic test\") {\n\t\tt.Errorf(\"expected panic message, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) {\n\tr := NewToolRegistry()\n\tr.Register(&mockPanicTool{name: \"bad_tool\", panicValue: \"boom\"})\n\tr.Register(&mockRegistryTool{\n\t\tname:   \"good_tool\",\n\t\tdesc:   \"works fine\",\n\t\tparams: map[string]any{},\n\t\tresult: SilentResult(\"success\"),\n\t})\n\n\t// First, trigger the panic\n\tresult1 := r.Execute(context.Background(), \"bad_tool\", nil)\n\tif !result1.IsError {\n\t\tt.Error(\"expected error from panic tool\")\n\t}\n\n\t// Then, verify the good tool still works\n\tresult2 := r.Execute(context.Background(), \"good_tool\", nil)\n\tif result2.IsError {\n\t\tt.Errorf(\"expected success from good tool, got error: %s\", result2.ForLLM)\n\t}\n\tif result2.ForLLM != \"success\" {\n\t\tt.Errorf(\"expected 'success', got %q\", result2.ForLLM)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/result.go",
    "content": "package tools\n\nimport \"encoding/json\"\n\n// ToolResult represents the structured return value from tool execution.\n// It provides clear semantics for different types of results and supports\n// async operations, user-facing messages, and error handling.\ntype ToolResult struct {\n\t// ForLLM is the content sent to the LLM for context.\n\t// Required for all results.\n\tForLLM string `json:\"for_llm\"`\n\n\t// ForUser is the content sent directly to the user.\n\t// If empty, no user message is sent.\n\t// Silent=true overrides this field.\n\tForUser string `json:\"for_user,omitempty\"`\n\n\t// Silent suppresses sending any message to the user.\n\t// When true, ForUser is ignored even if set.\n\tSilent bool `json:\"silent\"`\n\n\t// IsError indicates whether the tool execution failed.\n\t// When true, the result should be treated as an error.\n\tIsError bool `json:\"is_error\"`\n\n\t// Async indicates whether the tool is running asynchronously.\n\t// When true, the tool will complete later and notify via callback.\n\tAsync bool `json:\"async\"`\n\n\t// Err is the underlying error (not JSON serialized).\n\t// Used for internal error handling and logging.\n\tErr error `json:\"-\"`\n\n\t// Media contains media store refs produced by this tool.\n\t// When non-empty, the agent will publish these as OutboundMediaMessage.\n\tMedia []string `json:\"media,omitempty\"`\n}\n\n// NewToolResult creates a basic ToolResult with content for the LLM.\n// Use this when you need a simple result with default behavior.\n//\n// Example:\n//\n//\tresult := NewToolResult(\"File updated successfully\")\nfunc NewToolResult(forLLM string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM: forLLM,\n\t}\n}\n\n// SilentResult creates a ToolResult that is silent (no user message).\n// The content is only sent to the LLM for context.\n//\n// Use this for operations that should not spam the user, such as:\n// - File reads/writes\n// - Status updates\n// - Background operations\n//\n// Example:\n//\n//\tresult := SilentResult(\"Config file saved\")\nfunc SilentResult(forLLM string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM:  forLLM,\n\t\tSilent:  true,\n\t\tIsError: false,\n\t\tAsync:   false,\n\t}\n}\n\n// AsyncResult creates a ToolResult for async operations.\n// The task will run in the background and complete later.\n//\n// Use this for long-running operations like:\n// - Subagent spawns\n// - Background processing\n// - External API calls with callbacks\n//\n// Example:\n//\n//\tresult := AsyncResult(\"Subagent spawned, will report back\")\nfunc AsyncResult(forLLM string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM:  forLLM,\n\t\tSilent:  false,\n\t\tIsError: false,\n\t\tAsync:   true,\n\t}\n}\n\n// ErrorResult creates a ToolResult representing an error.\n// Sets IsError=true and includes the error message.\n//\n// Example:\n//\n//\tresult := ErrorResult(\"Failed to connect to database: connection refused\")\nfunc ErrorResult(message string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM:  message,\n\t\tSilent:  false,\n\t\tIsError: true,\n\t\tAsync:   false,\n\t}\n}\n\n// UserResult creates a ToolResult with content for both LLM and user.\n// Both ForLLM and ForUser are set to the same content.\n//\n// Use this when the user needs to see the result directly:\n// - Command execution output\n// - Fetched web content\n// - Query results\n//\n// Example:\n//\n//\tresult := UserResult(\"Total files found: 42\")\nfunc UserResult(content string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM:  content,\n\t\tForUser: content,\n\t\tSilent:  false,\n\t\tIsError: false,\n\t\tAsync:   false,\n\t}\n}\n\n// MediaResult creates a ToolResult with media refs for the user.\n// The agent will publish these refs as OutboundMediaMessage.\n//\n// Example:\n//\n//\tresult := MediaResult(\"Image generated successfully\", []string{\"media://abc123\"})\nfunc MediaResult(forLLM string, mediaRefs []string) *ToolResult {\n\treturn &ToolResult{\n\t\tForLLM: forLLM,\n\t\tMedia:  mediaRefs,\n\t}\n}\n\n// MarshalJSON implements custom JSON serialization.\n// The Err field is excluded from JSON output via the json:\"-\" tag.\nfunc (tr *ToolResult) MarshalJSON() ([]byte, error) {\n\ttype Alias ToolResult\n\treturn json.Marshal(&struct {\n\t\t*Alias\n\t}{\n\t\tAlias: (*Alias)(tr),\n\t})\n}\n\n// WithError sets the Err field and returns the result for chaining.\n// This preserves the error for logging while keeping it out of JSON.\n//\n// Example:\n//\n//\tresult := ErrorResult(\"Operation failed\").WithError(err)\nfunc (tr *ToolResult) WithError(err error) *ToolResult {\n\ttr.Err = err\n\treturn tr\n}\n"
  },
  {
    "path": "pkg/tools/result_test.go",
    "content": "package tools\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestNewToolResult(t *testing.T) {\n\tresult := NewToolResult(\"test content\")\n\n\tif result.ForLLM != \"test content\" {\n\t\tt.Errorf(\"Expected ForLLM 'test content', got '%s'\", result.ForLLM)\n\t}\n\tif result.Silent {\n\t\tt.Error(\"Expected Silent to be false\")\n\t}\n\tif result.IsError {\n\t\tt.Error(\"Expected IsError to be false\")\n\t}\n\tif result.Async {\n\t\tt.Error(\"Expected Async to be false\")\n\t}\n}\n\nfunc TestSilentResult(t *testing.T) {\n\tresult := SilentResult(\"silent operation\")\n\n\tif result.ForLLM != \"silent operation\" {\n\t\tt.Errorf(\"Expected ForLLM 'silent operation', got '%s'\", result.ForLLM)\n\t}\n\tif !result.Silent {\n\t\tt.Error(\"Expected Silent to be true\")\n\t}\n\tif result.IsError {\n\t\tt.Error(\"Expected IsError to be false\")\n\t}\n\tif result.Async {\n\t\tt.Error(\"Expected Async to be false\")\n\t}\n}\n\nfunc TestAsyncResult(t *testing.T) {\n\tresult := AsyncResult(\"async task started\")\n\n\tif result.ForLLM != \"async task started\" {\n\t\tt.Errorf(\"Expected ForLLM 'async task started', got '%s'\", result.ForLLM)\n\t}\n\tif result.Silent {\n\t\tt.Error(\"Expected Silent to be false\")\n\t}\n\tif result.IsError {\n\t\tt.Error(\"Expected IsError to be false\")\n\t}\n\tif !result.Async {\n\t\tt.Error(\"Expected Async to be true\")\n\t}\n}\n\nfunc TestErrorResult(t *testing.T) {\n\tresult := ErrorResult(\"operation failed\")\n\n\tif result.ForLLM != \"operation failed\" {\n\t\tt.Errorf(\"Expected ForLLM 'operation failed', got '%s'\", result.ForLLM)\n\t}\n\tif result.Silent {\n\t\tt.Error(\"Expected Silent to be false\")\n\t}\n\tif !result.IsError {\n\t\tt.Error(\"Expected IsError to be true\")\n\t}\n\tif result.Async {\n\t\tt.Error(\"Expected Async to be false\")\n\t}\n}\n\nfunc TestUserResult(t *testing.T) {\n\tcontent := \"user visible message\"\n\tresult := UserResult(content)\n\n\tif result.ForLLM != content {\n\t\tt.Errorf(\"Expected ForLLM '%s', got '%s'\", content, result.ForLLM)\n\t}\n\tif result.ForUser != content {\n\t\tt.Errorf(\"Expected ForUser '%s', got '%s'\", content, result.ForUser)\n\t}\n\tif result.Silent {\n\t\tt.Error(\"Expected Silent to be false\")\n\t}\n\tif result.IsError {\n\t\tt.Error(\"Expected IsError to be false\")\n\t}\n\tif result.Async {\n\t\tt.Error(\"Expected Async to be false\")\n\t}\n}\n\nfunc TestToolResultJSONSerialization(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tresult *ToolResult\n\t}{\n\t\t{\n\t\t\tname:   \"basic result\",\n\t\t\tresult: NewToolResult(\"basic content\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"silent result\",\n\t\t\tresult: SilentResult(\"silent content\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"async result\",\n\t\t\tresult: AsyncResult(\"async content\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"error result\",\n\t\t\tresult: ErrorResult(\"error content\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"user result\",\n\t\t\tresult: UserResult(\"user content\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Marshal to JSON\n\t\t\tdata, err := json.Marshal(tt.result)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to marshal: %v\", err)\n\t\t\t}\n\n\t\t\t// Unmarshal back\n\t\t\tvar decoded ToolResult\n\t\t\tif err := json.Unmarshal(data, &decoded); err != nil {\n\t\t\t\tt.Fatalf(\"Failed to unmarshal: %v\", err)\n\t\t\t}\n\n\t\t\t// Verify fields match (Err should be excluded)\n\t\t\tif decoded.ForLLM != tt.result.ForLLM {\n\t\t\t\tt.Errorf(\"ForLLM mismatch: got '%s', want '%s'\", decoded.ForLLM, tt.result.ForLLM)\n\t\t\t}\n\t\t\tif decoded.ForUser != tt.result.ForUser {\n\t\t\t\tt.Errorf(\"ForUser mismatch: got '%s', want '%s'\", decoded.ForUser, tt.result.ForUser)\n\t\t\t}\n\t\t\tif decoded.Silent != tt.result.Silent {\n\t\t\t\tt.Errorf(\"Silent mismatch: got %v, want %v\", decoded.Silent, tt.result.Silent)\n\t\t\t}\n\t\t\tif decoded.IsError != tt.result.IsError {\n\t\t\t\tt.Errorf(\"IsError mismatch: got %v, want %v\", decoded.IsError, tt.result.IsError)\n\t\t\t}\n\t\t\tif decoded.Async != tt.result.Async {\n\t\t\t\tt.Errorf(\"Async mismatch: got %v, want %v\", decoded.Async, tt.result.Async)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestToolResultWithErrors(t *testing.T) {\n\terr := errors.New(\"underlying error\")\n\tresult := ErrorResult(\"error message\").WithError(err)\n\n\tif result.Err == nil {\n\t\tt.Error(\"Expected Err to be set\")\n\t}\n\tif result.Err.Error() != \"underlying error\" {\n\t\tt.Errorf(\"Expected Err message 'underlying error', got '%s'\", result.Err.Error())\n\t}\n\n\t// Verify Err is not serialized\n\tdata, marshalErr := json.Marshal(result)\n\tif marshalErr != nil {\n\t\tt.Fatalf(\"Failed to marshal: %v\", marshalErr)\n\t}\n\n\tvar decoded ToolResult\n\tif unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil {\n\t\tt.Fatalf(\"Failed to unmarshal: %v\", unmarshalErr)\n\t}\n\n\tif decoded.Err != nil {\n\t\tt.Error(\"Expected Err to be nil after JSON round-trip (should not be serialized)\")\n\t}\n}\n\nfunc TestToolResultJSONStructure(t *testing.T) {\n\tresult := UserResult(\"test content\")\n\n\tdata, err := json.Marshal(result)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to marshal: %v\", err)\n\t}\n\n\t// Verify JSON structure\n\tvar parsed map[string]any\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"Failed to parse JSON: %v\", err)\n\t}\n\n\t// Check expected keys exist\n\tif _, ok := parsed[\"for_llm\"]; !ok {\n\t\tt.Error(\"Expected 'for_llm' key in JSON\")\n\t}\n\tif _, ok := parsed[\"for_user\"]; !ok {\n\t\tt.Error(\"Expected 'for_user' key in JSON\")\n\t}\n\tif _, ok := parsed[\"silent\"]; !ok {\n\t\tt.Error(\"Expected 'silent' key in JSON\")\n\t}\n\tif _, ok := parsed[\"is_error\"]; !ok {\n\t\tt.Error(\"Expected 'is_error' key in JSON\")\n\t}\n\tif _, ok := parsed[\"async\"]; !ok {\n\t\tt.Error(\"Expected 'async' key in JSON\")\n\t}\n\n\t// Check that 'err' is NOT present (it should have json:\"-\" tag)\n\tif _, ok := parsed[\"err\"]; ok {\n\t\tt.Error(\"Expected 'err' key to be excluded from JSON\")\n\t}\n\n\t// Verify values\n\tif parsed[\"for_llm\"] != \"test content\" {\n\t\tt.Errorf(\"Expected for_llm 'test content', got %v\", parsed[\"for_llm\"])\n\t}\n\tif parsed[\"silent\"] != false {\n\t\tt.Errorf(\"Expected silent false, got %v\", parsed[\"silent\"])\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/search_tool.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tMaxRegexPatternLength = 200\n)\n\ntype RegexSearchTool struct {\n\tregistry         *ToolRegistry\n\tttl              int\n\tmaxSearchResults int\n}\n\nfunc NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool {\n\treturn &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults}\n}\n\nfunc (t *RegexSearchTool) Name() string {\n\treturn \"tool_search_tool_regex\"\n}\n\nfunc (t *RegexSearchTool) Description() string {\n\treturn \"Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools.\"\n}\n\nfunc (t *RegexSearchTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"pattern\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Regex pattern to match tool name or description\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"pattern\"},\n\t}\n}\n\nfunc (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpattern, ok := args[\"pattern\"].(string)\n\tif !ok || strings.TrimSpace(pattern) == \"\" {\n\t\t// An empty string regex (?i) will match every hidden tool,\n\t\t// dumping massive payloads into the context and burning tokens.\n\t\treturn ErrorResult(\"Missing or invalid 'pattern' argument. Must be a non-empty string.\")\n\t}\n\n\tif len(pattern) > MaxRegexPatternLength {\n\t\tlogger.WarnCF(\"discovery\", \"Regex pattern rejected (too long)\", map[string]any{\"len\": len(pattern)})\n\t\treturn ErrorResult(fmt.Sprintf(\"Pattern too long: max %d characters allowed\", MaxRegexPatternLength))\n\t}\n\n\tlogger.DebugCF(\"discovery\", \"Regex search\", map[string]any{\"pattern\": pattern})\n\n\tres, err := t.registry.SearchRegex(pattern, t.maxSearchResults)\n\tif err != nil {\n\t\tlogger.WarnCF(\"discovery\", \"Invalid regex pattern\", map[string]any{\"pattern\": pattern, \"error\": err.Error()})\n\t\treturn ErrorResult(fmt.Sprintf(\"Invalid regex pattern syntax: %v. Please fix your regex and try again.\", err))\n\t}\n\n\tlogger.InfoCF(\"discovery\", \"Regex search completed\", map[string]any{\"pattern\": pattern, \"results\": len(res)})\n\treturn formatDiscoveryResponse(t.registry, res, t.ttl)\n}\n\ntype BM25SearchTool struct {\n\tregistry         *ToolRegistry\n\tttl              int\n\tmaxSearchResults int\n\n\t// Cache: rebuilt only when the registry version changes.\n\tcacheMu      sync.Mutex\n\tcachedEngine *bm25CachedEngine\n\tcacheVersion uint64\n}\n\nfunc NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool {\n\treturn &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults}\n}\n\nfunc (t *BM25SearchTool) Name() string {\n\treturn \"tool_search_tool_bm25\"\n}\n\nfunc (t *BM25SearchTool) Description() string {\n\treturn \"Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools.\"\n}\n\nfunc (t *BM25SearchTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"query\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Search query\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"query\"},\n\t}\n}\n\nfunc (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tquery, ok := args[\"query\"].(string)\n\tif !ok || strings.TrimSpace(query) == \"\" {\n\t\t// An empty string query will match every hidden tool,\n\t\t// dumping massive payloads into the context and burning tokens.\n\t\treturn ErrorResult(\"Missing or invalid 'query' argument. Must be a non-empty string.\")\n\t}\n\n\tlogger.DebugCF(\"discovery\", \"BM25 search\", map[string]any{\"query\": query})\n\n\tcached := t.getOrBuildEngine()\n\tif cached == nil {\n\t\tlogger.DebugCF(\"discovery\", \"BM25 search: no hidden tools available\", nil)\n\t\treturn SilentResult(\"No tools found matching the query.\")\n\t}\n\n\tranked := cached.engine.Search(query, t.maxSearchResults)\n\tif len(ranked) == 0 {\n\t\tlogger.DebugCF(\"discovery\", \"BM25 search: no matches\", map[string]any{\"query\": query})\n\t\treturn SilentResult(\"No tools found matching the query.\")\n\t}\n\n\tresults := make([]ToolSearchResult, len(ranked))\n\tfor i, r := range ranked {\n\t\tresults[i] = ToolSearchResult{\n\t\t\tName:        r.Document.Name,\n\t\t\tDescription: r.Document.Description,\n\t\t}\n\t}\n\n\tlogger.InfoCF(\"discovery\", \"BM25 search completed\", map[string]any{\"query\": query, \"results\": len(results)})\n\treturn formatDiscoveryResponse(t.registry, results, t.ttl)\n}\n\n// ToolSearchResult represents the result returned to the LLM.\n// Parameters are omitted from the JSON response to save context tokens;\n// the LLM will see full schemas via ToProviderDefs after promotion.\ntype ToolSearchResult struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\nfunc (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) {\n\tif maxSearchResults <= 0 {\n\t\treturn nil, nil\n\t}\n\n\tregex, err := regexp.Compile(\"(?i)\" + pattern)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to compile regex pattern %q: %w\", pattern, err)\n\t}\n\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tvar results []ToolSearchResult\n\n\t// Iterate in sorted order for deterministic results across calls.\n\tfor _, name := range r.sortedToolNames() {\n\t\tentry := r.tools[name]\n\t\t// Search only among the hidden tools (Core tools are already visible)\n\t\tif !entry.IsCore {\n\t\t\t// Directly call interface methods! No reflection/unmarshalling needed.\n\t\t\tdesc := entry.Tool.Description()\n\n\t\t\tif regex.MatchString(name) || regex.MatchString(desc) {\n\t\t\t\tresults = append(results, ToolSearchResult{\n\t\t\t\t\tName:        name,\n\t\t\t\t\tDescription: desc,\n\t\t\t\t})\n\t\t\t\tif len(results) >= maxSearchResults {\n\t\t\t\t\tbreak // Stop searching once we hit the max! Saves CPU.\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn results, nil\n}\n\nfunc formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult {\n\tif len(results) == 0 {\n\t\treturn SilentResult(\"No tools found matching the query.\")\n\t}\n\n\tnames := make([]string, len(results))\n\tfor i, r := range results {\n\t\tnames[i] = r.Name\n\t}\n\tregistry.PromoteTools(names, ttl)\n\tlogger.InfoCF(\"discovery\", \"Promoted tools\", map[string]any{\"tools\": names, \"ttl\": ttl})\n\n\tb, err := json.Marshal(results)\n\tif err != nil {\n\t\treturn ErrorResult(\"Failed to format search results: \" + err.Error())\n\t}\n\n\tmsg := fmt.Sprintf(\n\t\t\"Found %d tools:\\n%s\\n\\nSUCCESS: These tools have been temporarily UNLOCKED as native tools! In your next response, you can call them directly just like any normal tool\",\n\t\tlen(results),\n\t\tstring(b),\n\t)\n\n\treturn SilentResult(msg)\n}\n\n// Lightweight internal type used as corpus document for BM25.\ntype searchDoc struct {\n\tName        string\n\tDescription string\n}\n\n// bm25CachedEngine wraps a BM25Engine with its corpus snapshot.\ntype bm25CachedEngine struct {\n\tengine *utils.BM25Engine[searchDoc]\n}\n\n// snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice.\nfunc snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc {\n\tdocs := make([]searchDoc, len(snap.Docs))\n\tfor i, d := range snap.Docs {\n\t\tdocs[i] = searchDoc{Name: d.Name, Description: d.Description}\n\t}\n\treturn docs\n}\n\n// buildBM25Engine creates a BM25Engine from a slice of searchDocs.\nfunc buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] {\n\treturn utils.NewBM25Engine(\n\t\tdocs,\n\t\tfunc(doc searchDoc) string {\n\t\t\treturn doc.Name + \" \" + doc.Description\n\t\t},\n\t)\n}\n\n// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when\n// the registry version has changed (new tools registered).\nfunc (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine {\n\t// Fast path: optimistic check without locking.\n\tif t.cachedEngine != nil && t.cacheVersion == t.registry.Version() {\n\t\treturn t.cachedEngine\n\t}\n\n\tt.cacheMu.Lock()\n\tdefer t.cacheMu.Unlock()\n\n\t// Snapshot + version are read under a single registry RLock,\n\t// guaranteeing consistency (no TOCTOU).\n\tsnap := t.registry.SnapshotHiddenTools()\n\n\t// Re-check: another goroutine may have rebuilt while we waited for cacheMu.\n\tif t.cachedEngine != nil && t.cacheVersion == snap.Version {\n\t\treturn t.cachedEngine\n\t}\n\n\tdocs := snapshotToSearchDocs(snap)\n\tif len(docs) == 0 {\n\t\tt.cachedEngine = nil\n\t\tt.cacheVersion = snap.Version\n\t\treturn nil\n\t}\n\n\tcached := &bm25CachedEngine{engine: buildBM25Engine(docs)}\n\tt.cachedEngine = cached\n\tt.cacheVersion = snap.Version\n\tlogger.DebugCF(\"discovery\", \"BM25 engine rebuilt\", map[string]any{\"docs\": len(docs), \"version\": snap.Version})\n\treturn cached\n}\n\n// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine.\n// This non-cached variant rebuilds the engine on every call. Used by tests\n// and any code that doesn't hold a BM25SearchTool instance.\nfunc (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult {\n\tsnap := r.SnapshotHiddenTools()\n\tdocs := snapshotToSearchDocs(snap)\n\tif len(docs) == 0 {\n\t\treturn nil\n\t}\n\n\tranked := buildBM25Engine(docs).Search(query, maxSearchResults)\n\tif len(ranked) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]ToolSearchResult, len(ranked))\n\tfor i, r := range ranked {\n\t\tout[i] = ToolSearchResult{\n\t\t\tName:        r.Document.Name,\n\t\t\tDescription: r.Document.Description,\n\t\t}\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "pkg/tools/search_tools_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// Dummy tool to fill the registry in our tests.\ntype mockSearchableTool struct {\n\tname string\n\tdesc string\n}\n\nfunc (m *mockSearchableTool) Name() string        { return m.name }\nfunc (m *mockSearchableTool) Description() string { return m.desc }\nfunc (m *mockSearchableTool) Parameters() map[string]any {\n\treturn map[string]any{\"type\": \"object\"}\n}\n\nfunc (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\treturn SilentResult(\"mock executed: \" + m.name)\n}\n\n// Helper to initialize a populated ToolRegistry\nfunc setupPopulatedRegistry() *ToolRegistry {\n\treg := NewToolRegistry()\n\n\t// A core tool (NOT to be found by searches)\n\treg.Register(&mockSearchableTool{\n\t\tname: \"core_search\",\n\t\tdesc: \"I am a visible core tool for searching files\",\n\t})\n\n\t// Hidden tools (must be found by searches)\n\treg.RegisterHidden(&mockSearchableTool{\n\t\tname: \"mcp_read_file\",\n\t\tdesc: \"Read the contents of a system file\",\n\t})\n\treg.RegisterHidden(&mockSearchableTool{\n\t\tname: \"mcp_list_dir\",\n\t\tdesc: \"List directories and files in the system\",\n\t})\n\treg.RegisterHidden(&mockSearchableTool{\n\t\tname: \"mcp_fetch_net\",\n\t\tdesc: \"Fetch data from a network database\",\n\t})\n\n\treturn reg\n}\n\nfunc TestRegexSearchTool_Execute(t *testing.T) {\n\treg := setupPopulatedRegistry()\n\ttool := NewRegexSearchTool(reg, 5, 10)\n\tctx := context.Background()\n\n\tt.Run(\"Empty Pattern Error\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{})\n\t\tif !res.IsError || !strings.Contains(res.ForLLM, \"Missing or invalid 'pattern'\") {\n\t\t\tt.Errorf(\"Expected missing pattern error, got: %v\", res.ForLLM)\n\t\t}\n\t})\n\n\tt.Run(\"Invalid Regex Syntax\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"pattern\": \"[unclosed\"})\n\t\tif !res.IsError || !strings.Contains(res.ForLLM, \"Invalid regex pattern syntax\") {\n\t\t\tt.Errorf(\"Expected regex syntax error, got: %v\", res.ForLLM)\n\t\t}\n\t})\n\n\tt.Run(\"No Match Found\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"pattern\": \"alien\"})\n\t\tif res.IsError || !strings.Contains(res.ForLLM, \"No tools found matching\") {\n\t\t\tt.Errorf(\"Expected 'no tools found' message, got: %v\", res.ForLLM)\n\t\t}\n\t})\n\n\tt.Run(\"Successful Match & Promotion\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"pattern\": \"system\"})\n\n\t\tif res.IsError {\n\t\t\tt.Fatalf(\"Unexpected error: %v\", res.ForLLM)\n\t\t}\n\t\tif !strings.Contains(res.ForLLM, \"SUCCESS: These tools have been temporarily UNLOCKED\") {\n\t\t\tt.Errorf(\"Expected success string, got: %v\", res.ForLLM)\n\t\t}\n\t\tif !strings.Contains(res.ForLLM, \"mcp_read_file\") {\n\t\t\tt.Errorf(\"Expected 'mcp_read_file' in results\")\n\t\t}\n\n\t\t// Verify that the TTL has been updated for the tools found\n\t\treg.mu.RLock()\n\t\tdefer reg.mu.RUnlock()\n\t\tif reg.tools[\"mcp_read_file\"].TTL != 5 {\n\t\t\tt.Errorf(\"Expected TTL of 'mcp_read_file' to be promoted to 5, got %d\", reg.tools[\"mcp_read_file\"].TTL)\n\t\t}\n\t\tif reg.tools[\"mcp_fetch_net\"].TTL != 0 {\n\t\t\tt.Errorf(\"Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)\")\n\t\t}\n\t})\n}\n\nfunc TestBM25SearchTool_Execute(t *testing.T) {\n\treg := setupPopulatedRegistry()\n\ttool := NewBM25SearchTool(reg, 3, 10)\n\tctx := context.Background()\n\n\tt.Run(\"Empty Query Error\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"query\": \"   \"})\n\t\tif !res.IsError || !strings.Contains(res.ForLLM, \"Missing or invalid 'query'\") {\n\t\t\tt.Errorf(\"Expected missing query error, got: %v\", res.ForLLM)\n\t\t}\n\t})\n\n\tt.Run(\"No Match Found\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"query\": \"aliens spaceships\"})\n\t\tif res.IsError || !strings.Contains(res.ForLLM, \"No tools found matching\") {\n\t\t\tt.Errorf(\"Expected 'no tools found', got: %v\", res.ForLLM)\n\t\t}\n\t})\n\n\tt.Run(\"Successful Match & Promotion\", func(t *testing.T) {\n\t\tres := tool.Execute(ctx, map[string]any{\"query\": \"read files\"})\n\n\t\tif res.IsError {\n\t\t\tt.Fatalf(\"Unexpected error: %v\", res.ForLLM)\n\t\t}\n\t\tif !strings.Contains(res.ForLLM, \"mcp_read_file\") {\n\t\t\tt.Errorf(\"Expected 'mcp_read_file' in BM25 results\")\n\t\t}\n\n\t\treg.mu.RLock()\n\t\tdefer reg.mu.RUnlock()\n\t\tif reg.tools[\"mcp_read_file\"].TTL != 3 {\n\t\t\tt.Errorf(\"Expected TTL of 'mcp_read_file' to be promoted to 3\")\n\t\t}\n\t})\n}\n\nfunc TestRegexSearchTool_PatternTooLong(t *testing.T) {\n\treg := setupPopulatedRegistry()\n\ttool := NewRegexSearchTool(reg, 5, 10)\n\tctx := context.Background()\n\n\tlongPattern := strings.Repeat(\"a\", MaxRegexPatternLength+1)\n\tres := tool.Execute(ctx, map[string]any{\"pattern\": longPattern})\n\tif !res.IsError || !strings.Contains(res.ForLLM, \"Pattern too long\") {\n\t\tt.Errorf(\"Expected pattern too long error, got: %v\", res.ForLLM)\n\t}\n}\n\nfunc TestSearchRegex_ZeroMaxResults(t *testing.T) {\n\treg := setupPopulatedRegistry()\n\n\tres, err := reg.SearchRegex(\"mcp\", 0)\n\tif err != nil {\n\t\tt.Fatalf(\"SearchRegex failed: %v\", err)\n\t}\n\tif len(res) != 0 {\n\t\tt.Errorf(\"Expected 0 results with maxSearchResults=0, got %d\", len(res))\n\t}\n}\n\nfunc TestSearchBM25_ZeroMaxResults(t *testing.T) {\n\treg := setupPopulatedRegistry()\n\n\tres := reg.SearchBM25(\"read file\", 0)\n\tif len(res) != 0 {\n\t\tt.Errorf(\"Expected 0 results with maxSearchResults=0, got %d\", len(res))\n\t}\n}\n\nfunc TestSearchRegex_DeterministicOrder(t *testing.T) {\n\treg := NewToolRegistry()\n\tfor i := 0; i < 20; i++ {\n\t\treg.RegisterHidden(&mockSearchableTool{\n\t\t\tname: fmt.Sprintf(\"tool_%02d\", i),\n\t\t\tdesc: \"searchable tool\",\n\t\t})\n\t}\n\n\t// Run the same search multiple times and verify order is stable\n\tvar firstRun []string\n\tfor attempt := 0; attempt < 10; attempt++ {\n\t\tres, err := reg.SearchRegex(\"searchable\", 20)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"SearchRegex failed: %v\", err)\n\t\t}\n\n\t\tnames := make([]string, len(res))\n\t\tfor i, r := range res {\n\t\t\tnames[i] = r.Name\n\t\t}\n\n\t\tif attempt == 0 {\n\t\t\tfirstRun = names\n\t\t} else {\n\t\t\tfor i, name := range names {\n\t\t\t\tif name != firstRun[i] {\n\t\t\t\t\tt.Fatalf(\"Non-deterministic order at attempt %d, index %d: got %q, want %q\",\n\t\t\t\t\t\tattempt, i, name, firstRun[i])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) {\n\treg := NewToolRegistry()\n\n\t// Add 1 Core and 10 Hidden, all containing the word \"match\"\n\treg.Register(&mockSearchableTool{\"core_match\", \"I am core with match\"})\n\tfor i := 0; i < 10; i++ {\n\t\treg.RegisterHidden(&mockSearchableTool{\n\t\t\tname: fmt.Sprintf(\"hidden_match_%d\", i),\n\t\t\tdesc: \"this has a match\",\n\t\t})\n\t}\n\n\tt.Run(\"Regex limits and core filtering\", func(t *testing.T) {\n\t\t// Search with Regex and a limit of maxSearchResults = 4\n\t\tres, err := reg.SearchRegex(\"match\", 4)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"SearchRegex failed: %v\", err)\n\t\t}\n\n\t\tif len(res) != 4 {\n\t\t\tt.Errorf(\"Expected exactly 4 results due to limit, got %d\", len(res))\n\t\t}\n\n\t\tfor _, r := range res {\n\t\t\tif r.Name == \"core_match\" {\n\t\t\t\tt.Errorf(\"SearchRegex returned a Core tool, which should be excluded\")\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"BM25 limits and core filtering\", func(t *testing.T) {\n\t\t// Search with BM25 and a limit of maxSearchResults = 3\n\t\tres := reg.SearchBM25(\"match\", 3)\n\n\t\tif len(res) != 3 {\n\t\t\tt.Errorf(\"Expected exactly 3 results due to limit, got %d\", len(res))\n\t\t}\n\n\t\tfor _, r := range res {\n\t\t\tif r.Name == \"core_match\" {\n\t\t\t\tt.Errorf(\"SearchBM25 returned a Core tool, which should be excluded\")\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestGet_HiddenToolTTLLifecycle(t *testing.T) {\n\treg := NewToolRegistry()\n\treg.RegisterHidden(&mockSearchableTool{name: \"hidden_tool\", desc: \"test\"})\n\n\t// TTL=0 at registration → not gettable\n\t_, ok := reg.Get(\"hidden_tool\")\n\tif ok {\n\t\tt.Error(\"Expected hidden tool with TTL=0 to NOT be gettable\")\n\t}\n\n\t// Promote → gettable\n\treg.PromoteTools([]string{\"hidden_tool\"}, 3)\n\t_, ok = reg.Get(\"hidden_tool\")\n\tif !ok {\n\t\tt.Error(\"Expected promoted hidden tool to be gettable\")\n\t}\n\n\t// Tick down to 0 → not gettable again\n\treg.TickTTL() // 3→2\n\treg.TickTTL() // 2→1\n\treg.TickTTL() // 1→0\n\t_, ok = reg.Get(\"hidden_tool\")\n\tif ok {\n\t\tt.Error(\"Expected hidden tool with TTL ticked to 0 to NOT be gettable\")\n\t}\n\n\t// Core tools remain always gettable\n\treg.Register(&mockSearchableTool{name: \"core_tool\", desc: \"core\"})\n\t_, ok = reg.Get(\"core_tool\")\n\tif !ok {\n\t\tt.Error(\"Expected core tool to always be gettable\")\n\t}\n}\n\nfunc TestBM25CacheInvalidation(t *testing.T) {\n\treg := NewToolRegistry()\n\treg.RegisterHidden(&mockSearchableTool{name: \"tool_alpha\", desc: \"alpha functionality\"})\n\n\ttool := NewBM25SearchTool(reg, 5, 10)\n\tctx := context.Background()\n\n\t// First search should find tool_alpha\n\tres := tool.Execute(ctx, map[string]any{\"query\": \"alpha\"})\n\tif !strings.Contains(res.ForLLM, \"tool_alpha\") {\n\t\tt.Fatalf(\"Expected 'tool_alpha' in first search, got: %v\", res.ForLLM)\n\t}\n\n\t// Register a new hidden tool\n\treg.RegisterHidden(&mockSearchableTool{name: \"tool_beta\", desc: \"beta functionality\"})\n\n\t// Cache should be invalidated; new tool should be findable\n\tres = tool.Execute(ctx, map[string]any{\"query\": \"beta\"})\n\tif !strings.Contains(res.ForLLM, \"tool_beta\") {\n\t\tt.Errorf(\"Expected 'tool_beta' after cache invalidation, got: %v\", res.ForLLM)\n\t}\n}\n\nfunc TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) {\n\treg := NewToolRegistry()\n\tfor i := 0; i < 20; i++ {\n\t\treg.RegisterHidden(&mockSearchableTool{\n\t\t\tname: fmt.Sprintf(\"concurrent_tool_%d\", i),\n\t\t\tdesc: \"concurrent test tool\",\n\t\t})\n\t}\n\n\tnames := make([]string, 20)\n\tfor i := 0; i < 20; i++ {\n\t\tnames[i] = fmt.Sprintf(\"concurrent_tool_%d\", i)\n\t}\n\n\t// Hammer PromoteTools and TickTTL concurrently to detect races\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tfor i := 0; i < 1000; i++ {\n\t\t\treg.PromoteTools(names, 5)\n\t\t}\n\t\tclose(done)\n\t}()\n\n\tfor i := 0; i < 1000; i++ {\n\t\treg.TickTTL()\n\t}\n\t<-done\n}\n"
  },
  {
    "path": "pkg/tools/send_file.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"mime\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/h2non/filetype\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\n// SendFileTool allows the LLM to send a local file (image, document, etc.)\n// to the user on the current chat channel via the MediaStore pipeline.\ntype SendFileTool struct {\n\tworkspace   string\n\trestrict    bool\n\tmaxFileSize int\n\tmediaStore  media.MediaStore\n\tallowPaths  []*regexp.Regexp\n\n\tdefaultChannel string\n\tdefaultChatID  string\n}\n\nfunc NewSendFileTool(\n\tworkspace string,\n\trestrict bool,\n\tmaxFileSize int,\n\tstore media.MediaStore,\n\tallowPaths ...[]*regexp.Regexp,\n) *SendFileTool {\n\tif maxFileSize <= 0 {\n\t\tmaxFileSize = config.DefaultMaxMediaSize\n\t}\n\tvar patterns []*regexp.Regexp\n\tif len(allowPaths) > 0 {\n\t\tpatterns = allowPaths[0]\n\t}\n\treturn &SendFileTool{\n\t\tworkspace:   workspace,\n\t\trestrict:    restrict,\n\t\tmaxFileSize: maxFileSize,\n\t\tmediaStore:  store,\n\t\tallowPaths:  patterns,\n\t}\n}\n\nfunc (t *SendFileTool) Name() string { return \"send_file\" }\nfunc (t *SendFileTool) Description() string {\n\treturn \"Send a local file (image, document, etc.) to the user on the current chat channel.\"\n}\n\nfunc (t *SendFileTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Path to the local file. Relative paths are resolved from workspace.\",\n\t\t\t},\n\t\t\t\"filename\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional display filename. Defaults to the basename of path.\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"path\"},\n\t}\n}\n\nfunc (t *SendFileTool) SetContext(channel, chatID string) {\n\tt.defaultChannel = channel\n\tt.defaultChatID = chatID\n}\n\nfunc (t *SendFileTool) SetMediaStore(store media.MediaStore) {\n\tt.mediaStore = store\n}\n\nfunc (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tpath, _ := args[\"path\"].(string)\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn ErrorResult(\"path is required\")\n\t}\n\n\t// Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values.\n\tchannel := ToolChannel(ctx)\n\tif channel == \"\" {\n\t\tchannel = t.defaultChannel\n\t}\n\tchatID := ToolChatID(ctx)\n\tif chatID == \"\" {\n\t\tchatID = t.defaultChatID\n\t}\n\tif channel == \"\" || chatID == \"\" {\n\t\treturn ErrorResult(\"no target channel/chat available\")\n\t}\n\n\tif t.mediaStore == nil {\n\t\treturn ErrorResult(\"media store not configured\")\n\t}\n\n\tresolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"invalid path: %v\", err))\n\t}\n\n\tinfo, err := os.Stat(resolved)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"file not found: %v\", err))\n\t}\n\tif info.IsDir() {\n\t\treturn ErrorResult(\"path is a directory, expected a file\")\n\t}\n\tif info.Size() > int64(t.maxFileSize) {\n\t\treturn ErrorResult(fmt.Sprintf(\n\t\t\t\"file too large: %d bytes (max %d bytes)\",\n\t\t\tinfo.Size(), t.maxFileSize,\n\t\t))\n\t}\n\n\tfilename, _ := args[\"filename\"].(string)\n\tif filename == \"\" {\n\t\tfilename = filepath.Base(resolved)\n\t}\n\n\tmediaType := detectMediaType(resolved)\n\tscope := fmt.Sprintf(\"tool:send_file:%s:%s\", channel, chatID)\n\n\tref, err := t.mediaStore.Store(resolved, media.MediaMeta{\n\t\tFilename:    filename,\n\t\tContentType: mediaType,\n\t\tSource:      \"tool:send_file\",\n\t}, scope)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to register media: %v\", err))\n\t}\n\n\treturn MediaResult(fmt.Sprintf(\"File %q sent to user\", filename), []string{ref})\n}\n\n// detectMediaType determines the MIME type of a file.\n// Uses magic-bytes detection (h2non/filetype) first, then falls back to\n// extension-based lookup via mime.TypeByExtension.\nfunc detectMediaType(path string) string {\n\tkind, err := filetype.MatchFile(path)\n\tif err == nil && kind != filetype.Unknown {\n\t\treturn kind.MIME.Value\n\t}\n\n\tif ext := filepath.Ext(path); ext != \"\" {\n\t\tif t := mime.TypeByExtension(ext); t != \"\" {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn \"application/octet-stream\"\n}\n"
  },
  {
    "path": "pkg/tools/send_file_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\nfunc TestSendFileTool_MissingPath(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(\"/tmp\", false, 0, store)\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error for missing path\")\n\t}\n}\n\nfunc TestSendFileTool_NoContext(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(\"/tmp\", false, 0, store)\n\t// no SetContext call\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": \"/tmp/test.txt\"})\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error when no channel context\")\n\t}\n}\n\nfunc TestSendFileTool_NoMediaStore(t *testing.T) {\n\ttool := NewSendFileTool(\"/tmp\", false, 0, nil)\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": \"/tmp/test.txt\"})\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error when no media store\")\n\t}\n}\n\nfunc TestSendFileTool_Directory(t *testing.T) {\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(\"/tmp\", false, 0, store)\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": \"/tmp\"})\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error for directory path\")\n\t}\n}\n\nfunc TestSendFileTool_FileTooLarge(t *testing.T) {\n\tdir := t.TempDir()\n\ttestFile := filepath.Join(dir, \"big.bin\")\n\t// Create a file larger than the limit\n\tif err := os.WriteFile(testFile, make([]byte, 1024), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(dir, false, 512, store) // 512 byte limit\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": testFile})\n\tif !result.IsError {\n\t\tt.Fatal(\"expected error for oversized file\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"too large\") {\n\t\tt.Errorf(\"expected 'too large' in error, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestSendFileTool_DefaultMaxSize(t *testing.T) {\n\ttool := NewSendFileTool(\"/tmp\", false, 0, nil)\n\tif tool.maxFileSize != config.DefaultMaxMediaSize {\n\t\tt.Errorf(\"expected default max size %d, got %d\", config.DefaultMaxMediaSize, tool.maxFileSize)\n\t}\n}\n\nfunc TestSendFileTool_Success(t *testing.T) {\n\tdir := t.TempDir()\n\ttestFile := filepath.Join(dir, \"photo.png\")\n\tif err := os.WriteFile(testFile, []byte(\"fake png\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(dir, false, 0, store)\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": testFile})\n\tif result.IsError {\n\t\tt.Fatalf(\"unexpected error: %s\", result.ForLLM)\n\t}\n\tif len(result.Media) != 1 {\n\t\tt.Fatalf(\"expected 1 media ref, got %d\", len(result.Media))\n\t}\n\tif result.Media[0][:8] != \"media://\" {\n\t\tt.Errorf(\"expected media:// ref, got %q\", result.Media[0])\n\t}\n}\n\nfunc TestSendFileTool_CustomFilename(t *testing.T) {\n\tdir := t.TempDir()\n\ttestFile := filepath.Join(dir, \"img.jpg\")\n\tif err := os.WriteFile(testFile, []byte(\"fake jpg\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(dir, false, 0, store)\n\ttool.SetContext(\"telegram\", \"chat456\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"path\":     testFile,\n\t\t\"filename\": \"my-photo.jpg\",\n\t})\n\tif result.IsError {\n\t\tt.Fatalf(\"unexpected error: %s\", result.ForLLM)\n\t}\n\tif len(result.Media) != 1 {\n\t\tt.Fatalf(\"expected 1 media ref, got %d\", len(result.Media))\n\t}\n}\n\nfunc TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) {\n\tworkspace := t.TempDir()\n\tmediaDir := media.TempDir()\n\tif err := os.MkdirAll(mediaDir, 0o700); err != nil {\n\t\tt.Fatalf(\"MkdirAll(mediaDir) error = %v\", err)\n\t}\n\n\ttestFile, err := os.CreateTemp(mediaDir, \"send-file-*.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"CreateTemp(mediaDir) error = %v\", err)\n\t}\n\ttestPath := testFile.Name()\n\tif _, err := testFile.WriteString(\"forward me\"); err != nil {\n\t\ttestFile.Close()\n\t\tt.Fatalf(\"WriteString(testFile) error = %v\", err)\n\t}\n\tif err := testFile.Close(); err != nil {\n\t\tt.Fatalf(\"Close(testFile) error = %v\", err)\n\t}\n\tt.Cleanup(func() { _ = os.Remove(testPath) })\n\n\tpattern := regexp.MustCompile(\n\t\t\"^\" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + \"(?:\" + regexp.QuoteMeta(string(os.PathSeparator)) + \"|$)\",\n\t)\n\n\tstore := media.NewFileMediaStore()\n\ttool := NewSendFileTool(workspace, true, 0, store, []*regexp.Regexp{pattern})\n\ttool.SetContext(\"feishu\", \"chat123\")\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"path\": testPath})\n\tif result.IsError {\n\t\tt.Fatalf(\"expected whitelisted temp media file to be sendable, got: %s\", result.ForLLM)\n\t}\n\tif len(result.Media) != 1 {\n\t\tt.Fatalf(\"expected 1 media ref, got %d\", len(result.Media))\n\t}\n}\n\nfunc TestDetectMediaType_MagicBytes(t *testing.T) {\n\tdir := t.TempDir()\n\n\t// Minimal valid PNG header\n\tpngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}\n\tpngFile := filepath.Join(dir, \"image.dat\") // wrong extension, but valid PNG bytes\n\tif err := os.WriteFile(pngFile, pngHeader, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgot := detectMediaType(pngFile)\n\tif got != \"image/png\" {\n\t\tt.Errorf(\"expected image/png from magic bytes, got %q\", got)\n\t}\n}\n\nfunc TestDetectMediaType_FallbackToExtension(t *testing.T) {\n\tdir := t.TempDir()\n\n\t// File with unrecognizable content but known extension\n\ttxtFile := filepath.Join(dir, \"readme.txt\")\n\tif err := os.WriteFile(txtFile, []byte(\"hello world\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgot := detectMediaType(txtFile)\n\t// text/plain or similar — just verify it's not application/octet-stream\n\tif got == \"application/octet-stream\" {\n\t\tt.Errorf(\"expected extension-based MIME for .txt, got %q\", got)\n\t}\n}\n\nfunc TestDetectMediaType_UnknownFallsToOctetStream(t *testing.T) {\n\tdir := t.TempDir()\n\n\t// File with no extension and random bytes\n\tunknownFile := filepath.Join(dir, \"mystery\")\n\tif err := os.WriteFile(unknownFile, []byte{0x00, 0x01, 0x02}, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgot := detectMediaType(unknownFile)\n\tif got != \"application/octet-stream\" {\n\t\tt.Errorf(\"expected application/octet-stream, got %q\", got)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/shell.go",
    "content": "package tools\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/constants\"\n)\n\ntype ExecTool struct {\n\tworkingDir          string\n\ttimeout             time.Duration\n\tdenyPatterns        []*regexp.Regexp\n\tallowPatterns       []*regexp.Regexp\n\tcustomAllowPatterns []*regexp.Regexp\n\tallowedPathPatterns []*regexp.Regexp\n\trestrictToWorkspace bool\n\tallowRemote         bool\n}\n\nvar (\n\tdefaultDenyPatterns = []*regexp.Regexp{\n\t\tregexp.MustCompile(`\\brm\\s+-[rf]{1,2}\\b`),\n\t\tregexp.MustCompile(`\\bdel\\s+/[fq]\\b`),\n\t\tregexp.MustCompile(`\\brmdir\\s+/s\\b`),\n\t\t// Match disk wiping commands (must be followed by space/args)\n\t\tregexp.MustCompile(\n\t\t\t`\\b(format|mkfs|diskpart)\\b\\s`,\n\t\t),\n\t\tregexp.MustCompile(`\\bdd\\s+if=`),\n\t\t// Block writes to block devices (all common naming schemes).\n\t\tregexp.MustCompile(\n\t\t\t`>\\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\\d|mmcblk\\d|loop\\d|dm-\\d|md\\d|sr\\d|nbd\\d)`,\n\t\t),\n\t\tregexp.MustCompile(`\\b(shutdown|reboot|poweroff)\\b`),\n\t\tregexp.MustCompile(`:\\(\\)\\s*\\{.*\\};\\s*:`),\n\t\tregexp.MustCompile(`\\$\\([^)]+\\)`),\n\t\tregexp.MustCompile(`\\$\\{[^}]+\\}`),\n\t\tregexp.MustCompile(\"`[^`]+`\"),\n\t\tregexp.MustCompile(`\\|\\s*sh\\b`),\n\t\tregexp.MustCompile(`\\|\\s*bash\\b`),\n\t\tregexp.MustCompile(`;\\s*rm\\s+-[rf]`),\n\t\tregexp.MustCompile(`&&\\s*rm\\s+-[rf]`),\n\t\tregexp.MustCompile(`\\|\\|\\s*rm\\s+-[rf]`),\n\t\tregexp.MustCompile(`<<\\s*EOF`),\n\t\tregexp.MustCompile(`\\$\\(\\s*cat\\s+`),\n\t\tregexp.MustCompile(`\\$\\(\\s*curl\\s+`),\n\t\tregexp.MustCompile(`\\$\\(\\s*wget\\s+`),\n\t\tregexp.MustCompile(`\\$\\(\\s*which\\s+`),\n\t\tregexp.MustCompile(`\\bsudo\\b`),\n\t\tregexp.MustCompile(`\\bchmod\\s+[0-7]{3,4}\\b`),\n\t\tregexp.MustCompile(`\\bchown\\b`),\n\t\tregexp.MustCompile(`\\bpkill\\b`),\n\t\tregexp.MustCompile(`\\bkillall\\b`),\n\t\tregexp.MustCompile(`\\bkill\\b`),\n\t\tregexp.MustCompile(`\\bcurl\\b.*\\|\\s*(sh|bash)`),\n\t\tregexp.MustCompile(`\\bwget\\b.*\\|\\s*(sh|bash)`),\n\t\tregexp.MustCompile(`\\bnpm\\s+install\\s+-g\\b`),\n\t\tregexp.MustCompile(`\\bpip\\s+install\\s+--user\\b`),\n\t\tregexp.MustCompile(`\\bapt\\s+(install|remove|purge)\\b`),\n\t\tregexp.MustCompile(`\\byum\\s+(install|remove)\\b`),\n\t\tregexp.MustCompile(`\\bdnf\\s+(install|remove)\\b`),\n\t\tregexp.MustCompile(`\\bdocker\\s+run\\b`),\n\t\tregexp.MustCompile(`\\bdocker\\s+exec\\b`),\n\t\tregexp.MustCompile(`\\bgit\\s+push\\b`),\n\t\tregexp.MustCompile(`\\bgit\\s+force\\b`),\n\t\tregexp.MustCompile(`\\bssh\\b.*@`),\n\t\tregexp.MustCompile(`\\beval\\b`),\n\t\tregexp.MustCompile(`\\bsource\\s+.*\\.sh\\b`),\n\t}\n\n\t// absolutePathPattern matches absolute file paths in commands (Unix and Windows).\n\tabsolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\\\[^\\\\\\\"']+|/[^\\s\\\"']+`)\n\n\t// safePaths are kernel pseudo-devices that are always safe to reference in\n\t// commands, regardless of workspace restriction. They contain no user data\n\t// and cannot cause destructive writes.\n\tsafePaths = map[string]bool{\n\t\t\"/dev/null\":    true,\n\t\t\"/dev/zero\":    true,\n\t\t\"/dev/random\":  true,\n\t\t\"/dev/urandom\": true,\n\t\t\"/dev/stdin\":   true,\n\t\t\"/dev/stdout\":  true,\n\t\t\"/dev/stderr\":  true,\n\t}\n)\n\nfunc NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {\n\treturn NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...)\n}\n\nfunc NewExecToolWithConfig(\n\tworkingDir string,\n\trestrict bool,\n\tconfig *config.Config,\n\tallowPaths ...[]*regexp.Regexp,\n) (*ExecTool, error) {\n\tdenyPatterns := make([]*regexp.Regexp, 0)\n\tcustomAllowPatterns := make([]*regexp.Regexp, 0)\n\tvar allowedPathPatterns []*regexp.Regexp\n\tallowRemote := true\n\tif len(allowPaths) > 0 {\n\t\tallowedPathPatterns = allowPaths[0]\n\t}\n\n\tif config != nil {\n\t\texecConfig := config.Tools.Exec\n\t\tenableDenyPatterns := execConfig.EnableDenyPatterns\n\t\tallowRemote = execConfig.AllowRemote\n\t\tif enableDenyPatterns {\n\t\t\tdenyPatterns = append(denyPatterns, defaultDenyPatterns...)\n\t\t\tif len(execConfig.CustomDenyPatterns) > 0 {\n\t\t\t\tfmt.Printf(\"Using custom deny patterns: %v\\n\", execConfig.CustomDenyPatterns)\n\t\t\t\tfor _, pattern := range execConfig.CustomDenyPatterns {\n\t\t\t\t\tre, err := regexp.Compile(pattern)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"invalid custom deny pattern %q: %w\", pattern, err)\n\t\t\t\t\t}\n\t\t\t\t\tdenyPatterns = append(denyPatterns, re)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// If deny patterns are disabled, we won't add any patterns, allowing all commands.\n\t\t\tfmt.Println(\"Warning: deny patterns are disabled. All commands will be allowed.\")\n\t\t}\n\t\tfor _, pattern := range execConfig.CustomAllowPatterns {\n\t\t\tre, err := regexp.Compile(pattern)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid custom allow pattern %q: %w\", pattern, err)\n\t\t\t}\n\t\t\tcustomAllowPatterns = append(customAllowPatterns, re)\n\t\t}\n\t} else {\n\t\tdenyPatterns = append(denyPatterns, defaultDenyPatterns...)\n\t}\n\n\ttimeout := 60 * time.Second\n\tif config != nil && config.Tools.Exec.TimeoutSeconds > 0 {\n\t\ttimeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second\n\t}\n\n\treturn &ExecTool{\n\t\tworkingDir:          workingDir,\n\t\ttimeout:             timeout,\n\t\tdenyPatterns:        denyPatterns,\n\t\tallowPatterns:       nil,\n\t\tcustomAllowPatterns: customAllowPatterns,\n\t\tallowedPathPatterns: allowedPathPatterns,\n\t\trestrictToWorkspace: restrict,\n\t\tallowRemote:         allowRemote,\n\t}, nil\n}\n\nfunc (t *ExecTool) Name() string {\n\treturn \"exec\"\n}\n\nfunc (t *ExecTool) Description() string {\n\treturn \"Execute a shell command and return its output. Use with caution.\"\n}\n\nfunc (t *ExecTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"command\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The shell command to execute\",\n\t\t\t},\n\t\t\t\"working_dir\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional working directory for the command\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"command\"},\n\t}\n}\n\nfunc (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tcommand, ok := args[\"command\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"command is required\")\n\t}\n\n\t// GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks)\n\t// unless explicitly opted-in via config. Fail-closed: empty channel = blocked.\n\tif !t.allowRemote {\n\t\tchannel := ToolChannel(ctx)\n\t\tif channel == \"\" {\n\t\t\tchannel, _ = args[\"__channel\"].(string)\n\t\t}\n\t\tchannel = strings.TrimSpace(channel)\n\t\tif channel == \"\" || !constants.IsInternalChannel(channel) {\n\t\t\treturn ErrorResult(\"exec is restricted to internal channels\")\n\t\t}\n\t}\n\n\tcwd := t.workingDir\n\tif wd, ok := args[\"working_dir\"].(string); ok && wd != \"\" {\n\t\tif t.restrictToWorkspace && t.workingDir != \"\" {\n\t\t\tresolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)\n\t\t\tif err != nil {\n\t\t\t\treturn ErrorResult(\"Command blocked by safety guard (\" + err.Error() + \")\")\n\t\t\t}\n\t\t\tcwd = resolvedWD\n\t\t} else {\n\t\t\tcwd = wd\n\t\t}\n\t}\n\n\tif cwd == \"\" {\n\t\twd, err := os.Getwd()\n\t\tif err == nil {\n\t\t\tcwd = wd\n\t\t}\n\t}\n\n\tif guardError := t.guardCommand(command, cwd); guardError != \"\" {\n\t\treturn ErrorResult(guardError)\n\t}\n\n\t// Re-resolve symlinks immediately before execution to shrink the TOCTOU window\n\t// between validation and cmd.Dir assignment.\n\tif t.restrictToWorkspace && t.workingDir != \"\" && cwd != t.workingDir {\n\t\tresolved, err := filepath.EvalSymlinks(cwd)\n\t\tif err != nil {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"Command blocked by safety guard (path resolution failed: %v)\", err))\n\t\t}\n\t\tif isAllowedPath(resolved, t.allowedPathPatterns) {\n\t\t\tcwd = resolved\n\t\t} else {\n\t\t\tabsWorkspace, _ := filepath.Abs(t.workingDir)\n\t\t\twsResolved, _ := filepath.EvalSymlinks(absWorkspace)\n\t\t\tif wsResolved == \"\" {\n\t\t\t\twsResolved = absWorkspace\n\t\t\t}\n\t\t\trel, err := filepath.Rel(wsResolved, resolved)\n\t\t\tif err != nil || !filepath.IsLocal(rel) {\n\t\t\t\treturn ErrorResult(\"Command blocked by safety guard (working directory escaped workspace)\")\n\t\t\t}\n\t\t\tcwd = resolved\n\t\t}\n\t}\n\n\t// timeout == 0 means no timeout\n\tvar cmdCtx context.Context\n\tvar cancel context.CancelFunc\n\tif t.timeout > 0 {\n\t\tcmdCtx, cancel = context.WithTimeout(ctx, t.timeout)\n\t} else {\n\t\tcmdCtx, cancel = context.WithCancel(ctx)\n\t}\n\tdefer cancel()\n\n\tvar cmd *exec.Cmd\n\tif runtime.GOOS == \"windows\" {\n\t\tcmd = exec.CommandContext(cmdCtx, \"powershell\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", command)\n\t} else {\n\t\tcmd = exec.CommandContext(cmdCtx, \"sh\", \"-c\", command)\n\t}\n\tif cwd != \"\" {\n\t\tcmd.Dir = cwd\n\t}\n\n\tprepareCommandForTermination(cmd)\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to start command: %v\", err))\n\t}\n\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tdone <- cmd.Wait()\n\t}()\n\n\tvar err error\n\tselect {\n\tcase err = <-done:\n\tcase <-cmdCtx.Done():\n\t\t_ = terminateProcessTree(cmd)\n\t\tselect {\n\t\tcase err = <-done:\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tif cmd.Process != nil {\n\t\t\t\t_ = cmd.Process.Kill()\n\t\t\t}\n\t\t\terr = <-done\n\t\t}\n\t}\n\n\toutput := stdout.String()\n\tif stderr.Len() > 0 {\n\t\toutput += \"\\nSTDERR:\\n\" + stderr.String()\n\t}\n\n\tif err != nil {\n\t\tif errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {\n\t\t\tmsg := fmt.Sprintf(\"Command timed out after %v\", t.timeout)\n\t\t\tif output != \"\" {\n\t\t\t\tmsg += \"\\n\\nPartial output before timeout:\\n\" + output\n\t\t\t}\n\t\t\treturn &ToolResult{\n\t\t\t\tForLLM:  msg,\n\t\t\t\tForUser: msg,\n\t\t\t\tIsError: true,\n\t\t\t\tErr:     fmt.Errorf(\"command timeout: %w\", err),\n\t\t\t}\n\t\t}\n\n\t\t// Extract detailed exit information\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\texitCode := exitErr.ExitCode()\n\t\t\toutput += fmt.Sprintf(\"\\n\\n[Command exited with code %d]\", exitCode)\n\n\t\t\t// Add signal information if killed by signal (Unix)\n\t\t\tif exitCode == -1 {\n\t\t\t\toutput += \" (killed by signal)\"\n\t\t\t}\n\t\t} else {\n\t\t\toutput += fmt.Sprintf(\"\\n\\n[Command failed: %v]\", err)\n\t\t}\n\t}\n\n\tif output == \"\" {\n\t\toutput = \"(no output)\"\n\t}\n\n\tmaxLen := 10000\n\tif len(output) > maxLen {\n\t\toutput = output[:maxLen] + fmt.Sprintf(\"\\n... (truncated, %d more chars)\", len(output)-maxLen)\n\t}\n\n\tif err != nil {\n\t\treturn &ToolResult{\n\t\t\tForLLM:  output,\n\t\t\tForUser: output,\n\t\t\tIsError: true,\n\t\t}\n\t}\n\n\treturn &ToolResult{\n\t\tForLLM:  output,\n\t\tForUser: output,\n\t\tIsError: false,\n\t}\n}\n\nfunc (t *ExecTool) guardCommand(command, cwd string) string {\n\tcmd := strings.TrimSpace(command)\n\tlower := strings.ToLower(cmd)\n\n\t// Custom allow patterns exempt a command from deny checks.\n\texplicitlyAllowed := false\n\tfor _, pattern := range t.customAllowPatterns {\n\t\tif pattern.MatchString(lower) {\n\t\t\texplicitlyAllowed = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !explicitlyAllowed {\n\t\tfor _, pattern := range t.denyPatterns {\n\t\t\tif pattern.MatchString(lower) {\n\t\t\t\treturn \"Command blocked by safety guard (dangerous pattern detected)\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(t.allowPatterns) > 0 {\n\t\tallowed := false\n\t\tfor _, pattern := range t.allowPatterns {\n\t\t\tif pattern.MatchString(lower) {\n\t\t\t\tallowed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !allowed {\n\t\t\treturn \"Command blocked by safety guard (not in allowlist)\"\n\t\t}\n\t}\n\n\tif t.restrictToWorkspace {\n\t\tif strings.Contains(cmd, \"..\\\\\") || strings.Contains(cmd, \"../\") {\n\t\t\treturn \"Command blocked by safety guard (path traversal detected)\"\n\t\t}\n\n\t\tcwdPath, err := filepath.Abs(cwd)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\n\t\t// Web URL schemes whose path components (starting with //) should be exempt\n\t\t// from workspace sandbox checks. file: is intentionally excluded so that\n\t\t// file:// URIs are still validated against the workspace boundary.\n\t\twebSchemes := []string{\"http:\", \"https:\", \"ftp:\", \"ftps:\", \"sftp:\", \"ssh:\", \"git:\"}\n\n\t\tmatchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)\n\n\t\tfor _, loc := range matchIndices {\n\t\t\traw := cmd[loc[0]:loc[1]]\n\n\t\t\t// Skip URL path components that look like they're from web URLs.\n\t\t\t// When a URL like \"https://github.com\" is parsed, the regex captures\n\t\t\t// \"//github.com\" as a match (the path portion after \"https:\").\n\t\t\t// Use the exact match position (loc[0]) so that duplicate //path substrings\n\t\t\t// in the same command are each evaluated at their own position.\n\t\t\tif strings.HasPrefix(raw, \"//\") && loc[0] > 0 {\n\t\t\t\tbefore := cmd[:loc[0]]\n\t\t\t\tisWebURL := false\n\n\t\t\t\tfor _, scheme := range webSchemes {\n\t\t\t\t\tif strings.HasSuffix(before, scheme) {\n\t\t\t\t\t\tisWebURL = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif isWebURL {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tp, err := filepath.Abs(raw)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif safePaths[p] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif isAllowedPath(p, t.allowedPathPatterns) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\trel, err := filepath.Rel(cwdPath, p)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif strings.HasPrefix(rel, \"..\") {\n\t\t\t\treturn \"Command blocked by safety guard (path outside working dir)\"\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\nfunc (t *ExecTool) SetTimeout(timeout time.Duration) {\n\tt.timeout = timeout\n}\n\nfunc (t *ExecTool) SetRestrictToWorkspace(restrict bool) {\n\tt.restrictToWorkspace = restrict\n}\n\nfunc (t *ExecTool) SetAllowPatterns(patterns []string) error {\n\tt.allowPatterns = make([]*regexp.Regexp, 0, len(patterns))\n\tfor _, p := range patterns {\n\t\tre, err := regexp.Compile(p)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid allow pattern %q: %w\", p, err)\n\t\t}\n\t\tt.allowPatterns = append(t.allowPatterns, re)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tools/shell_process_unix.go",
    "content": "//go:build !windows\n\npackage tools\n\nimport (\n\t\"os/exec\"\n\t\"syscall\"\n)\n\nfunc prepareCommandForTermination(cmd *exec.Cmd) {\n\tif cmd == nil {\n\t\treturn\n\t}\n\tcmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}\n}\n\nfunc terminateProcessTree(cmd *exec.Cmd) error {\n\tif cmd == nil || cmd.Process == nil {\n\t\treturn nil\n\t}\n\n\tpid := cmd.Process.Pid\n\tif pid <= 0 {\n\t\treturn nil\n\t}\n\n\t// Kill the entire process group spawned by the shell command.\n\t_ = syscall.Kill(-pid, syscall.SIGKILL)\n\t// Fallback kill on the shell process itself.\n\t_ = cmd.Process.Kill()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tools/shell_process_windows.go",
    "content": "//go:build windows\n\npackage tools\n\nimport (\n\t\"os/exec\"\n\t\"strconv\"\n)\n\nfunc prepareCommandForTermination(cmd *exec.Cmd) {\n\t// no-op on Windows\n}\n\nfunc terminateProcessTree(cmd *exec.Cmd) error {\n\tif cmd == nil || cmd.Process == nil {\n\t\treturn nil\n\t}\n\n\tpid := cmd.Process.Pid\n\tif pid <= 0 {\n\t\treturn nil\n\t}\n\n\t_ = exec.Command(\"taskkill\", \"/T\", \"/F\", \"/PID\", strconv.Itoa(pid)).Run()\n\t_ = cmd.Process.Kill()\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tools/shell_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// TestShellTool_Success verifies successful command execution\nfunc TestShellTool_Success(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"echo 'hello world'\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForUser should contain command output\n\tif !strings.Contains(result.ForUser, \"hello world\") {\n\t\tt.Errorf(\"Expected ForUser to contain 'hello world', got: %s\", result.ForUser)\n\t}\n\n\t// ForLLM should contain full output\n\tif !strings.Contains(result.ForLLM, \"hello world\") {\n\t\tt.Errorf(\"Expected ForLLM to contain 'hello world', got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_Failure verifies failed command execution\nfunc TestShellTool_Failure(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"ls /nonexistent_directory_12345\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Failure should be marked as error\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for failed command, got IsError=false\")\n\t}\n\n\t// ForUser should contain error information\n\tif result.ForUser == \"\" {\n\t\tt.Errorf(\"Expected ForUser to contain error info, got empty string\")\n\t}\n\n\t// ForLLM should contain exit code or error\n\tif !strings.Contains(result.ForLLM, \"Exit code\") && result.ForUser == \"\" {\n\t\tt.Errorf(\"Expected ForLLM to contain exit code or error, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_Timeout verifies command timeout handling\nfunc TestShellTool_Timeout(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\ttool.SetTimeout(100 * time.Millisecond)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"sleep 10\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Timeout should be marked as error\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for timeout, got IsError=false\")\n\t}\n\n\t// Should mention timeout\n\tif !strings.Contains(result.ForLLM, \"timed out\") && !strings.Contains(result.ForUser, \"timed out\") {\n\t\tt.Errorf(\"Expected timeout message, got ForLLM: %s, ForUser: %s\", result.ForLLM, result.ForUser)\n\t}\n}\n\n// TestShellTool_WorkingDir verifies custom working directory\nfunc TestShellTool_WorkingDir(t *testing.T) {\n\t// Create temp directory\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tos.WriteFile(testFile, []byte(\"test content\"), 0o644)\n\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\":     \"cat test.txt\",\n\t\t\"working_dir\": tmpDir,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success in custom working dir, got error: %s\", result.ForLLM)\n\t}\n\n\tif !strings.Contains(result.ForUser, \"test content\") {\n\t\tt.Errorf(\"Expected output from custom dir, got: %s\", result.ForUser)\n\t}\n}\n\n// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands\nfunc TestShellTool_DangerousCommand(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"rm -rf /\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Dangerous command should be blocked\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected dangerous command to be blocked (IsError=true)\")\n\t}\n\n\tif !strings.Contains(result.ForLLM, \"blocked\") && !strings.Contains(result.ForUser, \"blocked\") {\n\t\tt.Errorf(\"Expected 'blocked' message, got ForLLM: %s, ForUser: %s\", result.ForLLM, result.ForUser)\n\t}\n}\n\nfunc TestShellTool_DangerousCommand_KillBlocked(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"kill 12345\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected kill command to be blocked\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"blocked\") && !strings.Contains(result.ForUser, \"blocked\") {\n\t\tt.Errorf(\"Expected blocked message, got ForLLM: %s, ForUser: %s\", result.ForLLM, result.ForUser)\n\t}\n}\n\n// TestShellTool_MissingCommand verifies error handling for missing command\nfunc TestShellTool_MissingCommand(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when command is missing\")\n\t}\n}\n\n// TestShellTool_StderrCapture verifies stderr is captured and included\nfunc TestShellTool_StderrCapture(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"sh -c 'echo stdout; echo stderr >&2'\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Both stdout and stderr should be in output\n\tif !strings.Contains(result.ForLLM, \"stdout\") {\n\t\tt.Errorf(\"Expected stdout in output, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"stderr\") {\n\t\tt.Errorf(\"Expected stderr in output, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_OutputTruncation verifies long output is truncated\nfunc TestShellTool_OutputTruncation(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\t// Generate long output (>10000 chars)\n\targs := map[string]any{\n\t\t\"command\": \"python3 -c \\\"print('x' * 20000)\\\" || echo \" + strings.Repeat(\"x\", 20000),\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should have truncation message or be truncated\n\tif len(result.ForLLM) > 15000 {\n\t\tt.Errorf(\"Expected output to be truncated, got length: %d\", len(result.ForLLM))\n\t}\n}\n\n// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly\nfunc TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {\n\troot := t.TempDir()\n\tworkspace := filepath.Join(root, \"workspace\")\n\toutsideDir := filepath.Join(root, \"outside\")\n\tif err := os.MkdirAll(workspace, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create workspace: %v\", err)\n\t}\n\tif err := os.MkdirAll(outsideDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create outside dir: %v\", err)\n\t}\n\n\ttool, err := NewExecTool(workspace, true)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"command\":     \"pwd\",\n\t\t\"working_dir\": outsideDir,\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatalf(\"expected working_dir outside workspace to be blocked, got output: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"blocked\") {\n\t\tt.Errorf(\"expected 'blocked' in error, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace\n// pointing outside cannot be used as working_dir to escape the sandbox.\nfunc TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {\n\troot := t.TempDir()\n\tworkspace := filepath.Join(root, \"workspace\")\n\tsecretDir := filepath.Join(root, \"secret\")\n\tif err := os.MkdirAll(workspace, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create workspace: %v\", err)\n\t}\n\tif err := os.MkdirAll(secretDir, 0o755); err != nil {\n\t\tt.Fatalf(\"failed to create secret dir: %v\", err)\n\t}\n\tos.WriteFile(filepath.Join(secretDir, \"secret.txt\"), []byte(\"top secret\"), 0o644)\n\n\t// symlink lives inside the workspace but resolves to secretDir outside it\n\tlink := filepath.Join(workspace, \"escape\")\n\tif err := os.Symlink(secretDir, link); err != nil {\n\t\tt.Skipf(\"symlinks not supported in this environment: %v\", err)\n\t}\n\n\ttool, err := NewExecTool(workspace, true)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"command\":     \"cat secret.txt\",\n\t\t\"working_dir\": link,\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatalf(\"expected symlink working_dir escape to be blocked, got output: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"blocked\") {\n\t\tt.Errorf(\"expected 'blocked' in error, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels\nfunc TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) {\n\tcfg := &config.Config{}\n\tcfg.Tools.Exec.EnableDenyPatterns = true\n\tcfg.Tools.Exec.AllowRemote = false\n\n\ttool, err := NewExecToolWithConfig(\"\", false, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecToolWithConfig() error: %v\", err)\n\t}\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-1\")\n\tresult := tool.Execute(ctx, map[string]any{\"command\": \"echo hi\"})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected remote-channel exec to be blocked\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"restricted to internal channels\") {\n\t\tt.Errorf(\"expected 'restricted to internal channels' message, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels\nfunc TestShellTool_InternalChannelAllowed(t *testing.T) {\n\tcfg := &config.Config{}\n\tcfg.Tools.Exec.EnableDenyPatterns = true\n\tcfg.Tools.Exec.AllowRemote = false\n\n\ttool, err := NewExecToolWithConfig(\"\", false, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecToolWithConfig() error: %v\", err)\n\t}\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\tresult := tool.Execute(ctx, map[string]any{\"command\": \"echo hi\"})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected internal channel exec to succeed, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"hi\") {\n\t\tt.Errorf(\"expected output to contain 'hi', got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context\nfunc TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) {\n\tcfg := &config.Config{}\n\tcfg.Tools.Exec.EnableDenyPatterns = true\n\tcfg.Tools.Exec.AllowRemote = false\n\n\ttool, err := NewExecToolWithConfig(\"\", false, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecToolWithConfig() error: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"command\": \"echo hi\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Fatal(\"expected exec with empty channel to be blocked when allowRemote=false\")\n\t}\n}\n\n// TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel\nfunc TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) {\n\tcfg := &config.Config{}\n\tcfg.Tools.Exec.EnableDenyPatterns = true\n\tcfg.Tools.Exec.AllowRemote = true\n\n\ttool, err := NewExecToolWithConfig(\"\", false, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecToolWithConfig() error: %v\", err)\n\t}\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-1\")\n\tresult := tool.Execute(ctx, map[string]any{\"command\": \"echo hi\"})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected allowRemote=true to permit remote channel, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestShellTool_RestrictToWorkspace verifies workspace restriction\nfunc TestShellTool_RestrictToWorkspace(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\ttool.SetRestrictToWorkspace(true)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"cat ../../etc/passwd\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Path traversal should be blocked\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected path traversal to be blocked with restrictToWorkspace=true\")\n\t}\n\n\tif !strings.Contains(result.ForLLM, \"blocked\") && !strings.Contains(result.ForUser, \"blocked\") {\n\t\tt.Errorf(\n\t\t\t\"Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s\",\n\t\t\tresult.ForLLM,\n\t\t\tresult.ForUser,\n\t\t)\n\t}\n}\n\n// TestShellTool_DevNullAllowed verifies that /dev/null redirections are not blocked (issue #964).\nfunc TestShellTool_DevNullAllowed(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, true)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tcommands := []string{\n\t\t\"echo hello 2>/dev/null\",\n\t\t\"echo hello >/dev/null\",\n\t\t\"echo hello > /dev/null\",\n\t\t\"echo hello 2> /dev/null\",\n\t\t\"echo hello >/dev/null 2>&1\",\n\t\t\"find \" + tmpDir + \" -name '*.go' 2>/dev/null\",\n\t}\n\n\tfor _, cmd := range commands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif result.IsError && strings.Contains(result.ForLLM, \"blocked\") {\n\t\t\tt.Errorf(\"command should not be blocked: %s\\n  error: %s\", cmd, result.ForLLM)\n\t\t}\n\t}\n}\n\n// TestShellTool_BlockDevices verifies that writes to block devices are blocked (issue #965).\nfunc TestShellTool_BlockDevices(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tblocked := []string{\n\t\t\"echo x > /dev/sda\",\n\t\t\"echo x > /dev/hda\",\n\t\t\"echo x > /dev/vda\",\n\t\t\"echo x > /dev/xvda\",\n\t\t\"echo x > /dev/nvme0n1\",\n\t\t\"echo x > /dev/mmcblk0\",\n\t\t\"echo x > /dev/loop0\",\n\t\t\"echo x > /dev/dm-0\",\n\t\t\"echo x > /dev/md0\",\n\t\t\"echo x > /dev/sr0\",\n\t\t\"echo x > /dev/nbd0\",\n\t}\n\n\tfor _, cmd := range blocked {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif !result.IsError {\n\t\t\tt.Errorf(\"expected block device write to be blocked: %s\", cmd)\n\t\t}\n\t}\n}\n\n// TestShellTool_SafePathsInWorkspaceRestriction verifies that safe kernel pseudo-devices\n// are allowed even when workspace restriction is active.\nfunc TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, true)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\t// These reference paths outside workspace but should be allowed via safePaths.\n\tcommands := []string{\n\t\t\"cat /dev/urandom | head -c 16 | od\",\n\t\t\"echo test > /dev/null\",\n\t\t\"dd if=/dev/zero bs=1 count=1\",\n\t}\n\n\tfor _, cmd := range commands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif result.IsError && strings.Contains(result.ForLLM, \"path outside working dir\") {\n\t\t\tt.Errorf(\"safe path should not be blocked by workspace check: %s\\n  error: %s\", cmd, result.ForLLM)\n\t\t}\n\t}\n}\n\n// TestShellTool_ExitCodeDetails verifies that exit codes are captured with details\nfunc TestShellTool_ExitCodeDetails(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"command\": \"sh -c 'exit 42'\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for non-zero exit code\")\n\t}\n\n\t// Should contain the exit code in the message (new format: \"exited with code 42\")\n\tif !strings.Contains(result.ForLLM, \"42\") {\n\t\tt.Errorf(\"expected exit code 42 in error message, got: %s\", result.ForLLM)\n\t}\n\n\t// Verify the new detailed message format\n\tif !strings.Contains(result.ForLLM, \"exited with code\") {\n\t\tt.Errorf(\"expected 'exited with code' in message, got: %s\", result.ForLLM)\n\t}\n\n\t// Err field is set by the exec system (may or may not be set depending on implementation)\n\t// The important thing is that IsError=true\n\tt.Logf(\"Exit code result: %s\", result.ForLLM)\n}\n\n// TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output\nfunc TestShellTool_TimeoutWithPartialOutput(t *testing.T) {\n\ttool, err := NewExecTool(\"\", false)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\ttool.SetTimeout(1 * time.Second) // Give more time for echo to complete\n\n\tctx := context.Background()\n\t// Use a command that outputs immediately then sleeps\n\targs := map[string]any{\n\t\t\"command\": \"echo 'partial output before timeout' && sleep 30\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for timeout\")\n\t}\n\n\t// Should mention timeout\n\tif !strings.Contains(result.ForLLM, \"timed out\") {\n\t\tt.Errorf(\"expected 'timed out' in message, got: %s\", result.ForLLM)\n\t}\n\n\t// Log the result for debugging (partial output depends on shell behavior)\n\tt.Logf(\"Timeout result: %s\", result.ForLLM)\n}\n\n// TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt\n// commands from deny pattern checks.\nfunc TestShellTool_CustomAllowPatterns(t *testing.T) {\n\tcfg := &config.Config{\n\t\tTools: config.ToolsConfig{\n\t\t\tExec: config.ExecConfig{\n\t\t\t\tEnableDenyPatterns:  true,\n\t\t\t\tCustomAllowPatterns: []string{`\\bgit\\s+push\\s+origin\\b`},\n\t\t\t},\n\t\t},\n\t}\n\n\ttool, err := NewExecToolWithConfig(\"\", false, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\t// \"git push origin main\" should be allowed by custom allow pattern.\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"command\": \"git push origin main\",\n\t})\n\tif result.IsError && strings.Contains(result.ForLLM, \"blocked\") {\n\t\tt.Errorf(\"custom allow pattern should exempt 'git push origin main', got: %s\", result.ForLLM)\n\t}\n\n\t// \"git push upstream main\" should still be blocked (does not match allow pattern).\n\tresult = tool.Execute(context.Background(), map[string]any{\n\t\t\"command\": \"git push upstream main\",\n\t})\n\tif !result.IsError {\n\t\tt.Errorf(\"'git push upstream main' should still be blocked by deny pattern\")\n\t}\n}\n\n// TestShellTool_URLsNotBlocked verifies that commands containing URLs are not\n// incorrectly blocked by the workspace restriction safety guard (issue #1203).\nfunc TestShellTool_URLsNotBlocked(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, true)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\t// These commands contain URLs and should NOT be blocked by workspace restriction.\n\t// The URL path components (e.g., \"//github.com\") should be recognized as URLs,\n\t// not as file system paths.\n\tcommands := []string{\n\t\t\"agent-browser open https://github.com\",\n\t\t\"curl https://api.example.com/data\",\n\t\t\"wget http://example.com/file\",\n\t\t\"browser open https://github.com/user/repo\",\n\t\t\"fetch ftp://ftp.example.com/file.txt\",\n\t\t\"git clone https://github.com/sipeed/picoclaw.git\",\n\t}\n\n\tfor _, cmd := range commands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif result.IsError && strings.Contains(result.ForLLM, \"path outside working dir\") {\n\t\t\tt.Errorf(\"command with URL should not be blocked by workspace check: %s\\n  error: %s\", cmd, result.ForLLM)\n\t\t}\n\t}\n}\n\n// TestShellTool_FileURISandboxing verifies that file:// URIs that escape the\n// workspace are still blocked, even though other URLs are allowed (issue #1254).\nfunc TestShellTool_FileURISandboxing(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, true)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\t// These file:// URIs should be blocked if they reference paths outside the workspace.\n\t// Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox.\n\tblockedCommands := []string{\n\t\t\"cat file:///etc/passwd\",\n\t\t\"cat file:///etc/hosts\",\n\t\t\"cat file:///root/.ssh/id_rsa\",\n\t}\n\n\tfor _, cmd := range blockedCommands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif !result.IsError || !strings.Contains(result.ForLLM, \"path outside working dir\") {\n\t\t\tt.Errorf(\"file:// URI outside workspace should be blocked: %s\", cmd)\n\t\t}\n\t}\n\n\t// These file:// URIs should be allowed if they reference paths inside the workspace.\n\t// Create a test file inside the temp directory\n\ttestFile := filepath.Join(tmpDir, \"test.txt\")\n\tif err := os.WriteFile(testFile, []byte(\"test content\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to create test file: %s\", err)\n\t}\n\n\tallowedCommands := []string{\n\t\t\"cat file://\" + testFile,\n\t}\n\n\tfor _, cmd := range allowedCommands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif result.IsError && strings.Contains(result.ForLLM, \"path outside working dir\") {\n\t\t\tt.Errorf(\"file:// URI inside workspace should be allowed: %s\\n  error: %s\", cmd, result.ForLLM)\n\t\t}\n\t}\n}\n\n// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace\n// sandbox by smuggling a real path after a URL that contains the same //path substring.\n// e.g. \"echo https://etc/passwd && cat //etc/passwd\" must still be blocked.\nfunc TestShellTool_URLBypassPrevented(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttool, err := NewExecTool(tmpDir, true)\n\tif err != nil {\n\t\tt.Fatalf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\t// The path //etc/passwd appears twice: once as the host part of an https URL\n\t// and once as a real (escaped) absolute path. The guard must block the command\n\t// because the second occurrence is a genuine out-of-workspace path.\n\tblockedCommands := []string{\n\t\t\"echo https://etc/passwd && cat //etc/passwd\",\n\t\t\"curl https://host/file && ls //etc\",\n\t}\n\n\tfor _, cmd := range blockedCommands {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"command\": cmd})\n\t\tif !result.IsError || !strings.Contains(result.ForLLM, \"path outside working dir\") {\n\t\t\tt.Errorf(\"bypass attempt should be blocked: %q\\n  got: %s\", cmd, result.ForLLM)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/shell_timeout_unix_test.go",
    "content": "//go:build !windows\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc processExists(pid int) bool {\n\tif pid <= 0 {\n\t\treturn false\n\t}\n\terr := syscall.Kill(pid, 0)\n\treturn err == nil || err == syscall.EPERM\n}\n\nfunc TestShellTool_TimeoutKillsChildProcess(t *testing.T) {\n\ttool, err := NewExecTool(t.TempDir(), false)\n\tif err != nil {\n\t\tt.Errorf(\"unable to configure exec tool: %s\", err)\n\t}\n\n\ttool.SetTimeout(500 * time.Millisecond)\n\n\targs := map[string]any{\n\t\t// Spawn a child process that would outlive the shell unless process-group kill is used.\n\t\t\"command\": \"sleep 60 & echo $! > child.pid; wait\",\n\t}\n\n\tresult := tool.Execute(context.Background(), args)\n\tif !result.IsError {\n\t\tt.Fatalf(\"expected timeout error, got success: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"timed out\") {\n\t\tt.Fatalf(\"expected timeout message, got: %s\", result.ForLLM)\n\t}\n\n\tchildPIDPath := filepath.Join(tool.workingDir, \"child.pid\")\n\tdata, err := os.ReadFile(childPIDPath)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to read child pid file: %v\", err)\n\t}\n\n\tchildPID, err := strconv.Atoi(strings.TrimSpace(string(data)))\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse child pid: %v\", err)\n\t}\n\n\tdeadline := time.Now().Add(2 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\tif !processExists(childPID) {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\n\tt.Fatalf(\"child process %d is still running after timeout\", childPID)\n}\n"
  },
  {
    "path": "pkg/tools/skills_install.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/fileutil\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// InstallSkillTool allows the LLM agent to install skills from registries.\n// It shares the same RegistryManager that FindSkillsTool uses,\n// so all registries configured in config are available for installation.\ntype InstallSkillTool struct {\n\tregistryMgr *skills.RegistryManager\n\tworkspace   string\n\tmu          sync.Mutex\n}\n\n// NewInstallSkillTool creates a new InstallSkillTool.\n// registryMgr is the shared registry manager (same instance as FindSkillsTool).\n// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.\nfunc NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {\n\treturn &InstallSkillTool{\n\t\tregistryMgr: registryMgr,\n\t\tworkspace:   workspace,\n\t\tmu:          sync.Mutex{},\n\t}\n}\n\nfunc (t *InstallSkillTool) Name() string {\n\treturn \"install_skill\"\n}\n\nfunc (t *InstallSkillTool) Description() string {\n\treturn \"Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills.\"\n}\n\nfunc (t *InstallSkillTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"slug\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The unique slug of the skill to install (e.g., 'github', 'docker-compose')\",\n\t\t\t},\n\t\t\t\"version\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Specific version to install (optional, defaults to latest)\",\n\t\t\t},\n\t\t\t\"registry\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Registry to install from (required, e.g., 'clawhub')\",\n\t\t\t},\n\t\t\t\"force\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"Force reinstall if skill already exists (default false)\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"slug\", \"registry\"},\n\t}\n}\n\nfunc (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\t// Install lock to prevent concurrent directory operations.\n\t// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\n\t// Validate slug\n\tslug, _ := args[\"slug\"].(string)\n\tif err := utils.ValidateSkillIdentifier(slug); err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"invalid slug %q: error: %s\", slug, err.Error()))\n\t}\n\n\t// Validate registry\n\tregistryName, _ := args[\"registry\"].(string)\n\tif err := utils.ValidateSkillIdentifier(registryName); err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"invalid registry %q: error: %s\", registryName, err.Error()))\n\t}\n\n\tversion, _ := args[\"version\"].(string)\n\tforce, _ := args[\"force\"].(bool)\n\n\t// Check if already installed.\n\tskillsDir := filepath.Join(t.workspace, \"skills\")\n\ttargetDir := filepath.Join(skillsDir, slug)\n\n\tif !force {\n\t\tif _, err := os.Stat(targetDir); err == nil {\n\t\t\treturn ErrorResult(\n\t\t\t\tfmt.Sprintf(\"skill %q already installed at %s. Use force=true to reinstall.\", slug, targetDir),\n\t\t\t)\n\t\t}\n\t} else {\n\t\t// Force: remove existing if present.\n\t\tos.RemoveAll(targetDir)\n\t}\n\n\t// Resolve which registry to use.\n\tregistry := t.registryMgr.GetRegistry(registryName)\n\tif registry == nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"registry %q not found\", registryName))\n\t}\n\n\t// Ensure skills directory exists.\n\tif err := os.MkdirAll(skillsDir, 0o755); err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to create skills directory: %v\", err))\n\t}\n\n\t// Download and install (handles metadata, version resolution, extraction).\n\tresult, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)\n\tif err != nil {\n\t\t// Clean up partial install.\n\t\trmErr := os.RemoveAll(targetDir)\n\t\tif rmErr != nil {\n\t\t\tlogger.ErrorCF(\"tool\", \"Failed to remove partial install\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"tool\":       \"install_skill\",\n\t\t\t\t\t\"target_dir\": targetDir,\n\t\t\t\t\t\"error\":      rmErr.Error(),\n\t\t\t\t})\n\t\t}\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to install %q: %v\", slug, err))\n\t}\n\n\t// Moderation: block malware.\n\tif result.IsMalwareBlocked {\n\t\trmErr := os.RemoveAll(targetDir)\n\t\tif rmErr != nil {\n\t\t\tlogger.ErrorCF(\"tool\", \"Failed to remove partial install\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"tool\":       \"install_skill\",\n\t\t\t\t\t\"target_dir\": targetDir,\n\t\t\t\t\t\"error\":      rmErr.Error(),\n\t\t\t\t})\n\t\t}\n\t\treturn ErrorResult(fmt.Sprintf(\"skill %q is flagged as malicious and cannot be installed\", slug))\n\t}\n\n\t// Write origin metadata.\n\tif err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {\n\t\tlogger.ErrorCF(\"tool\", \"Failed to write origin metadata\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tool\":     \"install_skill\",\n\t\t\t\t\"error\":    err.Error(),\n\t\t\t\t\"target\":   targetDir,\n\t\t\t\t\"registry\": registry.Name(),\n\t\t\t\t\"slug\":     slug,\n\t\t\t\t\"version\":  result.Version,\n\t\t\t})\n\t\t_ = err\n\t}\n\n\t// Build result with moderation warning if suspicious.\n\tvar output string\n\tif result.IsSuspicious {\n\t\toutput = fmt.Sprintf(\"⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\\n\\n\", slug)\n\t}\n\toutput += fmt.Sprintf(\"Successfully installed skill %q v%s from %s registry.\\nLocation: %s\\n\",\n\t\tslug, result.Version, registry.Name(), targetDir)\n\n\tif result.Summary != \"\" {\n\t\toutput += fmt.Sprintf(\"Description: %s\\n\", result.Summary)\n\t}\n\toutput += \"\\nThe skill is now available and can be loaded in the current session.\"\n\n\treturn SilentResult(output)\n}\n\n// originMeta tracks which registry a skill was installed from.\ntype originMeta struct {\n\tVersion          int    `json:\"version\"`\n\tRegistry         string `json:\"registry\"`\n\tSlug             string `json:\"slug\"`\n\tInstalledVersion string `json:\"installed_version\"`\n\tInstalledAt      int64  `json:\"installed_at\"`\n}\n\nfunc writeOriginMeta(targetDir, registryName, slug, version string) error {\n\tmeta := originMeta{\n\t\tVersion:          1,\n\t\tRegistry:         registryName,\n\t\tSlug:             slug,\n\t\tInstalledVersion: version,\n\t\tInstalledAt:      time.Now().UnixMilli(),\n\t}\n\n\tdata, err := json.MarshalIndent(meta, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Use unified atomic write utility with explicit sync for flash storage reliability.\n\treturn fileutil.WriteFileAtomic(filepath.Join(targetDir, \".skill-origin.json\"), data, 0o600)\n}\n"
  },
  {
    "path": "pkg/tools/skills_install_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc TestInstallSkillToolName(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\tassert.Equal(t, \"install_skill\", tool.Name())\n}\n\nfunc TestInstallSkillToolMissingSlug(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"identifier is required and must be a non-empty string\")\n}\n\nfunc TestInstallSkillToolEmptySlug(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"slug\": \"   \",\n\t})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"identifier is required and must be a non-empty string\")\n}\n\nfunc TestInstallSkillToolUnsafeSlug(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\n\tcases := []string{\n\t\t\"../etc/passwd\",\n\t\t\"path/traversal\",\n\t\t\"path\\\\traversal\",\n\t}\n\n\tfor _, slug := range cases {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\t\"slug\": slug,\n\t\t})\n\t\tassert.True(t, result.IsError, \"slug %q should be rejected\", slug)\n\t\tassert.Contains(t, result.ForLLM, \"invalid slug\")\n\t}\n}\n\nfunc TestInstallSkillToolAlreadyExists(t *testing.T) {\n\tworkspace := t.TempDir()\n\tskillDir := filepath.Join(workspace, \"skills\", \"existing-skill\")\n\trequire.NoError(t, os.MkdirAll(skillDir, 0o755))\n\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"slug\":     \"existing-skill\",\n\t\t\"registry\": \"clawhub\",\n\t})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"already installed\")\n}\n\nfunc TestInstallSkillToolRegistryNotFound(t *testing.T) {\n\tworkspace := t.TempDir()\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"slug\":     \"some-skill\",\n\t\t\"registry\": \"nonexistent\",\n\t})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"registry\")\n\tassert.Contains(t, result.ForLLM, \"not found\")\n}\n\nfunc TestInstallSkillToolParameters(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\tparams := tool.Parameters()\n\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Contains(t, props, \"slug\")\n\tassert.Contains(t, props, \"version\")\n\tassert.Contains(t, props, \"registry\")\n\tassert.Contains(t, props, \"force\")\n\n\trequired, ok := params[\"required\"].([]string)\n\tassert.True(t, ok)\n\tassert.Contains(t, required, \"slug\")\n\tassert.Contains(t, required, \"registry\")\n}\n\nfunc TestInstallSkillToolMissingRegistry(t *testing.T) {\n\ttool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"slug\": \"some-skill\",\n\t})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"invalid registry\")\n}\n"
  },
  {
    "path": "pkg/tools/skills_search.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\n// FindSkillsTool allows the LLM agent to search for installable skills from registries.\ntype FindSkillsTool struct {\n\tregistryMgr *skills.RegistryManager\n\tcache       *skills.SearchCache\n}\n\n// NewFindSkillsTool creates a new FindSkillsTool.\n// registryMgr is the shared registry manager (built from config in createToolRegistry).\n// cache is the search cache for deduplicating similar queries.\nfunc NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {\n\treturn &FindSkillsTool{\n\t\tregistryMgr: registryMgr,\n\t\tcache:       cache,\n\t}\n}\n\nfunc (t *FindSkillsTool) Name() string {\n\treturn \"find_skills\"\n}\n\nfunc (t *FindSkillsTool) Description() string {\n\treturn \"Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill.\"\n}\n\nfunc (t *FindSkillsTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"query\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Search query describing the desired skill capability (e.g., 'github integration', 'database management')\",\n\t\t\t},\n\t\t\t\"limit\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Maximum number of results to return (1-20, default 5)\",\n\t\t\t\t\"minimum\":     1.0,\n\t\t\t\t\"maximum\":     20.0,\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"query\"},\n\t}\n}\n\nfunc (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tquery, ok := args[\"query\"].(string)\n\tquery = strings.ToLower(strings.TrimSpace(query))\n\tif !ok || query == \"\" {\n\t\treturn ErrorResult(\"query is required and must be a non-empty string\")\n\t}\n\n\tlimit := 5\n\tif l, ok := args[\"limit\"].(float64); ok {\n\t\tli := int(l)\n\t\tif li >= 1 && li <= 20 {\n\t\t\tlimit = li\n\t\t}\n\t}\n\n\t// Check cache first.\n\tif t.cache != nil {\n\t\tif cached, hit := t.cache.Get(query); hit {\n\t\t\treturn SilentResult(formatSearchResults(query, cached, true))\n\t\t}\n\t}\n\n\t// Search all registries.\n\tresults, err := t.registryMgr.SearchAll(ctx, query, limit)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"skill search failed: %v\", err))\n\t}\n\n\t// Cache the results.\n\tif t.cache != nil && len(results) > 0 {\n\t\tt.cache.Put(query, results)\n\t}\n\n\treturn SilentResult(formatSearchResults(query, results, false))\n}\n\nfunc formatSearchResults(query string, results []skills.SearchResult, cached bool) string {\n\tif len(results) == 0 {\n\t\treturn fmt.Sprintf(\"No skills found for query: %q\", query)\n\t}\n\n\tvar sb strings.Builder\n\tsource := \"\"\n\tif cached {\n\t\tsource = \" (cached)\"\n\t}\n\tsb.WriteString(fmt.Sprintf(\"Found %d skills for %q%s:\\n\\n\", len(results), query, source))\n\n\tfor i, r := range results {\n\t\tsb.WriteString(fmt.Sprintf(\"%d. **%s**\", i+1, r.Slug))\n\t\tif r.Version != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\" v%s\", r.Version))\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"  (score: %.3f, registry: %s)\\n\", r.Score, r.RegistryName))\n\t\tif r.DisplayName != \"\" && r.DisplayName != r.Slug {\n\t\t\tsb.WriteString(fmt.Sprintf(\"   Name: %s\\n\", r.DisplayName))\n\t\t}\n\t\tif r.Summary != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\"   %s\\n\", r.Summary))\n\t\t}\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\tsb.WriteString(\"Use install_skill with the slug to install a skill.\")\n\treturn sb.String()\n}\n"
  },
  {
    "path": "pkg/tools/skills_search_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\nfunc TestFindSkillsToolName(t *testing.T) {\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), nil)\n\tassert.Equal(t, \"find_skills\", tool.Name())\n}\n\nfunc TestFindSkillsToolMissingQuery(t *testing.T) {\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), nil)\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tassert.True(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"query is required\")\n}\n\nfunc TestFindSkillsToolEmptyQuery(t *testing.T) {\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), nil)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"query\": \"   \",\n\t})\n\tassert.True(t, result.IsError)\n}\n\nfunc TestFindSkillsToolCacheHit(t *testing.T) {\n\tcache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min\n\tcache.Put(\"github\", []skills.SearchResult{\n\t\t{Slug: \"github\", Score: 0.9, RegistryName: \"clawhub\"},\n\t})\n\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), cache)\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"query\": \"github\",\n\t})\n\n\tassert.False(t, result.IsError)\n\tassert.Contains(t, result.ForLLM, \"github\")\n\tassert.Contains(t, result.ForLLM, \"cached\")\n}\n\nfunc TestFindSkillsToolParameters(t *testing.T) {\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), nil)\n\tparams := tool.Parameters()\n\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Contains(t, props, \"query\")\n\tassert.Contains(t, props, \"limit\")\n\n\trequired, ok := params[\"required\"].([]string)\n\tassert.True(t, ok)\n\tassert.Contains(t, required, \"query\")\n}\n\nfunc TestFindSkillsToolDescription(t *testing.T) {\n\ttool := NewFindSkillsTool(skills.NewRegistryManager(), nil)\n\tassert.NotEmpty(t, tool.Description())\n\tassert.Contains(t, tool.Description(), \"skill\")\n}\n\nfunc TestFormatSearchResultsEmpty(t *testing.T) {\n\tresult := formatSearchResults(\"test query\", nil, false)\n\tassert.Contains(t, result, \"No skills found\")\n}\n\nfunc TestFormatSearchResultsWithData(t *testing.T) {\n\tresults := []skills.SearchResult{\n\t\t{\n\t\t\tSlug:         \"github\",\n\t\t\tScore:        0.95,\n\t\t\tDisplayName:  \"GitHub\",\n\t\t\tSummary:      \"GitHub API integration\",\n\t\t\tVersion:      \"1.0.0\",\n\t\t\tRegistryName: \"clawhub\",\n\t\t},\n\t}\n\toutput := formatSearchResults(\"github\", results, false)\n\tassert.Contains(t, output, \"github\")\n\tassert.Contains(t, output, \"v1.0.0\")\n\tassert.Contains(t, output, \"0.950\")\n\tassert.Contains(t, output, \"clawhub\")\n\tassert.Contains(t, output, \"install_skill\")\n}\n"
  },
  {
    "path": "pkg/tools/spawn.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype SpawnTool struct {\n\tmanager        *SubagentManager\n\tallowlistCheck func(targetAgentID string) bool\n}\n\n// Compile-time check: SpawnTool implements AsyncExecutor.\nvar _ AsyncExecutor = (*SpawnTool)(nil)\n\nfunc NewSpawnTool(manager *SubagentManager) *SpawnTool {\n\treturn &SpawnTool{\n\t\tmanager: manager,\n\t}\n}\n\nfunc (t *SpawnTool) Name() string {\n\treturn \"spawn\"\n}\n\nfunc (t *SpawnTool) Description() string {\n\treturn \"Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done.\"\n}\n\nfunc (t *SpawnTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"task\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The task for subagent to complete\",\n\t\t\t},\n\t\t\t\"label\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional short label for the task (for display)\",\n\t\t\t},\n\t\t\t\"agent_id\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional target agent ID to delegate the task to\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"task\"},\n\t}\n}\n\nfunc (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {\n\tt.allowlistCheck = check\n}\n\nfunc (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\treturn t.execute(ctx, args, nil)\n}\n\n// ExecuteAsync implements AsyncExecutor. The callback is passed through to the\n// subagent manager as a call parameter — never stored on the SpawnTool instance.\nfunc (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {\n\treturn t.execute(ctx, args, cb)\n}\n\nfunc (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {\n\ttask, ok := args[\"task\"].(string)\n\tif !ok || strings.TrimSpace(task) == \"\" {\n\t\treturn ErrorResult(\"task is required and must be a non-empty string\")\n\t}\n\n\tlabel, _ := args[\"label\"].(string)\n\tagentID, _ := args[\"agent_id\"].(string)\n\n\t// Check allowlist if targeting a specific agent\n\tif agentID != \"\" && t.allowlistCheck != nil {\n\t\tif !t.allowlistCheck(agentID) {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"not allowed to spawn agent '%s'\", agentID))\n\t\t}\n\t}\n\n\tif t.manager == nil {\n\t\treturn ErrorResult(\"Subagent manager not configured\")\n\t}\n\n\t// Read channel/chatID from context (injected by registry).\n\t// Fall back to \"cli\"/\"direct\" for non-conversation callers (e.g., CLI, tests)\n\t// to preserve the same defaults as the original NewSpawnTool constructor.\n\tchannel := ToolChannel(ctx)\n\tif channel == \"\" {\n\t\tchannel = \"cli\"\n\t}\n\tchatID := ToolChatID(ctx)\n\tif chatID == \"\" {\n\t\tchatID = \"direct\"\n\t}\n\n\t// Pass callback to manager for async completion notification\n\tresult, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to spawn subagent: %v\", err))\n\t}\n\n\t// Return AsyncResult since the task runs in background\n\treturn AsyncResult(result)\n}\n"
  },
  {
    "path": "pkg/tools/spawn_status.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n)\n\n// SpawnStatusTool reports the status of subagents that were spawned via the\n// spawn tool. It can query a specific task by ID, or list every known task with\n// a summary count broken-down by status.\ntype SpawnStatusTool struct {\n\tmanager *SubagentManager\n}\n\n// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager.\nfunc NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool {\n\treturn &SpawnStatusTool{manager: manager}\n}\n\nfunc (t *SpawnStatusTool) Name() string {\n\treturn \"spawn_status\"\n}\n\nfunc (t *SpawnStatusTool) Description() string {\n\treturn \"Get the status of spawned subagents. \" +\n\t\t\"Returns a list of all subagents and their current state \" +\n\t\t\"(running, completed, failed, or canceled), or retrieves details \" +\n\t\t\"for a specific subagent task when task_id is provided. \" +\n\t\t\"Results are scoped to the current conversation's channel and chat ID; \" +\n\t\t\"all tasks are listed only when no channel/chat context is injected \" +\n\t\t\"(e.g. direct programmatic calls via Execute).\"\n}\n\nfunc (t *SpawnStatusTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"task_id\": map[string]any{\n\t\t\t\t\"type\": \"string\",\n\t\t\t\t\"description\": \"Optional task ID (e.g. \\\"subagent-1\\\") to inspect a specific \" +\n\t\t\t\t\t\"subagent. When omitted, all visible subagents are listed.\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{},\n\t}\n}\n\nfunc (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tif t.manager == nil {\n\t\treturn ErrorResult(\"Subagent manager not configured\")\n\t}\n\n\t// Derive the calling conversation's identity so we can scope results to the\n\t// current chat only — preventing cross-conversation task leakage in\n\t// multi-user deployments.\n\tcallerChannel := ToolChannel(ctx)\n\tcallerChatID := ToolChatID(ctx)\n\n\tvar taskID string\n\tif rawTaskID, ok := args[\"task_id\"]; ok && rawTaskID != nil {\n\t\ttaskIDStr, ok := rawTaskID.(string)\n\t\tif !ok {\n\t\t\treturn ErrorResult(\"task_id must be a string\")\n\t\t}\n\t\ttaskID = strings.TrimSpace(taskIDStr)\n\t}\n\n\tif taskID != \"\" {\n\t\t// GetTaskCopy returns a consistent snapshot under the manager lock,\n\t\t// eliminating any data race with the concurrent subagent goroutine.\n\t\ttaskCopy, ok := t.manager.GetTaskCopy(taskID)\n\t\tif !ok {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"No subagent found with task ID: %s\", taskID))\n\t\t}\n\n\t\t// Restrict lookup to tasks that belong to this conversation.\n\t\tif callerChannel != \"\" && taskCopy.OriginChannel != \"\" && taskCopy.OriginChannel != callerChannel {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"No subagent found with task ID: %s\", taskID))\n\t\t}\n\t\tif callerChatID != \"\" && taskCopy.OriginChatID != \"\" && taskCopy.OriginChatID != callerChatID {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"No subagent found with task ID: %s\", taskID))\n\t\t}\n\n\t\treturn NewToolResult(spawnStatusFormatTask(&taskCopy))\n\t}\n\n\t// ListTaskCopies returns consistent snapshots under the manager lock.\n\torigTasks := t.manager.ListTaskCopies()\n\tif len(origTasks) == 0 {\n\t\treturn NewToolResult(\"No subagents have been spawned yet.\")\n\t}\n\n\ttasks := make([]*SubagentTask, 0, len(origTasks))\n\tfor i := range origTasks {\n\t\tcpy := &origTasks[i]\n\n\t\t// Filter to tasks that originate from the current conversation only.\n\t\tif callerChannel != \"\" && cpy.OriginChannel != \"\" && cpy.OriginChannel != callerChannel {\n\t\t\tcontinue\n\t\t}\n\t\tif callerChatID != \"\" && cpy.OriginChatID != \"\" && cpy.OriginChatID != callerChatID {\n\t\t\tcontinue\n\t\t}\n\n\t\ttasks = append(tasks, cpy)\n\t}\n\n\tif len(tasks) == 0 {\n\t\treturn NewToolResult(\"No subagents found for this conversation.\")\n\t}\n\n\t// Order by creation time (ascending) so spawning order is preserved.\n\t// Fall back to ID string for tasks created in the same millisecond.\n\tsort.Slice(tasks, func(i, j int) bool {\n\t\tif tasks[i].Created != tasks[j].Created {\n\t\t\treturn tasks[i].Created < tasks[j].Created\n\t\t}\n\t\treturn tasks[i].ID < tasks[j].ID\n\t})\n\n\tcounts := map[string]int{}\n\tfor _, task := range tasks {\n\t\tcounts[task.Status]++\n\t}\n\n\tvar sb strings.Builder\n\tsb.WriteString(fmt.Sprintf(\"Subagent status report (%d total):\\n\", len(tasks)))\n\tfor _, status := range []string{\"running\", \"completed\", \"failed\", \"canceled\"} {\n\t\tif n := counts[status]; n > 0 {\n\t\t\tlabel := strings.ToUpper(status[:1]) + status[1:] + \":\"\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %-10s %d\\n\", label, n))\n\t\t}\n\t}\n\tsb.WriteString(\"\\n\")\n\n\tfor _, task := range tasks {\n\t\tsb.WriteString(spawnStatusFormatTask(task))\n\t\tsb.WriteString(\"\\n\\n\")\n\t}\n\n\treturn NewToolResult(strings.TrimRight(sb.String(), \"\\n\"))\n}\n\n// spawnStatusFormatTask renders a single SubagentTask as a human-readable block.\nfunc spawnStatusFormatTask(task *SubagentTask) string {\n\tvar sb strings.Builder\n\n\theader := fmt.Sprintf(\"[%s] status=%s\", task.ID, task.Status)\n\tif task.Label != \"\" {\n\t\theader += fmt.Sprintf(\"  label=%q\", task.Label)\n\t}\n\tif task.AgentID != \"\" {\n\t\theader += fmt.Sprintf(\"  agent=%s\", task.AgentID)\n\t}\n\tif task.Created > 0 {\n\t\tcreated := time.UnixMilli(task.Created).UTC().Format(\"2006-01-02 15:04:05 UTC\")\n\t\theader += fmt.Sprintf(\"  created=%s\", created)\n\t}\n\tsb.WriteString(header)\n\n\tif task.Task != \"\" {\n\t\tsb.WriteString(fmt.Sprintf(\"\\n  task:   %s\", task.Task))\n\t}\n\tif task.Result != \"\" {\n\t\tresult := task.Result\n\t\tconst maxResultLen = 300\n\t\trunes := []rune(result)\n\t\tif len(runes) > maxResultLen {\n\t\t\tresult = string(runes[:maxResultLen]) + \"…\"\n\t\t}\n\t\tsb.WriteString(fmt.Sprintf(\"\\n  result: %s\", result))\n\t}\n\n\treturn sb.String()\n}\n"
  },
  {
    "path": "pkg/tools/spawn_status_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestSpawnStatusTool_Name(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tworkspace := t.TempDir()\n\tmanager := NewSubagentManager(provider, \"test-model\", workspace)\n\ttool := NewSpawnStatusTool(manager)\n\n\tif tool.Name() != \"spawn_status\" {\n\t\tt.Errorf(\"Expected name 'spawn_status', got '%s'\", tool.Name())\n\t}\n}\n\nfunc TestSpawnStatusTool_Description(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tworkspace := t.TempDir()\n\tmanager := NewSubagentManager(provider, \"test-model\", workspace)\n\ttool := NewSpawnStatusTool(manager)\n\n\tdesc := tool.Description()\n\tif desc == \"\" {\n\t\tt.Error(\"Description should not be empty\")\n\t}\n\tif !strings.Contains(strings.ToLower(desc), \"subagent\") {\n\t\tt.Errorf(\"Description should mention 'subagent', got: %s\", desc)\n\t}\n}\n\nfunc TestSpawnStatusTool_Parameters(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tworkspace := t.TempDir()\n\tmanager := NewSubagentManager(provider, \"test-model\", workspace)\n\ttool := NewSpawnStatusTool(manager)\n\n\tparams := tool.Parameters()\n\tif params[\"type\"] != \"object\" {\n\t\tt.Errorf(\"Expected type 'object', got: %v\", params[\"type\"])\n\t}\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Expected 'properties' to be a map\")\n\t}\n\tif _, hasTaskID := props[\"task_id\"]; !hasTaskID {\n\t\tt.Error(\"Expected 'task_id' parameter in properties\")\n\t}\n}\n\nfunc TestSpawnStatusTool_NilManager(t *testing.T) {\n\ttool := &SpawnStatusTool{manager: nil}\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tif !result.IsError {\n\t\tt.Error(\"Expected error result when manager is nil\")\n\t}\n}\n\nfunc TestSpawnStatusTool_Empty(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tworkspace := t.TempDir()\n\tmanager := NewSubagentManager(provider, \"test-model\", workspace)\n\ttool := NewSpawnStatusTool(manager)\n\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tif result.IsError {\n\t\tt.Fatalf(\"Expected success, got error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"No subagents\") {\n\t\tt.Errorf(\"Expected 'No subagents' message, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_ListAll(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tworkspace := t.TempDir()\n\tmanager := NewSubagentManager(provider, \"test-model\", workspace)\n\n\tnow := time.Now().UnixMilli()\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-1\"] = &SubagentTask{\n\t\tID:      \"subagent-1\",\n\t\tTask:    \"Do task A\",\n\t\tLabel:   \"task-a\",\n\t\tStatus:  \"running\",\n\t\tCreated: now,\n\t}\n\tmanager.tasks[\"subagent-2\"] = &SubagentTask{\n\t\tID:      \"subagent-2\",\n\t\tTask:    \"Do task B\",\n\t\tLabel:   \"task-b\",\n\t\tStatus:  \"completed\",\n\t\tResult:  \"Done successfully\",\n\t\tCreated: now,\n\t}\n\tmanager.tasks[\"subagent-3\"] = &SubagentTask{\n\t\tID:     \"subagent-3\",\n\t\tTask:   \"Do task C\",\n\t\tStatus: \"failed\",\n\t\tResult: \"Error: something went wrong\",\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Expected success, got error: %s\", result.ForLLM)\n\t}\n\n\t// Summary header\n\tif !strings.Contains(result.ForLLM, \"3 total\") {\n\t\tt.Errorf(\"Expected total count in header, got: %s\", result.ForLLM)\n\t}\n\n\t// Individual task IDs\n\tfor _, id := range []string{\"subagent-1\", \"subagent-2\", \"subagent-3\"} {\n\t\tif !strings.Contains(result.ForLLM, id) {\n\t\t\tt.Errorf(\"Expected task %s in output, got:\\n%s\", id, result.ForLLM)\n\t\t}\n\t}\n\n\t// Status values\n\tfor _, status := range []string{\"running\", \"completed\", \"failed\"} {\n\t\tif !strings.Contains(result.ForLLM, status) {\n\t\t\tt.Errorf(\"Expected status '%s' in output, got:\\n%s\", status, result.ForLLM)\n\t\t}\n\t}\n\n\t// Result content\n\tif !strings.Contains(result.ForLLM, \"Done successfully\") {\n\t\tt.Errorf(\"Expected result text in output, got:\\n%s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_GetByID(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-42\"] = &SubagentTask{\n\t\tID:      \"subagent-42\",\n\t\tTask:    \"Specific task\",\n\t\tLabel:   \"my-task\",\n\t\tStatus:  \"failed\",\n\t\tResult:  \"Something went wrong\",\n\t\tCreated: time.Now().UnixMilli(),\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{\"task_id\": \"subagent-42\"})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Expected success, got error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"subagent-42\") {\n\t\tt.Errorf(\"Expected task ID in output, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"failed\") {\n\t\tt.Errorf(\"Expected status 'failed' in output, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Something went wrong\") {\n\t\tt.Errorf(\"Expected result text in output, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"my-task\") {\n\t\tt.Errorf(\"Expected label in output, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_GetByID_NotFound(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSpawnStatusTool(manager)\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"task_id\": \"nonexistent-999\"})\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for nonexistent task, got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"nonexistent-999\") {\n\t\tt.Errorf(\"Expected task ID in error message, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_TaskID_NonString(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSpawnStatusTool(manager)\n\n\tfor _, badVal := range []any{42, 3.14, true, map[string]any{\"x\": 1}, []string{\"a\"}} {\n\t\tresult := tool.Execute(context.Background(), map[string]any{\"task_id\": badVal})\n\t\tif !result.IsError {\n\t\t\tt.Errorf(\"Expected error for task_id=%T(%v), got success: %s\", badVal, badVal, result.ForLLM)\n\t\t}\n\t\tif !strings.Contains(result.ForLLM, \"task_id must be a string\") {\n\t\t\tt.Errorf(\"Expected type-error message, got: %s\", result.ForLLM)\n\t\t}\n\t}\n}\n\nfunc TestSpawnStatusTool_ResultTruncation(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tlongResult := strings.Repeat(\"X\", 500)\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-1\"] = &SubagentTask{\n\t\tID:     \"subagent-1\",\n\t\tTask:   \"Long task\",\n\t\tStatus: \"completed\",\n\t\tResult: longResult,\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{\"task_id\": \"subagent-1\"})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\t// Output should be shorter than the raw result due to truncation\n\tif len(result.ForLLM) >= len(longResult) {\n\t\tt.Errorf(\"Expected result to be truncated, but ForLLM is %d chars\", len(result.ForLLM))\n\t}\n\tif !strings.Contains(result.ForLLM, \"…\") {\n\t\tt.Errorf(\"Expected truncation indicator '…' in output, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\t// Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit.\n\tcjkChar := string(rune(0x5b57))\n\tlongResult := strings.Repeat(cjkChar, 400)\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-1\"] = &SubagentTask{\n\t\tID:     \"subagent-1\",\n\t\tTask:   \"Unicode task\",\n\t\tStatus: \"completed\",\n\t\tResult: longResult,\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{\"task_id\": \"subagent-1\"})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"…\") {\n\t\tt.Errorf(\"Expected truncation indicator in output\")\n\t}\n\t// The truncated result must be valid UTF-8 (no split rune boundaries).\n\tif !strings.Contains(result.ForLLM, cjkChar) {\n\t\tt.Errorf(\"Expected CJK runes to appear intact in output\")\n\t}\n}\n\nfunc TestSpawnStatusTool_StatusCounts(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tmanager.mu.Lock()\n\tfor i, status := range []string{\"running\", \"running\", \"completed\", \"failed\", \"canceled\"} {\n\t\tid := fmt.Sprintf(\"subagent-%d\", i+1)\n\t\tmanager.tasks[id] = &SubagentTask{ID: id, Task: \"t\", Status: status}\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\t// The summary line should mention all statuses that have counts\n\tfor _, want := range []string{\"Running:\", \"Completed:\", \"Failed:\", \"Canceled:\"} {\n\t\tif !strings.Contains(result.ForLLM, want) {\n\t\t\tt.Errorf(\"Expected %q in summary, got:\\n%s\", want, result.ForLLM)\n\t\t}\n\t}\n}\n\nfunc TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tnow := time.Now().UnixMilli()\n\tmanager.mu.Lock()\n\t// Intentionally insert with out-of-order IDs and timestamps that reflect\n\t// true spawn order: subagent-2 was spawned first, subagent-10 second.\n\tmanager.tasks[\"subagent-10\"] = &SubagentTask{\n\t\tID: \"subagent-10\", Task: \"second\", Status: \"running\",\n\t\tCreated: now + 1,\n\t}\n\tmanager.tasks[\"subagent-2\"] = &SubagentTask{\n\t\tID: \"subagent-2\", Task: \"first\", Status: \"running\",\n\t\tCreated: now,\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\n\tpos2 := strings.Index(result.ForLLM, \"subagent-2\")\n\tpos10 := strings.Index(result.ForLLM, \"subagent-10\")\n\tif pos2 < 0 || pos10 < 0 {\n\t\tt.Fatalf(\"Both task IDs should appear in output:\\n%s\", result.ForLLM)\n\t}\n\tif pos2 > pos10 {\n\t\tt.Errorf(\"Expected subagent-2 (created first) to appear before subagent-10, but got:\\n%s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-1\"] = &SubagentTask{\n\t\tID: \"subagent-1\", Task: \"mine\", Status: \"running\",\n\t\tOriginChannel: \"telegram\", OriginChatID: \"chat-A\",\n\t}\n\tmanager.tasks[\"subagent-2\"] = &SubagentTask{\n\t\tID: \"subagent-2\", Task: \"other user\", Status: \"running\",\n\t\tOriginChannel: \"telegram\", OriginChatID: \"chat-B\",\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\n\t// Caller is chat-A — should only see subagent-1.\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-A\")\n\tresult := tool.Execute(ctx, map[string]any{})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"subagent-1\") {\n\t\tt.Errorf(\"Expected own task in output, got:\\n%s\", result.ForLLM)\n\t}\n\tif strings.Contains(result.ForLLM, \"subagent-2\") {\n\t\tt.Errorf(\"Should NOT see other chat's task, got:\\n%s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-99\"] = &SubagentTask{\n\t\tID: \"subagent-99\", Task: \"secret\", Status: \"completed\", Result: \"private data\",\n\t\tOriginChannel: \"slack\", OriginChatID: \"room-Z\",\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\n\t// Different chat trying to look up subagent-99 by ID.\n\tctx := WithToolContext(context.Background(), \"slack\", \"room-OTHER\")\n\tresult := tool.Execute(ctx, map[string]any{\"task_id\": \"subagent-99\"})\n\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error (cross-chat lookup blocked), got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\n\tmanager.mu.Lock()\n\tmanager.tasks[\"subagent-1\"] = &SubagentTask{\n\t\tID: \"subagent-1\", Task: \"t\", Status: \"completed\",\n\t\tOriginChannel: \"telegram\", OriginChatID: \"chat-A\",\n\t}\n\tmanager.mu.Unlock()\n\n\ttool := NewSpawnStatusTool(manager)\n\n\t// No ToolContext injected (e.g. a direct programmatic call that bypasses\n\t// WithToolContext entirely) — callerChannel and callerChatID are both \"\".\n\t// Note: the normal CLI path uses ProcessDirectWithChannel(\"cli\", \"direct\"),\n\t// which *does* inject a non-empty context; this test covers the case where\n\t// no context injection happens at all.\n\t// The filter conditions require a non-empty caller value, so all tasks pass through.\n\tresult := tool.Execute(context.Background(), map[string]any{})\n\tif result.IsError {\n\t\tt.Fatalf(\"Unexpected error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"subagent-1\") {\n\t\tt.Errorf(\"Expected task visible from no-context caller, got:\\n%s\", result.ForLLM)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/spawn_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestSpawnTool_Execute_EmptyTask(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSpawnTool(manager)\n\n\tctx := context.Background()\n\n\ttests := []struct {\n\t\tname string\n\t\targs map[string]any\n\t}{\n\t\t{\"empty string\", map[string]any{\"task\": \"\"}},\n\t\t{\"whitespace only\", map[string]any{\"task\": \"   \"}},\n\t\t{\"tabs and newlines\", map[string]any{\"task\": \"\\t\\n  \"}},\n\t\t{\"missing task key\", map[string]any{\"label\": \"test\"}},\n\t\t{\"wrong type\", map[string]any{\"task\": 123}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tool.Execute(ctx, tt.args)\n\t\t\tif result == nil {\n\t\t\t\tt.Fatal(\"Result should not be nil\")\n\t\t\t}\n\t\t\tif !result.IsError {\n\t\t\t\tt.Error(\"Expected error for invalid task parameter\")\n\t\t\t}\n\t\t\tif !strings.Contains(result.ForLLM, \"task is required\") {\n\t\t\t\tt.Errorf(\"Error message should mention 'task is required', got: %s\", result.ForLLM)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSpawnTool_Execute_ValidTask(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSpawnTool(manager)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"task\":  \"Write a haiku about coding\",\n\t\t\"label\": \"haiku-task\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\tif result == nil {\n\t\tt.Fatal(\"Result should not be nil\")\n\t}\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success for valid task, got error: %s\", result.ForLLM)\n\t}\n\tif !result.Async {\n\t\tt.Error(\"SpawnTool should return async result\")\n\t}\n}\n\nfunc TestSpawnTool_Execute_NilManager(t *testing.T) {\n\ttool := NewSpawnTool(nil)\n\n\tctx := context.Background()\n\targs := map[string]any{\"task\": \"test task\"}\n\n\tresult := tool.Execute(ctx, args)\n\tif !result.IsError {\n\t\tt.Error(\"Expected error for nil manager\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"Subagent manager not configured\") {\n\t\tt.Errorf(\"Error message should mention manager not configured, got: %s\", result.ForLLM)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/spi.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n)\n\n// SPITool provides SPI bus interaction for high-speed peripheral communication.\ntype SPITool struct{}\n\nfunc NewSPITool() *SPITool {\n\treturn &SPITool{}\n}\n\nfunc (t *SPITool) Name() string {\n\treturn \"spi\"\n}\n\nfunc (t *SPITool) Description() string {\n\treturn \"Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only.\"\n}\n\nfunc (t *SPITool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"action\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"enum\":        []string{\"list\", \"transfer\", \"read\"},\n\t\t\t\t\"description\": \"Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)\",\n\t\t\t},\n\t\t\t\"device\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"SPI device identifier (e.g. \\\"2.0\\\" for /dev/spidev2.0). Required for transfer/read.\",\n\t\t\t},\n\t\t\t\"speed\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"SPI clock speed in Hz. Default: 1000000 (1 MHz).\",\n\t\t\t},\n\t\t\t\"mode\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.\",\n\t\t\t},\n\t\t\t\"bits\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Bits per word. Default: 8.\",\n\t\t\t},\n\t\t\t\"data\": map[string]any{\n\t\t\t\t\"type\":        \"array\",\n\t\t\t\t\"items\":       map[string]any{\"type\": \"integer\"},\n\t\t\t\t\"description\": \"Bytes to send (0-255 each). Required for transfer action.\",\n\t\t\t},\n\t\t\t\"length\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Number of bytes to read (1-4096). Required for read action.\",\n\t\t\t},\n\t\t\t\"confirm\": map[string]any{\n\t\t\t\t\"type\":        \"boolean\",\n\t\t\t\t\"description\": \"Must be true for transfer operations. Safety guard to prevent accidental writes.\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"action\"},\n\t}\n}\n\nfunc (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tif runtime.GOOS != \"linux\" {\n\t\treturn ErrorResult(\"SPI is only supported on Linux. This tool requires /dev/spidev* device files.\")\n\t}\n\n\taction, ok := args[\"action\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"action is required\")\n\t}\n\n\tswitch action {\n\tcase \"list\":\n\t\treturn t.list()\n\tcase \"transfer\":\n\t\treturn t.transfer(args)\n\tcase \"read\":\n\t\treturn t.readDevice(args)\n\tdefault:\n\t\treturn ErrorResult(fmt.Sprintf(\"unknown action: %s (valid: list, transfer, read)\", action))\n\t}\n}\n\n// list finds available SPI devices by globbing /dev/spidev*\nfunc (t *SPITool) list() *ToolResult {\n\tmatches, err := filepath.Glob(\"/dev/spidev*\")\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"failed to scan for SPI devices: %v\", err))\n\t}\n\n\tif len(matches) == 0 {\n\t\treturn SilentResult(\n\t\t\t\"No SPI devices found. You may need to:\\n1. Enable SPI in device tree\\n2. Configure pinmux for your board (see hardware skill)\\n3. Check that spidev module is loaded\",\n\t\t)\n\t}\n\n\ttype devInfo struct {\n\t\tPath   string `json:\"path\"`\n\t\tDevice string `json:\"device\"`\n\t}\n\n\tdevices := make([]devInfo, 0, len(matches))\n\tre := regexp.MustCompile(`/dev/spidev(\\d+\\.\\d+)`)\n\tfor _, m := range matches {\n\t\tif sub := re.FindStringSubmatch(m); sub != nil {\n\t\t\tdevices = append(devices, devInfo{Path: m, Device: sub[1]})\n\t\t}\n\t}\n\n\tresult, _ := json.MarshalIndent(devices, \"\", \"  \")\n\treturn SilentResult(fmt.Sprintf(\"Found %d SPI device(s):\\n%s\", len(devices), string(result)))\n}\n\n// Helper function for SPI operations (used by platform-specific implementations)\n\n// parseSPIArgs extracts and validates common SPI parameters\n//\n//nolint:unused // Used by spi_linux.go\nfunc parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {\n\tdev, ok := args[\"device\"].(string)\n\tif !ok || dev == \"\" {\n\t\treturn \"\", 0, 0, 0, \"device is required (e.g. \\\"2.0\\\" for /dev/spidev2.0)\"\n\t}\n\tmatched, _ := regexp.MatchString(`^\\d+\\.\\d+$`, dev)\n\tif !matched {\n\t\treturn \"\", 0, 0, 0, \"invalid device identifier: must be in format \\\"X.Y\\\" (e.g. \\\"2.0\\\")\"\n\t}\n\n\tspeed = 1000000 // default 1 MHz\n\tif s, ok := args[\"speed\"].(float64); ok {\n\t\tif s < 1 || s > 125000000 {\n\t\t\treturn \"\", 0, 0, 0, \"speed must be between 1 Hz and 125 MHz\"\n\t\t}\n\t\tspeed = uint32(s)\n\t}\n\n\tmode = 0\n\tif m, ok := args[\"mode\"].(float64); ok {\n\t\tif int(m) < 0 || int(m) > 3 {\n\t\t\treturn \"\", 0, 0, 0, \"mode must be 0-3\"\n\t\t}\n\t\tmode = uint8(m)\n\t}\n\n\tbits = 8\n\tif b, ok := args[\"bits\"].(float64); ok {\n\t\tif int(b) < 1 || int(b) > 32 {\n\t\t\treturn \"\", 0, 0, 0, \"bits must be between 1 and 32\"\n\t\t}\n\t\tbits = uint8(b)\n\t}\n\n\treturn dev, speed, mode, bits, \"\"\n}\n"
  },
  {
    "path": "pkg/tools/spi_linux.go",
    "content": "package tools\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"runtime\"\n\t\"syscall\"\n\t\"unsafe\"\n)\n\n// SPI ioctl constants from Linux kernel headers.\n// Calculated from _IOW('k', nr, size) macro:\n//\n//\tdirection(1)<<30 | size<<16 | type(0x6B)<<8 | nr\nconst (\n\tspiIocWrMode        = 0x40016B01 // _IOW('k', 1, __u8)\n\tspiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8)\n\tspiIocWrMaxSpeedHz  = 0x40046B04 // _IOW('k', 4, __u32)\n\tspiIocMessage1      = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) — 32 bytes\n)\n\n// spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures).\ntype spiTransfer struct {\n\ttxBuf       uint64\n\trxBuf       uint64\n\tlength      uint32\n\tspeedHz     uint32\n\tdelayUsecs  uint16\n\tbitsPerWord uint8\n\tcsChange    uint8\n\ttxNbits     uint8\n\trxNbits     uint8\n\twordDelay   uint8\n\tpad         uint8\n}\n\n// configureSPI opens an SPI device and sets mode, bits per word, and speed\nfunc configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {\n\tfd, err := syscall.Open(devPath, syscall.O_RDWR, 0)\n\tif err != nil {\n\t\treturn -1, ErrorResult(fmt.Sprintf(\"failed to open %s: %v (check permissions and spidev module)\", devPath, err))\n\t}\n\n\t// Set SPI mode\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode)))\n\tif errno != 0 {\n\t\tsyscall.Close(fd)\n\t\treturn -1, ErrorResult(fmt.Sprintf(\"failed to set SPI mode %d: %v\", mode, errno))\n\t}\n\n\t// Set bits per word\n\t_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits)))\n\tif errno != 0 {\n\t\tsyscall.Close(fd)\n\t\treturn -1, ErrorResult(fmt.Sprintf(\"failed to set bits per word %d: %v\", bits, errno))\n\t}\n\n\t// Set max speed\n\t_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed)))\n\tif errno != 0 {\n\t\tsyscall.Close(fd)\n\t\treturn -1, ErrorResult(fmt.Sprintf(\"failed to set SPI speed %d Hz: %v\", speed, errno))\n\t}\n\n\treturn fd, nil\n}\n\n// transfer performs a full-duplex SPI transfer\nfunc (t *SPITool) transfer(args map[string]any) *ToolResult {\n\tconfirm, _ := args[\"confirm\"].(bool)\n\tif !confirm {\n\t\treturn ErrorResult(\n\t\t\t\"transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.\",\n\t\t)\n\t}\n\n\tdev, speed, mode, bits, errMsg := parseSPIArgs(args)\n\tif errMsg != \"\" {\n\t\treturn ErrorResult(errMsg)\n\t}\n\n\tdataRaw, ok := args[\"data\"].([]any)\n\tif !ok || len(dataRaw) == 0 {\n\t\treturn ErrorResult(\"data is required for transfer (array of byte values 0-255)\")\n\t}\n\tif len(dataRaw) > 4096 {\n\t\treturn ErrorResult(\"data too long: maximum 4096 bytes per SPI transfer\")\n\t}\n\n\ttxBuf := make([]byte, len(dataRaw))\n\tfor i, v := range dataRaw {\n\t\tf, ok := v.(float64)\n\t\tif !ok {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"data[%d] is not a valid byte value\", i))\n\t\t}\n\t\tb := int(f)\n\t\tif b < 0 || b > 255 {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"data[%d] = %d is out of byte range (0-255)\", i, b))\n\t\t}\n\t\ttxBuf[i] = byte(b)\n\t}\n\n\tdevPath := fmt.Sprintf(\"/dev/spidev%s\", dev)\n\tfd, errResult := configureSPI(devPath, mode, bits, speed)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\tdefer syscall.Close(fd)\n\n\trxBuf := make([]byte, len(txBuf))\n\n\txfer := spiTransfer{\n\t\ttxBuf:       uint64(uintptr(unsafe.Pointer(&txBuf[0]))),\n\t\trxBuf:       uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),\n\t\tlength:      uint32(len(txBuf)),\n\t\tspeedHz:     speed,\n\t\tbitsPerWord: bits,\n\t}\n\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer)))\n\truntime.KeepAlive(txBuf)\n\truntime.KeepAlive(rxBuf)\n\tif errno != 0 {\n\t\treturn ErrorResult(fmt.Sprintf(\"SPI transfer failed: %v\", errno))\n\t}\n\n\t// Format received bytes\n\thexBytes := make([]string, len(rxBuf))\n\tintBytes := make([]int, len(rxBuf))\n\tfor i, b := range rxBuf {\n\t\thexBytes[i] = fmt.Sprintf(\"0x%02x\", b)\n\t\tintBytes[i] = int(b)\n\t}\n\n\tresult, _ := json.MarshalIndent(map[string]any{\n\t\t\"device\":   devPath,\n\t\t\"sent\":     len(txBuf),\n\t\t\"received\": intBytes,\n\t\t\"hex\":      hexBytes,\n\t}, \"\", \"  \")\n\treturn SilentResult(string(result))\n}\n\n// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)\nfunc (t *SPITool) readDevice(args map[string]any) *ToolResult {\n\tdev, speed, mode, bits, errMsg := parseSPIArgs(args)\n\tif errMsg != \"\" {\n\t\treturn ErrorResult(errMsg)\n\t}\n\n\tlength := 0\n\tif l, ok := args[\"length\"].(float64); ok {\n\t\tlength = int(l)\n\t}\n\tif length < 1 || length > 4096 {\n\t\treturn ErrorResult(\"length is required for read (1-4096)\")\n\t}\n\n\tdevPath := fmt.Sprintf(\"/dev/spidev%s\", dev)\n\tfd, errResult := configureSPI(devPath, mode, bits, speed)\n\tif errResult != nil {\n\t\treturn errResult\n\t}\n\tdefer syscall.Close(fd)\n\n\ttxBuf := make([]byte, length) // zeros\n\trxBuf := make([]byte, length)\n\n\txfer := spiTransfer{\n\t\ttxBuf:       uint64(uintptr(unsafe.Pointer(&txBuf[0]))),\n\t\trxBuf:       uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),\n\t\tlength:      uint32(length),\n\t\tspeedHz:     speed,\n\t\tbitsPerWord: bits,\n\t}\n\n\t_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer)))\n\truntime.KeepAlive(txBuf)\n\truntime.KeepAlive(rxBuf)\n\tif errno != 0 {\n\t\treturn ErrorResult(fmt.Sprintf(\"SPI read failed: %v\", errno))\n\t}\n\n\thexBytes := make([]string, len(rxBuf))\n\tintBytes := make([]int, len(rxBuf))\n\tfor i, b := range rxBuf {\n\t\thexBytes[i] = fmt.Sprintf(\"0x%02x\", b)\n\t\tintBytes[i] = int(b)\n\t}\n\n\tresult, _ := json.MarshalIndent(map[string]any{\n\t\t\"device\": devPath,\n\t\t\"bytes\":  intBytes,\n\t\t\"hex\":    hexBytes,\n\t\t\"length\": len(rxBuf),\n\t}, \"\", \"  \")\n\treturn SilentResult(string(result))\n}\n"
  },
  {
    "path": "pkg/tools/spi_other.go",
    "content": "//go:build !linux\n\npackage tools\n\n// transfer is a stub for non-Linux platforms.\nfunc (t *SPITool) transfer(args map[string]any) *ToolResult {\n\treturn ErrorResult(\"SPI is only supported on Linux\")\n}\n\n// readDevice is a stub for non-Linux platforms.\nfunc (t *SPITool) readDevice(args map[string]any) *ToolResult {\n\treturn ErrorResult(\"SPI is only supported on Linux\")\n}\n"
  },
  {
    "path": "pkg/tools/subagent.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\ntype SubagentTask struct {\n\tID            string\n\tTask          string\n\tLabel         string\n\tAgentID       string\n\tOriginChannel string\n\tOriginChatID  string\n\tStatus        string\n\tResult        string\n\tCreated       int64\n}\n\ntype SubagentManager struct {\n\ttasks          map[string]*SubagentTask\n\tmu             sync.RWMutex\n\tprovider       providers.LLMProvider\n\tdefaultModel   string\n\tworkspace      string\n\ttools          *ToolRegistry\n\tmaxIterations  int\n\tmaxTokens      int\n\ttemperature    float64\n\thasMaxTokens   bool\n\thasTemperature bool\n\tnextID         int\n}\n\nfunc NewSubagentManager(\n\tprovider providers.LLMProvider,\n\tdefaultModel, workspace string,\n) *SubagentManager {\n\treturn &SubagentManager{\n\t\ttasks:         make(map[string]*SubagentTask),\n\t\tprovider:      provider,\n\t\tdefaultModel:  defaultModel,\n\t\tworkspace:     workspace,\n\t\ttools:         NewToolRegistry(),\n\t\tmaxIterations: 10,\n\t\tnextID:        1,\n\t}\n}\n\n// SetLLMOptions sets max tokens and temperature for subagent LLM calls.\nfunc (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tsm.maxTokens = maxTokens\n\tsm.hasMaxTokens = true\n\tsm.temperature = temperature\n\tsm.hasTemperature = true\n}\n\n// SetTools sets the tool registry for subagent execution.\n// If not set, subagent will have access to the provided tools.\nfunc (sm *SubagentManager) SetTools(tools *ToolRegistry) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tsm.tools = tools\n}\n\n// RegisterTool registers a tool for subagent execution.\nfunc (sm *SubagentManager) RegisterTool(tool Tool) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tsm.tools.Register(tool)\n}\n\nfunc (sm *SubagentManager) Spawn(\n\tctx context.Context,\n\ttask, label, agentID, originChannel, originChatID string,\n\tcallback AsyncCallback,\n) (string, error) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\ttaskID := fmt.Sprintf(\"subagent-%d\", sm.nextID)\n\tsm.nextID++\n\n\tsubagentTask := &SubagentTask{\n\t\tID:            taskID,\n\t\tTask:          task,\n\t\tLabel:         label,\n\t\tAgentID:       agentID,\n\t\tOriginChannel: originChannel,\n\t\tOriginChatID:  originChatID,\n\t\tStatus:        \"running\",\n\t\tCreated:       time.Now().UnixMilli(),\n\t}\n\tsm.tasks[taskID] = subagentTask\n\n\t// Start task in background with context cancellation support\n\tgo sm.runTask(ctx, subagentTask, callback)\n\n\tif label != \"\" {\n\t\treturn fmt.Sprintf(\"Spawned subagent '%s' for task: %s\", label, task), nil\n\t}\n\treturn fmt.Sprintf(\"Spawned subagent for task: %s\", task), nil\n}\n\nfunc (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {\n\t// Build system prompt for subagent\n\tsystemPrompt := `You are a subagent. Complete the given task independently and report the result.\nYou have access to tools - use them as needed to complete your task.\nAfter completing the task, provide a clear summary of what was done.`\n\n\tmessages := []providers.Message{\n\t\t{\n\t\t\tRole:    \"system\",\n\t\t\tContent: systemPrompt,\n\t\t},\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: task.Task,\n\t\t},\n\t}\n\n\t// Check if context is already canceled before starting\n\tselect {\n\tcase <-ctx.Done():\n\t\tsm.mu.Lock()\n\t\ttask.Status = \"canceled\"\n\t\ttask.Result = \"Task canceled before execution\"\n\t\tsm.mu.Unlock()\n\t\treturn\n\tdefault:\n\t}\n\n\t// Run tool loop with access to tools\n\tsm.mu.RLock()\n\ttools := sm.tools\n\tmaxIter := sm.maxIterations\n\tmaxTokens := sm.maxTokens\n\ttemperature := sm.temperature\n\thasMaxTokens := sm.hasMaxTokens\n\thasTemperature := sm.hasTemperature\n\tsm.mu.RUnlock()\n\n\tvar llmOptions map[string]any\n\tif hasMaxTokens || hasTemperature {\n\t\tllmOptions = map[string]any{}\n\t\tif hasMaxTokens {\n\t\t\tllmOptions[\"max_tokens\"] = maxTokens\n\t\t}\n\t\tif hasTemperature {\n\t\t\tllmOptions[\"temperature\"] = temperature\n\t\t}\n\t}\n\n\tloopResult, err := RunToolLoop(ctx, ToolLoopConfig{\n\t\tProvider:      sm.provider,\n\t\tModel:         sm.defaultModel,\n\t\tTools:         tools,\n\t\tMaxIterations: maxIter,\n\t\tLLMOptions:    llmOptions,\n\t}, messages, task.OriginChannel, task.OriginChatID)\n\n\tsm.mu.Lock()\n\tvar result *ToolResult\n\tdefer func() {\n\t\tsm.mu.Unlock()\n\t\t// Call callback if provided and result is set\n\t\tif callback != nil && result != nil {\n\t\t\tcallback(ctx, result)\n\t\t}\n\t}()\n\n\tif err != nil {\n\t\ttask.Status = \"failed\"\n\t\ttask.Result = fmt.Sprintf(\"Error: %v\", err)\n\t\t// Check if it was canceled\n\t\tif ctx.Err() != nil {\n\t\t\ttask.Status = \"canceled\"\n\t\t\ttask.Result = \"Task canceled during execution\"\n\t\t}\n\t\tresult = &ToolResult{\n\t\t\tForLLM:  task.Result,\n\t\t\tForUser: \"\",\n\t\t\tSilent:  false,\n\t\t\tIsError: true,\n\t\t\tAsync:   false,\n\t\t\tErr:     err,\n\t\t}\n\t} else {\n\t\ttask.Status = \"completed\"\n\t\ttask.Result = loopResult.Content\n\t\tresult = &ToolResult{\n\t\t\tForLLM: fmt.Sprintf(\n\t\t\t\t\"Subagent '%s' completed (iterations: %d): %s\",\n\t\t\t\ttask.Label,\n\t\t\t\tloopResult.Iterations,\n\t\t\t\tloopResult.Content,\n\t\t\t),\n\t\t\tForUser: loopResult.Content,\n\t\t\tSilent:  false,\n\t\t\tIsError: false,\n\t\t\tAsync:   false,\n\t\t}\n\t}\n}\n\nfunc (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\ttask, ok := sm.tasks[taskID]\n\treturn task, ok\n}\n\n// GetTaskCopy returns a copy of the task with the given ID, taken under the\n// read lock, so the caller receives a consistent snapshot with no data race.\nfunc (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\ttask, ok := sm.tasks[taskID]\n\tif !ok {\n\t\treturn SubagentTask{}, false\n\t}\n\treturn *task, true\n}\n\nfunc (sm *SubagentManager) ListTasks() []*SubagentTask {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\ttasks := make([]*SubagentTask, 0, len(sm.tasks))\n\tfor _, task := range sm.tasks {\n\t\ttasks = append(tasks, task)\n\t}\n\treturn tasks\n}\n\n// ListTaskCopies returns value copies of all tasks, taken under the read lock,\n// so callers receive consistent snapshots with no data race.\nfunc (sm *SubagentManager) ListTaskCopies() []SubagentTask {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\tcopies := make([]SubagentTask, 0, len(sm.tasks))\n\tfor _, task := range sm.tasks {\n\t\tcopies = append(copies, *task)\n\t}\n\treturn copies\n}\n\n// SubagentTool executes a subagent task synchronously and returns the result.\n// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion\n// and returns the result directly in the ToolResult.\ntype SubagentTool struct {\n\tmanager *SubagentManager\n}\n\nfunc NewSubagentTool(manager *SubagentManager) *SubagentTool {\n\treturn &SubagentTool{\n\t\tmanager: manager,\n\t}\n}\n\nfunc (t *SubagentTool) Name() string {\n\treturn \"subagent\"\n}\n\nfunc (t *SubagentTool) Description() string {\n\treturn \"Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM.\"\n}\n\nfunc (t *SubagentTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"task\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"The task for subagent to complete\",\n\t\t\t},\n\t\t\t\"label\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Optional short label for the task (for display)\",\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"task\"},\n\t}\n}\n\nfunc (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\ttask, ok := args[\"task\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"task is required\").WithError(fmt.Errorf(\"task parameter is required\"))\n\t}\n\n\tlabel, _ := args[\"label\"].(string)\n\n\tif t.manager == nil {\n\t\treturn ErrorResult(\"Subagent manager not configured\").WithError(fmt.Errorf(\"manager is nil\"))\n\t}\n\n\t// Build messages for subagent\n\tmessages := []providers.Message{\n\t\t{\n\t\t\tRole:    \"system\",\n\t\t\tContent: \"You are a subagent. Complete the given task independently and provide a clear, concise result.\",\n\t\t},\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: task,\n\t\t},\n\t}\n\n\t// Use RunToolLoop to execute with tools (same as async SpawnTool)\n\tsm := t.manager\n\tsm.mu.RLock()\n\ttools := sm.tools\n\tmaxIter := sm.maxIterations\n\tmaxTokens := sm.maxTokens\n\ttemperature := sm.temperature\n\thasMaxTokens := sm.hasMaxTokens\n\thasTemperature := sm.hasTemperature\n\tsm.mu.RUnlock()\n\n\tvar llmOptions map[string]any\n\tif hasMaxTokens || hasTemperature {\n\t\tllmOptions = map[string]any{}\n\t\tif hasMaxTokens {\n\t\t\tllmOptions[\"max_tokens\"] = maxTokens\n\t\t}\n\t\tif hasTemperature {\n\t\t\tllmOptions[\"temperature\"] = temperature\n\t\t}\n\t}\n\n\t// Fall back to \"cli\"/\"direct\" for non-conversation callers (e.g., CLI, tests)\n\t// to preserve the same defaults as the original NewSubagentTool constructor.\n\tchannel := ToolChannel(ctx)\n\tif channel == \"\" {\n\t\tchannel = \"cli\"\n\t}\n\tchatID := ToolChatID(ctx)\n\tif chatID == \"\" {\n\t\tchatID = \"direct\"\n\t}\n\n\tloopResult, err := RunToolLoop(ctx, ToolLoopConfig{\n\t\tProvider:      sm.provider,\n\t\tModel:         sm.defaultModel,\n\t\tTools:         tools,\n\t\tMaxIterations: maxIter,\n\t\tLLMOptions:    llmOptions,\n\t}, messages, channel, chatID)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"Subagent execution failed: %v\", err)).WithError(err)\n\t}\n\n\t// ForUser: Brief summary for user (truncated if too long)\n\tuserContent := loopResult.Content\n\tmaxUserLen := 500\n\tif len(userContent) > maxUserLen {\n\t\tuserContent = userContent[:maxUserLen] + \"...\"\n\t}\n\n\t// ForLLM: Full execution details\n\tlabelStr := label\n\tif labelStr == \"\" {\n\t\tlabelStr = \"(unnamed)\"\n\t}\n\tllmContent := fmt.Sprintf(\"Subagent task completed:\\nLabel: %s\\nIterations: %d\\nResult: %s\",\n\t\tlabelStr, loopResult.Iterations, loopResult.Content)\n\n\treturn &ToolResult{\n\t\tForLLM:  llmContent,\n\t\tForUser: userContent,\n\t\tSilent:  false,\n\t\tIsError: false,\n\t\tAsync:   false,\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/subagent_tool_test.go",
    "content": "package tools\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// MockLLMProvider is a test implementation of LLMProvider\ntype MockLLMProvider struct {\n\tlastOptions map[string]any\n}\n\nfunc (m *MockLLMProvider) Chat(\n\tctx context.Context,\n\tmessages []providers.Message,\n\ttools []providers.ToolDefinition,\n\tmodel string,\n\toptions map[string]any,\n) (*providers.LLMResponse, error) {\n\tm.lastOptions = options\n\t// Find the last user message to generate a response\n\tfor i := len(messages) - 1; i >= 0; i-- {\n\t\tif messages[i].Role == \"user\" {\n\t\t\treturn &providers.LLMResponse{\n\t\t\t\tContent: \"Task completed: \" + messages[i].Content,\n\t\t\t}, nil\n\t\t}\n\t}\n\treturn &providers.LLMResponse{Content: \"No task provided\"}, nil\n}\n\nfunc (m *MockLLMProvider) GetDefaultModel() string {\n\treturn \"test-model\"\n}\n\nfunc (m *MockLLMProvider) SupportsTools() bool {\n\treturn false\n}\n\nfunc (m *MockLLMProvider) GetContextWindow() int {\n\treturn 4096\n}\n\nfunc TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\tmanager.SetLLMOptions(2048, 0.6)\n\ttool := NewSubagentTool(manager)\n\n\tctx := WithToolContext(context.Background(), \"cli\", \"direct\")\n\targs := map[string]any{\"task\": \"Do something\"}\n\tresult := tool.Execute(ctx, args)\n\n\tif result == nil || result.IsError {\n\t\tt.Fatalf(\"Expected successful result, got: %+v\", result)\n\t}\n\n\tif provider.lastOptions == nil {\n\t\tt.Fatal(\"Expected LLM options to be passed, got nil\")\n\t}\n\tif provider.lastOptions[\"max_tokens\"] != 2048 {\n\t\tt.Fatalf(\"max_tokens = %v, want %d\", provider.lastOptions[\"max_tokens\"], 2048)\n\t}\n\tif provider.lastOptions[\"temperature\"] != 0.6 {\n\t\tt.Fatalf(\"temperature = %v, want %v\", provider.lastOptions[\"temperature\"], 0.6)\n\t}\n}\n\n// TestSubagentTool_Name verifies tool name\nfunc TestSubagentTool_Name(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tif tool.Name() != \"subagent\" {\n\t\tt.Errorf(\"Expected name 'subagent', got '%s'\", tool.Name())\n\t}\n}\n\n// TestSubagentTool_Description verifies tool description\nfunc TestSubagentTool_Description(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tdesc := tool.Description()\n\tif desc == \"\" {\n\t\tt.Error(\"Description should not be empty\")\n\t}\n\tif !strings.Contains(desc, \"subagent\") {\n\t\tt.Errorf(\"Description should mention 'subagent', got: %s\", desc)\n\t}\n}\n\n// TestSubagentTool_Parameters verifies tool parameters schema\nfunc TestSubagentTool_Parameters(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tparams := tool.Parameters()\n\tif params == nil {\n\t\tt.Error(\"Parameters should not be nil\")\n\t}\n\n\t// Check type\n\tif params[\"type\"] != \"object\" {\n\t\tt.Errorf(\"Expected type 'object', got: %v\", params[\"type\"])\n\t}\n\n\t// Check properties\n\tprops, ok := params[\"properties\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Properties should be a map\")\n\t}\n\n\t// Verify task parameter\n\ttask, ok := props[\"task\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Task parameter should exist\")\n\t}\n\tif task[\"type\"] != \"string\" {\n\t\tt.Errorf(\"Task type should be 'string', got: %v\", task[\"type\"])\n\t}\n\n\t// Verify label parameter\n\tlabel, ok := props[\"label\"].(map[string]any)\n\tif !ok {\n\t\tt.Fatal(\"Label parameter should exist\")\n\t}\n\tif label[\"type\"] != \"string\" {\n\t\tt.Errorf(\"Label type should be 'string', got: %v\", label[\"type\"])\n\t}\n\n\t// Check required fields\n\trequired, ok := params[\"required\"].([]string)\n\tif !ok {\n\t\tt.Fatal(\"Required should be a string array\")\n\t}\n\tif len(required) != 1 || required[0] != \"task\" {\n\t\tt.Errorf(\"Required should be ['task'], got: %v\", required)\n\t}\n}\n\n// TestSubagentTool_Execute_Success tests successful execution\nfunc TestSubagentTool_Execute_Success(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tctx := WithToolContext(context.Background(), \"telegram\", \"chat-123\")\n\targs := map[string]any{\n\t\t\"task\":  \"Write a haiku about coding\",\n\t\t\"label\": \"haiku-task\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Verify basic ToolResult structure\n\tif result == nil {\n\t\tt.Fatal(\"Result should not be nil\")\n\t}\n\n\t// Verify no error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got error: %s\", result.ForLLM)\n\t}\n\n\t// Verify not async\n\tif result.Async {\n\t\tt.Error(\"SubagentTool should be synchronous, not async\")\n\t}\n\n\t// Verify not silent\n\tif result.Silent {\n\t\tt.Error(\"SubagentTool should not be silent\")\n\t}\n\n\t// Verify ForUser contains brief summary (not empty)\n\tif result.ForUser == \"\" {\n\t\tt.Error(\"ForUser should contain result summary\")\n\t}\n\tif !strings.Contains(result.ForUser, \"Task completed\") {\n\t\tt.Errorf(\"ForUser should contain task completion, got: %s\", result.ForUser)\n\t}\n\n\t// Verify ForLLM contains full details\n\tif result.ForLLM == \"\" {\n\t\tt.Error(\"ForLLM should contain full details\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"haiku-task\") {\n\t\tt.Errorf(\"ForLLM should contain label 'haiku-task', got: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"Task completed:\") {\n\t\tt.Errorf(\"ForLLM should contain task result, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestSubagentTool_Execute_NoLabel tests execution without label\nfunc TestSubagentTool_Execute_NoLabel(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"task\": \"Test task without label\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success without label, got error: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should show (unnamed) for missing label\n\tif !strings.Contains(result.ForLLM, \"(unnamed)\") {\n\t\tt.Errorf(\"ForLLM should show '(unnamed)' for missing label, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestSubagentTool_Execute_MissingTask tests error handling for missing task\nfunc TestSubagentTool_Execute_MissingTask(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"label\": \"test\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error\n\tif !result.IsError {\n\t\tt.Error(\"Expected error for missing task parameter\")\n\t}\n\n\t// ForLLM should contain error message\n\tif !strings.Contains(result.ForLLM, \"task is required\") {\n\t\tt.Errorf(\"Error message should mention 'task is required', got: %s\", result.ForLLM)\n\t}\n\n\t// Err should be set\n\tif result.Err == nil {\n\t\tt.Error(\"Err should be set for validation failure\")\n\t}\n}\n\n// TestSubagentTool_Execute_NilManager tests error handling for nil manager\nfunc TestSubagentTool_Execute_NilManager(t *testing.T) {\n\ttool := NewSubagentTool(nil)\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"task\": \"test task\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error\n\tif !result.IsError {\n\t\tt.Error(\"Expected error for nil manager\")\n\t}\n\n\tif !strings.Contains(result.ForLLM, \"Subagent manager not configured\") {\n\t\tt.Errorf(\"Error message should mention manager not configured, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestSubagentTool_Execute_ContextPassing verifies context is properly used\nfunc TestSubagentTool_Execute_ContextPassing(t *testing.T) {\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tchannel := \"test-channel\"\n\tchatID := \"test-chat\"\n\tctx := WithToolContext(context.Background(), channel, chatID)\n\targs := map[string]any{\n\t\t\"task\": \"Test context passing\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should succeed\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success with context, got error: %s\", result.ForLLM)\n\t}\n\n\t// The context is used internally; we can't directly test it\n\t// but execution success indicates context was handled properly\n}\n\n// TestSubagentTool_ForUserTruncation verifies long content is truncated for user\nfunc TestSubagentTool_ForUserTruncation(t *testing.T) {\n\t// Create a mock provider that returns very long content\n\tprovider := &MockLLMProvider{}\n\tmanager := NewSubagentManager(provider, \"test-model\", \"/tmp/test\")\n\ttool := NewSubagentTool(manager)\n\n\tctx := context.Background()\n\n\t// Create a task that will generate long response\n\tlongTask := strings.Repeat(\"This is a very long task description. \", 100)\n\targs := map[string]any{\n\t\t\"task\":  longTask,\n\t\t\"label\": \"long-test\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// ForUser should be truncated to 500 chars + \"...\"\n\tmaxUserLen := 500\n\tif len(result.ForUser) > maxUserLen+3 { // +3 for \"...\"\n\t\tt.Errorf(\"ForUser should be truncated to ~%d chars, got: %d\", maxUserLen, len(result.ForUser))\n\t}\n\n\t// ForLLM should have full content\n\tif !strings.Contains(result.ForLLM, longTask[:50]) {\n\t\tt.Error(\"ForLLM should contain reference to original task\")\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/toolloop.go",
    "content": "// PicoClaw - Ultra-lightweight personal AI agent\n// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot\n// License: MIT\n//\n// Copyright (c) 2026 PicoClaw contributors\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\n// ToolLoopConfig configures the tool execution loop.\ntype ToolLoopConfig struct {\n\tProvider      providers.LLMProvider\n\tModel         string\n\tTools         *ToolRegistry\n\tMaxIterations int\n\tLLMOptions    map[string]any\n}\n\n// ToolLoopResult contains the result of running the tool loop.\ntype ToolLoopResult struct {\n\tContent    string\n\tIterations int\n}\n\n// RunToolLoop executes the LLM + tool call iteration loop.\n// This is the core agent logic that can be reused by both main agent and subagents.\nfunc RunToolLoop(\n\tctx context.Context,\n\tconfig ToolLoopConfig,\n\tmessages []providers.Message,\n\tchannel, chatID string,\n) (*ToolLoopResult, error) {\n\titeration := 0\n\tvar finalContent string\n\n\tfor iteration < config.MaxIterations {\n\t\titeration++\n\n\t\tlogger.DebugCF(\"toolloop\", \"LLM iteration\",\n\t\t\tmap[string]any{\n\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\"max\":       config.MaxIterations,\n\t\t\t})\n\n\t\t// 1. Build tool definitions\n\t\tvar providerToolDefs []providers.ToolDefinition\n\t\tif config.Tools != nil {\n\t\t\tproviderToolDefs = config.Tools.ToProviderDefs()\n\t\t}\n\n\t\t// 2. Set default LLM options\n\t\tllmOpts := config.LLMOptions\n\t\tif llmOpts == nil {\n\t\t\tllmOpts = map[string]any{}\n\t\t}\n\t\t// 3. Call LLM\n\t\tresponse, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)\n\t\tif err != nil {\n\t\t\tlogger.ErrorCF(\"toolloop\", \"LLM call failed\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\t\"error\":     err.Error(),\n\t\t\t\t})\n\t\t\treturn nil, fmt.Errorf(\"LLM call failed: %w\", err)\n\t\t}\n\n\t\t// 4. If no tool calls, we're done\n\t\tif len(response.ToolCalls) == 0 {\n\t\t\tfinalContent = response.Content\n\t\t\tlogger.InfoCF(\"toolloop\", \"LLM response without tool calls (direct answer)\",\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"iteration\":     iteration,\n\t\t\t\t\t\"content_chars\": len(finalContent),\n\t\t\t\t})\n\t\t\tbreak\n\t\t}\n\n\t\tnormalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))\n\t\tfor _, tc := range response.ToolCalls {\n\t\t\tnormalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))\n\t\t}\n\n\t\t// 5. Log tool calls\n\t\ttoolNames := make([]string, 0, len(normalizedToolCalls))\n\t\tfor _, tc := range normalizedToolCalls {\n\t\t\ttoolNames = append(toolNames, tc.Name)\n\t\t}\n\t\tlogger.InfoCF(\"toolloop\", \"LLM requested tool calls\",\n\t\t\tmap[string]any{\n\t\t\t\t\"tools\":     toolNames,\n\t\t\t\t\"count\":     len(normalizedToolCalls),\n\t\t\t\t\"iteration\": iteration,\n\t\t\t})\n\n\t\t// 6. Build assistant message with tool calls\n\t\tassistantMsg := providers.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: response.Content,\n\t\t}\n\t\tfor _, tc := range normalizedToolCalls {\n\t\t\targumentsJSON, _ := json.Marshal(tc.Arguments)\n\t\t\tassistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{\n\t\t\t\tID:        tc.ID,\n\t\t\t\tType:      \"function\",\n\t\t\t\tName:      tc.Name,\n\t\t\t\tArguments: tc.Arguments,\n\t\t\t\tFunction: &providers.FunctionCall{\n\t\t\t\t\tName:      tc.Name,\n\t\t\t\t\tArguments: string(argumentsJSON),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t\tmessages = append(messages, assistantMsg)\n\n\t\t// 7. Execute tool calls in parallel\n\t\ttype indexedResult struct {\n\t\t\tresult *ToolResult\n\t\t\ttc     providers.ToolCall\n\t\t}\n\n\t\tresults := make([]indexedResult, len(normalizedToolCalls))\n\t\tvar wg sync.WaitGroup\n\n\t\tfor i, tc := range normalizedToolCalls {\n\t\t\tresults[i].tc = tc\n\n\t\t\twg.Add(1)\n\t\t\tgo func(idx int, tc providers.ToolCall) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\targsJSON, _ := json.Marshal(tc.Arguments)\n\t\t\t\targsPreview := utils.Truncate(string(argsJSON), 200)\n\t\t\t\tlogger.InfoCF(\"toolloop\", fmt.Sprintf(\"Tool call: %s(%s)\", tc.Name, argsPreview),\n\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\"tool\":      tc.Name,\n\t\t\t\t\t\t\"iteration\": iteration,\n\t\t\t\t\t})\n\n\t\t\t\tvar toolResult *ToolResult\n\t\t\t\tif config.Tools != nil {\n\t\t\t\t\ttoolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil)\n\t\t\t\t} else {\n\t\t\t\t\ttoolResult = ErrorResult(\"No tools available\")\n\t\t\t\t}\n\t\t\t\tresults[idx].result = toolResult\n\t\t\t}(i, tc)\n\t\t}\n\t\twg.Wait()\n\n\t\t// Append results in original order\n\t\tfor _, r := range results {\n\t\t\tcontentForLLM := r.result.ForLLM\n\t\t\tif contentForLLM == \"\" && r.result.Err != nil {\n\t\t\t\tcontentForLLM = r.result.Err.Error()\n\t\t\t}\n\n\t\t\tmessages = append(messages, providers.Message{\n\t\t\t\tRole:       \"tool\",\n\t\t\t\tContent:    contentForLLM,\n\t\t\t\tToolCallID: r.tc.ID,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn &ToolLoopResult{\n\t\tContent:    finalContent,\n\t\tIterations: iteration,\n\t}, nil\n}\n"
  },
  {
    "path": "pkg/tools/types.go",
    "content": "package tools\n\nimport \"context\"\n\ntype Message struct {\n\tRole       string     `json:\"role\"`\n\tContent    string     `json:\"content\"`\n\tToolCalls  []ToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string     `json:\"tool_call_id,omitempty\"`\n}\n\ntype ToolCall struct {\n\tID        string         `json:\"id\"`\n\tType      string         `json:\"type\"`\n\tFunction  *FunctionCall  `json:\"function,omitempty\"`\n\tName      string         `json:\"name,omitempty\"`\n\tArguments map[string]any `json:\"arguments,omitempty\"`\n}\n\ntype FunctionCall struct {\n\tName      string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\ntype LLMResponse struct {\n\tContent      string     `json:\"content\"`\n\tToolCalls    []ToolCall `json:\"tool_calls,omitempty\"`\n\tFinishReason string     `json:\"finish_reason\"`\n\tUsage        *UsageInfo `json:\"usage,omitempty\"`\n}\n\ntype UsageInfo struct {\n\tPromptTokens     int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens      int `json:\"total_tokens\"`\n}\n\ntype LLMProvider interface {\n\tChat(\n\t\tctx context.Context,\n\t\tmessages []Message,\n\t\ttools []ToolDefinition,\n\t\tmodel string,\n\t\toptions map[string]any,\n\t) (*LLMResponse, error)\n\tGetDefaultModel() string\n}\n\ntype ToolDefinition struct {\n\tType     string                 `json:\"type\"`\n\tFunction ToolFunctionDefinition `json:\"function\"`\n}\n\ntype ToolFunctionDefinition struct {\n\tName        string         `json:\"name\"`\n\tDescription string         `json:\"description\"`\n\tParameters  map[string]any `json:\"parameters\"`\n}\n"
  },
  {
    "path": "pkg/tools/web.go",
    "content": "package tools\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\nconst (\n\tuserAgent       = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n\tuserAgentHonest = \"picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)\"\n\n\t// HTTP client timeouts for web tool providers.\n\tsearchTimeout     = 10 * time.Second // Brave, Tavily, DuckDuckGo\n\tperplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower)\n\tfetchTimeout      = 60 * time.Second // WebFetchTool\n\n\tdefaultMaxChars = 50000\n\tmaxRedirects    = 5\n)\n\n// Pre-compiled regexes for HTML text extraction\nvar (\n\treScript     = regexp.MustCompile(`<script[\\s\\S]*?</script>`)\n\treStyle      = regexp.MustCompile(`<style[\\s\\S]*?</style>`)\n\treTags       = regexp.MustCompile(`<[^>]+>`)\n\treWhitespace = regexp.MustCompile(`[^\\S\\n]+`)\n\treBlankLines = regexp.MustCompile(`\\n{3,}`)\n\n\t// DuckDuckGo result extraction\n\treDDGLink    = regexp.MustCompile(`<a[^>]*class=\"[^\"]*result__a[^\"]*\"[^>]*href=\"([^\"]+)\"[^>]*>([\\s\\S]*?)</a>`)\n\treDDGSnippet = regexp.MustCompile(`<a class=\"result__snippet[^\"]*\".*?>([\\s\\S]*?)</a>`)\n)\n\ntype APIKeyPool struct {\n\tkeys    []string\n\tcurrent uint32\n}\n\nfunc NewAPIKeyPool(keys []string) *APIKeyPool {\n\treturn &APIKeyPool{\n\t\tkeys: keys,\n\t}\n}\n\ntype APIKeyIterator struct {\n\tpool     *APIKeyPool\n\tstartIdx uint32\n\tattempt  uint32\n}\n\nfunc (p *APIKeyPool) NewIterator() *APIKeyIterator {\n\tif len(p.keys) == 0 {\n\t\treturn &APIKeyIterator{pool: p}\n\t}\n\tidx := atomic.AddUint32(&p.current, 1) - 1\n\treturn &APIKeyIterator{\n\t\tpool:     p,\n\t\tstartIdx: idx,\n\t}\n}\n\nfunc (it *APIKeyIterator) Next() (string, bool) {\n\tlength := uint32(len(it.pool.keys))\n\tif length == 0 || it.attempt >= length {\n\t\treturn \"\", false\n\t}\n\tkey := it.pool.keys[(it.startIdx+it.attempt)%length]\n\tit.attempt++\n\treturn key, true\n}\n\ntype SearchProvider interface {\n\tSearch(ctx context.Context, query string, count int) (string, error)\n}\n\ntype BraveSearchProvider struct {\n\tkeyPool *APIKeyPool\n\tproxy   string\n\tclient  *http.Client\n}\n\nfunc (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := fmt.Sprintf(\"https://api.search.brave.com/res/v1/web/search?q=%s&count=%d\",\n\t\turl.QueryEscape(query), count)\n\n\tvar lastErr error\n\titer := p.keyPool.NewIterator()\n\n\tfor {\n\t\tapiKey, ok := iter.Next()\n\t\tif !ok {\n\t\t\tbreak\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\n\t\treq.Header.Set(\"Accept\", \"application/json\")\n\t\treq.Header.Set(\"X-Subscription-Token\", apiKey)\n\n\t\tresp, err := p.client.Do(req)\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"request failed: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"failed to read response: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tlastErr = fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t\t\tif resp.StatusCode == http.StatusTooManyRequests ||\n\t\t\t\tresp.StatusCode == http.StatusUnauthorized ||\n\t\t\t\tresp.StatusCode == http.StatusForbidden ||\n\t\t\t\tresp.StatusCode >= 500 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\", lastErr\n\t\t}\n\n\t\tvar searchResp struct {\n\t\t\tWeb struct {\n\t\t\t\tResults []struct {\n\t\t\t\t\tTitle       string `json:\"title\"`\n\t\t\t\t\tURL         string `json:\"url\"`\n\t\t\t\t\tDescription string `json:\"description\"`\n\t\t\t\t} `json:\"results\"`\n\t\t\t} `json:\"web\"`\n\t\t}\n\n\t\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\t\t// Log error body for debugging\n\t\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t\t}\n\n\t\tresults := searchResp.Web.Results\n\t\tif len(results) == 0 {\n\t\t\treturn fmt.Sprintf(\"No results for: %s\", query), nil\n\t\t}\n\n\t\tvar lines []string\n\t\tlines = append(lines, fmt.Sprintf(\"Results for: %s\", query))\n\t\tfor i, item := range results {\n\t\t\tif i >= count {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tlines = append(lines, fmt.Sprintf(\"%d. %s\\n   %s\", i+1, item.Title, item.URL))\n\t\t\tif item.Description != \"\" {\n\t\t\t\tlines = append(lines, fmt.Sprintf(\"   %s\", item.Description))\n\t\t\t}\n\t\t}\n\n\t\treturn strings.Join(lines, \"\\n\"), nil\n\t}\n\n\treturn \"\", fmt.Errorf(\"all api keys failed, last error: %w\", lastErr)\n}\n\ntype TavilySearchProvider struct {\n\tkeyPool *APIKeyPool\n\tbaseURL string\n\tproxy   string\n\tclient  *http.Client\n}\n\nfunc (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := p.baseURL\n\tif searchURL == \"\" {\n\t\tsearchURL = \"https://api.tavily.com/search\"\n\t}\n\n\tvar lastErr error\n\titer := p.keyPool.NewIterator()\n\n\tfor {\n\t\tapiKey, ok := iter.Next()\n\t\tif !ok {\n\t\t\tbreak\n\t\t}\n\n\t\tpayload := map[string]any{\n\t\t\t\"api_key\":             apiKey,\n\t\t\t\"query\":               query,\n\t\t\t\"search_depth\":        \"advanced\",\n\t\t\t\"include_answer\":      false,\n\t\t\t\"include_images\":      false,\n\t\t\t\"include_raw_content\": false,\n\t\t\t\"max_results\":         count,\n\t\t}\n\n\t\tbodyBytes, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to marshal payload: %w\", err)\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", searchURL, bytes.NewBuffer(bodyBytes))\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"User-Agent\", userAgent)\n\n\t\tresp, err := p.client.Do(req)\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"request failed: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"failed to read response: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tlastErr = fmt.Errorf(\"tavily api error (status %d): %s\", resp.StatusCode, string(body))\n\t\t\tif resp.StatusCode == http.StatusTooManyRequests ||\n\t\t\t\tresp.StatusCode == http.StatusUnauthorized ||\n\t\t\t\tresp.StatusCode == http.StatusForbidden ||\n\t\t\t\tresp.StatusCode >= 500 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\", lastErr\n\t\t}\n\n\t\tvar searchResp struct {\n\t\t\tResults []struct {\n\t\t\t\tTitle   string `json:\"title\"`\n\t\t\t\tURL     string `json:\"url\"`\n\t\t\t\tContent string `json:\"content\"`\n\t\t\t} `json:\"results\"`\n\t\t}\n\n\t\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t\t}\n\n\t\tresults := searchResp.Results\n\t\tif len(results) == 0 {\n\t\t\treturn fmt.Sprintf(\"No results for: %s\", query), nil\n\t\t}\n\n\t\tvar lines []string\n\t\tlines = append(lines, fmt.Sprintf(\"Results for: %s (via Tavily)\", query))\n\t\tfor i, item := range results {\n\t\t\tif i >= count {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tlines = append(lines, fmt.Sprintf(\"%d. %s\\n   %s\", i+1, item.Title, item.URL))\n\t\t\tif item.Content != \"\" {\n\t\t\t\tlines = append(lines, fmt.Sprintf(\"   %s\", item.Content))\n\t\t\t}\n\t\t}\n\n\t\treturn strings.Join(lines, \"\\n\"), nil\n\t}\n\n\treturn \"\", fmt.Errorf(\"all api keys failed, last error: %w\", lastErr)\n}\n\ntype DuckDuckGoSearchProvider struct {\n\tproxy  string\n\tclient *http.Client\n}\n\nfunc (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := fmt.Sprintf(\"https://html.duckduckgo.com/html/?q=%s\", url.QueryEscape(query))\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"User-Agent\", userAgent)\n\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\treturn p.extractResults(string(body), count, query)\n}\n\nfunc (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {\n\t// Simple regex based extraction for DDG HTML\n\t// Strategy: Find all result containers or key anchors directly\n\n\t// Try finding the result links directly first, as they are the most critical\n\t// Pattern: <a class=\"result__a\" href=\"...\">Title</a>\n\t// The previous regex was a bit strict. Let's make it more flexible for attributes order/content\n\tmatches := reDDGLink.FindAllStringSubmatch(html, count+5)\n\n\tif len(matches) == 0 {\n\t\treturn fmt.Sprintf(\"No results found or extraction failed. Query: %s\", query), nil\n\t}\n\n\tvar lines []string\n\tlines = append(lines, fmt.Sprintf(\"Results for: %s (via DuckDuckGo)\", query))\n\n\t// Pre-compile snippet regex to run inside the loop\n\t// We'll search for snippets relative to the link position or just globally if needed\n\t// But simple global search for snippets might mismatch order.\n\t// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)\n\t// Or better: Let's assume the snippet follows the link in the HTML\n\n\t// A better regex approach: iterate through text and find matches in order\n\t// But for now, let's grab all snippets too\n\tsnippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5)\n\n\tmaxItems := min(len(matches), count)\n\n\tfor i := range maxItems {\n\t\turlStr := matches[i][1]\n\t\ttitle := stripTags(matches[i][2])\n\t\ttitle = strings.TrimSpace(title)\n\n\t\t// URL decoding if needed\n\t\tif strings.Contains(urlStr, \"uddg=\") {\n\t\t\tif u, err := url.QueryUnescape(urlStr); err == nil {\n\t\t\t\t_, after, ok := strings.Cut(u, \"uddg=\")\n\t\t\t\tif ok {\n\t\t\t\t\turlStr = after\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tlines = append(lines, fmt.Sprintf(\"%d. %s\\n   %s\", i+1, title, urlStr))\n\n\t\t// Attempt to attach snippet if available and index aligns\n\t\tif i < len(snippetMatches) {\n\t\t\tsnippet := stripTags(snippetMatches[i][1])\n\t\t\tsnippet = strings.TrimSpace(snippet)\n\t\t\tif snippet != \"\" {\n\t\t\t\tlines = append(lines, fmt.Sprintf(\"   %s\", snippet))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strings.Join(lines, \"\\n\"), nil\n}\n\nfunc stripTags(content string) string {\n\treturn reTags.ReplaceAllString(content, \"\")\n}\n\ntype PerplexitySearchProvider struct {\n\tkeyPool *APIKeyPool\n\tproxy   string\n\tclient  *http.Client\n}\n\nfunc (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := \"https://api.perplexity.ai/chat/completions\"\n\n\tvar lastErr error\n\titer := p.keyPool.NewIterator()\n\n\tfor {\n\t\tapiKey, ok := iter.Next()\n\t\tif !ok {\n\t\t\tbreak\n\t\t}\n\n\t\tpayload := map[string]any{\n\t\t\t\"model\": \"sonar\",\n\t\t\t\"messages\": []map[string]string{\n\t\t\t\t{\n\t\t\t\t\t\"role\":    \"system\",\n\t\t\t\t\t\"content\": \"You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\\n1. Title\\n   URL\\n   Description\\n\\nDo not add extra commentary.\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\"content\": fmt.Sprintf(\"Search for: %s. Provide up to %d relevant results.\", query, count),\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"max_tokens\": 1000,\n\t\t}\n\n\t\tpayloadBytes, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to marshal request: %w\", err)\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", searchURL, strings.NewReader(string(payloadBytes)))\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t\treq.Header.Set(\"User-Agent\", userAgent)\n\n\t\tresp, err := p.client.Do(req)\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"request failed: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"failed to read response: %w\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tlastErr = fmt.Errorf(\"Perplexity API error: %s\", string(body))\n\t\t\tif resp.StatusCode == http.StatusTooManyRequests ||\n\t\t\t\tresp.StatusCode == http.StatusUnauthorized ||\n\t\t\t\tresp.StatusCode == http.StatusForbidden ||\n\t\t\t\tresp.StatusCode >= 500 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\", lastErr\n\t\t}\n\n\t\tvar searchResp struct {\n\t\t\tChoices []struct {\n\t\t\t\tMessage struct {\n\t\t\t\t\tContent string `json:\"content\"`\n\t\t\t\t} `json:\"message\"`\n\t\t\t} `json:\"choices\"`\n\t\t}\n\n\t\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t\t}\n\n\t\tif len(searchResp.Choices) == 0 {\n\t\t\treturn fmt.Sprintf(\"No results for: %s\", query), nil\n\t\t}\n\n\t\treturn fmt.Sprintf(\"Results for: %s (via Perplexity)\\n%s\", query, searchResp.Choices[0].Message.Content), nil\n\t}\n\n\treturn \"\", fmt.Errorf(\"all api keys failed, last error: %w\", lastErr)\n}\n\ntype SearXNGSearchProvider struct {\n\tbaseURL string\n}\n\nfunc (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := fmt.Sprintf(\"%s/search?q=%s&format=json&categories=general\",\n\t\tstrings.TrimSuffix(p.baseURL, \"/\"),\n\t\turl.QueryEscape(query))\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", searchURL, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"SearXNG returned status %d\", resp.StatusCode)\n\t}\n\n\tvar result struct {\n\t\tResults []struct {\n\t\t\tTitle   string  `json:\"title\"`\n\t\t\tURL     string  `json:\"url\"`\n\t\t\tContent string  `json:\"content\"`\n\t\t\tEngine  string  `json:\"engine\"`\n\t\t\tScore   float64 `json:\"score\"`\n\t\t} `json:\"results\"`\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tif len(result.Results) == 0 {\n\t\treturn fmt.Sprintf(\"No results for: %s\", query), nil\n\t}\n\n\t// Limit results to requested count\n\tif len(result.Results) > count {\n\t\tresult.Results = result.Results[:count]\n\t}\n\n\t// Format results in standard PicoClaw format\n\tvar b strings.Builder\n\tb.WriteString(fmt.Sprintf(\"Results for: %s (via SearXNG)\\n\", query))\n\tfor i, r := range result.Results {\n\t\tb.WriteString(fmt.Sprintf(\"%d. %s\\n\", i+1, r.Title))\n\t\tb.WriteString(fmt.Sprintf(\"   %s\\n\", r.URL))\n\t\tif r.Content != \"\" {\n\t\t\tb.WriteString(fmt.Sprintf(\"   %s\\n\", r.Content))\n\t\t}\n\t}\n\n\treturn b.String(), nil\n}\n\ntype GLMSearchProvider struct {\n\tapiKey       string\n\tbaseURL      string\n\tsearchEngine string\n\tproxy        string\n\tclient       *http.Client\n}\n\nfunc (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {\n\tsearchURL := p.baseURL\n\tif searchURL == \"\" {\n\t\tsearchURL = \"https://open.bigmodel.cn/api/paas/v4/web_search\"\n\t}\n\n\tpayload := map[string]any{\n\t\t\"search_query\":  query,\n\t\t\"search_engine\": p.searchEngine,\n\t\t\"search_intent\": false,\n\t\t\"count\":         count,\n\t\t\"content_size\":  \"medium\",\n\t}\n\n\tbodyBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to marshal payload: %w\", err)\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", searchURL, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+p.apiKey)\n\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"GLM Search API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tvar searchResp struct {\n\t\tSearchResult []struct {\n\t\t\tTitle   string `json:\"title\"`\n\t\t\tContent string `json:\"content\"`\n\t\t\tLink    string `json:\"link\"`\n\t\t} `json:\"search_result\"`\n\t}\n\n\tif err := json.Unmarshal(body, &searchResp); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\tresults := searchResp.SearchResult\n\tif len(results) == 0 {\n\t\treturn fmt.Sprintf(\"No results for: %s\", query), nil\n\t}\n\n\tvar lines []string\n\tlines = append(lines, fmt.Sprintf(\"Results for: %s (via GLM Search)\", query))\n\tfor i, item := range results {\n\t\tif i >= count {\n\t\t\tbreak\n\t\t}\n\t\tlines = append(lines, fmt.Sprintf(\"%d. %s\\n   %s\", i+1, item.Title, item.Link))\n\t\tif item.Content != \"\" {\n\t\t\tlines = append(lines, fmt.Sprintf(\"   %s\", item.Content))\n\t\t}\n\t}\n\n\treturn strings.Join(lines, \"\\n\"), nil\n}\n\ntype WebSearchTool struct {\n\tprovider   SearchProvider\n\tmaxResults int\n}\n\ntype WebSearchToolOptions struct {\n\tBraveAPIKeys         []string\n\tBraveMaxResults      int\n\tBraveEnabled         bool\n\tTavilyAPIKeys        []string\n\tTavilyBaseURL        string\n\tTavilyMaxResults     int\n\tTavilyEnabled        bool\n\tDuckDuckGoMaxResults int\n\tDuckDuckGoEnabled    bool\n\tPerplexityAPIKeys    []string\n\tPerplexityMaxResults int\n\tPerplexityEnabled    bool\n\tSearXNGBaseURL       string\n\tSearXNGMaxResults    int\n\tSearXNGEnabled       bool\n\tGLMSearchAPIKey      string\n\tGLMSearchBaseURL     string\n\tGLMSearchEngine      string\n\tGLMSearchMaxResults  int\n\tGLMSearchEnabled     bool\n\tProxy                string\n}\n\nfunc NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {\n\tvar provider SearchProvider\n\tmaxResults := 5\n\t// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search\n\tif opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {\n\t\tclient, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for Perplexity: %w\", err)\n\t\t}\n\t\tprovider = &PerplexitySearchProvider{\n\t\t\tkeyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),\n\t\t\tproxy:   opts.Proxy,\n\t\t\tclient:  client,\n\t\t}\n\t\tif opts.PerplexityMaxResults > 0 {\n\t\t\tmaxResults = opts.PerplexityMaxResults\n\t\t}\n\t} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {\n\t\tclient, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for Brave: %w\", err)\n\t\t}\n\t\tprovider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}\n\t\tif opts.BraveMaxResults > 0 {\n\t\t\tmaxResults = opts.BraveMaxResults\n\t\t}\n\t} else if opts.SearXNGEnabled && opts.SearXNGBaseURL != \"\" {\n\t\tprovider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}\n\t\tif opts.SearXNGMaxResults > 0 {\n\t\t\tmaxResults = opts.SearXNGMaxResults\n\t\t}\n\t} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {\n\t\tclient, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for Tavily: %w\", err)\n\t\t}\n\t\tprovider = &TavilySearchProvider{\n\t\t\tkeyPool: NewAPIKeyPool(opts.TavilyAPIKeys),\n\t\t\tbaseURL: opts.TavilyBaseURL,\n\t\t\tproxy:   opts.Proxy,\n\t\t\tclient:  client,\n\t\t}\n\t\tif opts.TavilyMaxResults > 0 {\n\t\t\tmaxResults = opts.TavilyMaxResults\n\t\t}\n\t} else if opts.DuckDuckGoEnabled {\n\t\tclient, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for DuckDuckGo: %w\", err)\n\t\t}\n\t\tprovider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}\n\t\tif opts.DuckDuckGoMaxResults > 0 {\n\t\t\tmaxResults = opts.DuckDuckGoMaxResults\n\t\t}\n\t} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != \"\" {\n\t\tclient, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for GLM Search: %w\", err)\n\t\t}\n\t\tsearchEngine := opts.GLMSearchEngine\n\t\tif searchEngine == \"\" {\n\t\t\tsearchEngine = \"search_std\"\n\t\t}\n\t\tprovider = &GLMSearchProvider{\n\t\t\tapiKey:       opts.GLMSearchAPIKey,\n\t\t\tbaseURL:      opts.GLMSearchBaseURL,\n\t\t\tsearchEngine: searchEngine,\n\t\t\tproxy:        opts.Proxy,\n\t\t\tclient:       client,\n\t\t}\n\t\tif opts.GLMSearchMaxResults > 0 {\n\t\t\tmaxResults = opts.GLMSearchMaxResults\n\t\t}\n\t} else {\n\t\treturn nil, nil\n\t}\n\n\treturn &WebSearchTool{\n\t\tprovider:   provider,\n\t\tmaxResults: maxResults,\n\t}, nil\n}\n\nfunc (t *WebSearchTool) Name() string {\n\treturn \"web_search\"\n}\n\nfunc (t *WebSearchTool) Description() string {\n\treturn \"Search the web for current information. Returns titles, URLs, and snippets from search results.\"\n}\n\nfunc (t *WebSearchTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"query\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"Search query\",\n\t\t\t},\n\t\t\t\"count\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Number of results (1-10)\",\n\t\t\t\t\"minimum\":     1.0,\n\t\t\t\t\"maximum\":     10.0,\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"query\"},\n\t}\n}\n\nfunc (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\tquery, ok := args[\"query\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"query is required\")\n\t}\n\n\tcount := t.maxResults\n\tif c, ok := args[\"count\"].(float64); ok {\n\t\tif int(c) > 0 && int(c) <= 10 {\n\t\t\tcount = int(c)\n\t\t}\n\t}\n\n\tresult, err := t.provider.Search(ctx, query, count)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"search failed: %v\", err))\n\t}\n\n\treturn &ToolResult{\n\t\tForLLM:  result,\n\t\tForUser: result,\n\t}\n}\n\ntype WebFetchTool struct {\n\tmaxChars        int\n\tproxy           string\n\tclient          *http.Client\n\tformat          string\n\tfetchLimitBytes int64\n\twhitelist       *privateHostWhitelist\n}\n\ntype privateHostWhitelist struct {\n\texact map[string]struct{}\n\tcidrs []*net.IPNet\n}\n\nfunc NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {\n\t// createHTTPClient cannot fail with an empty proxy string.\n\treturn NewWebFetchToolWithConfig(maxChars, \"\", format, fetchLimitBytes, nil)\n}\n\n// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.\n// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.\nvar allowPrivateWebFetchHosts atomic.Bool\n\nfunc NewWebFetchToolWithProxy(\n\tmaxChars int,\n\tproxy string,\n\tformat string,\n\tfetchLimitBytes int64,\n\tprivateHostWhitelist []string,\n) (*WebFetchTool, error) {\n\treturn NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)\n}\n\nfunc NewWebFetchToolWithConfig(\n\tmaxChars int,\n\tproxy string,\n\tformat string,\n\tfetchLimitBytes int64,\n\tprivateHostWhitelist []string,\n) (*WebFetchTool, error) {\n\tif maxChars <= 0 {\n\t\tmaxChars = defaultMaxChars\n\t}\n\twhitelist, err := newPrivateHostWhitelist(privateHostWhitelist)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse web fetch private host whitelist: %w\", err)\n\t}\n\tclient, err := utils.CreateHTTPClient(proxy, fetchTimeout)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create HTTP client for web fetch: %w\", err)\n\t}\n\tif transport, ok := client.Transport.(*http.Transport); ok {\n\t\tdialer := &net.Dialer{\n\t\t\tTimeout:   15 * time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t}\n\t\ttransport.DialContext = newSafeDialContext(dialer, whitelist)\n\t}\n\tclient.CheckRedirect = func(req *http.Request, via []*http.Request) error {\n\t\tif len(via) >= maxRedirects {\n\t\t\treturn fmt.Errorf(\"stopped after %d redirects\", maxRedirects)\n\t\t}\n\t\tif isObviousPrivateHost(req.URL.Hostname(), whitelist) {\n\t\t\treturn fmt.Errorf(\"redirect target is private or local network host\")\n\t\t}\n\t\treturn nil\n\t}\n\tif fetchLimitBytes <= 0 {\n\t\tfetchLimitBytes = 10 * 1024 * 1024 // Security Fallback\n\t}\n\treturn &WebFetchTool{\n\t\tmaxChars:        maxChars,\n\t\tproxy:           proxy,\n\t\tclient:          client,\n\t\tformat:          format,\n\t\tfetchLimitBytes: fetchLimitBytes,\n\t\twhitelist:       whitelist,\n\t}, nil\n}\n\nfunc (t *WebFetchTool) Name() string {\n\treturn \"web_fetch\"\n}\n\nfunc (t *WebFetchTool) Description() string {\n\treturn \"Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content.\"\n}\n\nfunc (t *WebFetchTool) Parameters() map[string]any {\n\treturn map[string]any{\n\t\t\"type\": \"object\",\n\t\t\"properties\": map[string]any{\n\t\t\t\"url\": map[string]any{\n\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\"description\": \"URL to fetch\",\n\t\t\t},\n\t\t\t\"maxChars\": map[string]any{\n\t\t\t\t\"type\":        \"integer\",\n\t\t\t\t\"description\": \"Maximum characters to extract\",\n\t\t\t\t\"minimum\":     100.0,\n\t\t\t},\n\t\t},\n\t\t\"required\": []string{\"url\"},\n\t}\n}\n\nfunc (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {\n\turlStr, ok := args[\"url\"].(string)\n\tif !ok {\n\t\treturn ErrorResult(\"url is required\")\n\t}\n\n\tparsedURL, err := url.Parse(urlStr)\n\tif err != nil {\n\t\treturn ErrorResult(fmt.Sprintf(\"invalid URL: %v\", err))\n\t}\n\n\tif parsedURL.Scheme != \"http\" && parsedURL.Scheme != \"https\" {\n\t\treturn ErrorResult(\"only http/https URLs are allowed\")\n\t}\n\n\tif parsedURL.Host == \"\" {\n\t\treturn ErrorResult(\"missing domain in URL\")\n\t}\n\n\t// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.\n\t// The real SSRF guard is newSafeDialContext at connect time.\n\thostname := parsedURL.Hostname()\n\tif isObviousPrivateHost(hostname, t.whitelist) {\n\t\treturn ErrorResult(\"fetching private or local network hosts is not allowed\")\n\t}\n\n\tmaxChars := t.maxChars\n\tif mc, ok := args[\"maxChars\"].(float64); ok {\n\t\tif int(mc) > 100 {\n\t\t\tmaxChars = int(mc)\n\t\t}\n\t}\n\n\tdoFetch := func(ua string) (*http.Response, []byte, error) {\n\t\treq, reqErr := http.NewRequestWithContext(ctx, \"GET\", urlStr, nil)\n\t\tif reqErr != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to create request: %w\", reqErr)\n\t\t}\n\t\treq.Header.Set(\"User-Agent\", ua)\n\t\tresp, doErr := t.client.Do(req)\n\t\tif doErr != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"request failed: %w\", doErr)\n\t\t}\n\t\tresp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes)\n\n\t\tb, readErr := io.ReadAll(resp.Body)\n\t\treturn resp, b, readErr\n\t}\n\n\tresp, body, err := doFetch(userAgent)\n\tif resp != nil && resp.Body != nil {\n\t\tdefer resp.Body.Close()\n\t}\n\n\tif err != nil {\n\t\tvar maxBytesErr *http.MaxBytesError\n\t\tif errors.As(err, &maxBytesErr) {\n\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to read response: size exceeded %d bytes limit\", t.fetchLimitBytes))\n\t\t}\n\t\treturn ErrorResult(err.Error())\n\t}\n\n\t// Cloudflare (and similar WAFs) signal bot challenges with 403 + cf-mitigated: challenge.\n\t// Retry once with an honest User-Agent that identifies picoclaw, which some\n\t// operators explicitly allow-list for AI assistants.\n\tif resp.StatusCode == http.StatusForbidden && resp.Header.Get(\"Cf-Mitigated\") == \"challenge\" {\n\t\tlogger.DebugCF(\"tool\", \"Cloudflare challenge detected, retrying with honest User-Agent\",\n\t\t\tmap[string]any{\"url\": urlStr})\n\t\thonestUA := fmt.Sprintf(userAgentHonest, config.Version)\n\t\tresp2, body2, err2 := doFetch(honestUA)\n\t\tif resp2 != nil && resp2.Body != nil {\n\t\t\tdefer resp2.Body.Close()\n\t\t}\n\n\t\tif err2 == nil {\n\t\t\tresp, body = resp2, body2\n\t\t} else {\n\t\t\tvar maxBytesErr *http.MaxBytesError\n\t\t\tif errors.As(err2, &maxBytesErr) {\n\t\t\t\treturn ErrorResult(\n\t\t\t\t\tfmt.Sprintf(\"failed to read response: size exceeded %d bytes limit\", t.fetchLimitBytes),\n\t\t\t\t)\n\t\t\t}\n\t\t\treturn ErrorResult(err2.Error())\n\t\t}\n\t}\n\n\tbodyStr := string(body)\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\n\tmediaType, params, err := mime.ParseMediaType(contentType)\n\tif err != nil {\n\t\t// The most common error here is \"mime: no media type\" if the header is empty.\n\t\tlogger.WarnCF(\"tool\", \"Failed to parse Content-Type\", map[string]any{\n\t\t\t\"raw_header\": contentType,\n\t\t\t\"error\":      err.Error(),\n\t\t})\n\n\t\t// security fallback\n\t\tmediaType = \"application/octet-stream\"\n\t}\n\n\tcharset, hasCharset := params[\"charset\"]\n\tif hasCharset {\n\t\t// If the charset is not utf-8, we might have to convert the bodyStr\n\t\t// before passing it to the HTML/Markdown parser\n\t\tif strings.ToLower(charset) != \"utf-8\" {\n\t\t\tlogger.WarnCF(\"tool\", \"Note: the content is not in UTF-8\", map[string]any{\"charset\": charset})\n\t\t}\n\t}\n\n\tvar text, extractor string\n\n\tswitch {\n\tcase mediaType == \"application/json\":\n\t\tvar jsonData any\n\t\tif err := json.Unmarshal(body, &jsonData); err != nil {\n\t\t\ttext = bodyStr\n\t\t\textractor = \"raw\"\n\t\t\tbreak\n\t\t}\n\n\t\tformatted, err := json.MarshalIndent(jsonData, \"\", \"  \")\n\t\tif err != nil {\n\t\t\ttext = bodyStr\n\t\t\textractor = \"raw\"\n\t\t\tbreak\n\t\t}\n\n\t\ttext = string(formatted)\n\t\textractor = \"json\"\n\n\tcase mediaType == \"text/html\" || looksLikeHTML(bodyStr):\n\t\tswitch strings.ToLower(t.format) {\n\t\tcase \"markdown\":\n\t\t\tvar err error\n\t\t\ttext, err = utils.HtmlToMarkdown(bodyStr)\n\t\t\tif err != nil {\n\t\t\t\treturn ErrorResult(fmt.Sprintf(\"failed to HTML to markdown: %v\", err))\n\t\t\t}\n\t\t\textractor = \"markdown\"\n\n\t\tdefault:\n\t\t\ttext = t.extractText(bodyStr)\n\t\t\textractor = \"text\"\n\t\t}\n\n\tdefault:\n\t\ttext = bodyStr\n\t\textractor = \"raw\"\n\t}\n\n\ttruncated := len(text) > maxChars\n\tif truncated {\n\t\ttext = text[:maxChars] + \"\\n[Content truncated due to size limit]\"\n\t}\n\n\tresult := map[string]any{\n\t\t\"url\":       urlStr,\n\t\t\"status\":    resp.StatusCode,\n\t\t\"extractor\": extractor,\n\t\t\"truncated\": truncated,\n\t\t\"length\":    len(text),\n\t\t\"text\":      text,\n\t}\n\n\tresultJSON, _ := json.MarshalIndent(result, \"\", \"  \")\n\n\treturn &ToolResult{\n\t\tForLLM: string(resultJSON),\n\t\tForUser: fmt.Sprintf(\n\t\t\t\"Fetched %d bytes from %s (extractor: %s, truncated: %v)\",\n\t\t\tlen(text),\n\t\t\turlStr,\n\t\t\textractor,\n\t\t\ttruncated,\n\t\t),\n\t}\n}\n\nfunc looksLikeHTML(body string) bool {\n\tif body == \"\" {\n\t\treturn false\n\t}\n\n\tlower := strings.ToLower(body)\n\n\treturn strings.HasPrefix(body, \"<!doctype\") ||\n\t\tstrings.HasPrefix(lower, \"<html\")\n}\n\nfunc (t *WebFetchTool) extractText(htmlContent string) string {\n\tresult := reScript.ReplaceAllLiteralString(htmlContent, \"\")\n\tresult = reStyle.ReplaceAllLiteralString(result, \"\")\n\tresult = reTags.ReplaceAllLiteralString(result, \"\")\n\n\tresult = strings.TrimSpace(result)\n\n\tresult = reWhitespace.ReplaceAllString(result, \" \")\n\tresult = reBlankLines.ReplaceAllString(result, \"\\n\\n\")\n\n\tlines := strings.Split(result, \"\\n\")\n\tvar cleanLines []string\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line != \"\" {\n\t\t\tcleanLines = append(cleanLines, line)\n\t\t}\n\t}\n\n\treturn strings.Join(cleanLines, \"\\n\")\n}\n\n// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)\n// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.\nfunc newSafeDialContext(\n\tdialer *net.Dialer,\n\twhitelist *privateHostWhitelist,\n) func(context.Context, string, string) (net.Conn, error) {\n\treturn func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\tif allowPrivateWebFetchHosts.Load() {\n\t\t\treturn dialer.DialContext(ctx, network, address)\n\t\t}\n\n\t\thost, port, err := net.SplitHostPort(address)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid target address %q: %w\", address, err)\n\t\t}\n\t\tif host == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"empty target host\")\n\t\t}\n\n\t\tif ip := net.ParseIP(host); ip != nil {\n\t\t\tif shouldBlockPrivateIP(ip, whitelist) {\n\t\t\t\treturn nil, fmt.Errorf(\"blocked private or local target: %s\", host)\n\t\t\t}\n\t\t\treturn dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))\n\t\t}\n\n\t\tipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to resolve %s: %w\", host, err)\n\t\t}\n\n\t\tattempted := 0\n\t\tvar lastErr error\n\t\tfor _, ipAddr := range ipAddrs {\n\t\t\tif shouldBlockPrivateIP(ipAddr.IP, whitelist) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tattempted++\n\t\t\tconn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))\n\t\t\tif err == nil {\n\t\t\t\treturn conn, nil\n\t\t\t}\n\t\t\tlastErr = err\n\t\t}\n\n\t\tif attempted == 0 {\n\t\t\treturn nil, fmt.Errorf(\"all resolved addresses for %s are private, restricted, or not whitelisted\", host)\n\t\t}\n\t\tif lastErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed connecting to public addresses for %s: %w\", host, lastErr)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed connecting to public addresses for %s\", host)\n\t}\n}\n\nfunc newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {\n\tif len(entries) == 0 {\n\t\treturn nil, nil\n\t}\n\n\twhitelist := &privateHostWhitelist{\n\t\texact: make(map[string]struct{}),\n\t\tcidrs: make([]*net.IPNet, 0, len(entries)),\n\t}\n\tfor _, entry := range entries {\n\t\tentry = strings.TrimSpace(entry)\n\t\tif entry == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif ip := net.ParseIP(entry); ip != nil {\n\t\t\twhitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{}\n\t\t\tcontinue\n\t\t}\n\t\t_, network, err := net.ParseCIDR(entry)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid entry %q: expected IP or CIDR\", entry)\n\t\t}\n\t\twhitelist.cidrs = append(whitelist.cidrs, network)\n\t}\n\n\tif len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 {\n\t\treturn nil, nil\n\t}\n\treturn whitelist, nil\n}\n\nfunc (w *privateHostWhitelist) Contains(ip net.IP) bool {\n\tif w == nil || ip == nil {\n\t\treturn false\n\t}\n\n\tnormalized := normalizeWhitelistIP(ip)\n\tif _, ok := w.exact[normalized.String()]; ok {\n\t\treturn true\n\t}\n\tfor _, network := range w.cidrs {\n\t\tif network.Contains(normalized) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc normalizeWhitelistIP(ip net.IP) net.IP {\n\tif ip == nil {\n\t\treturn nil\n\t}\n\tif ip4 := ip.To4(); ip4 != nil {\n\t\treturn ip4\n\t}\n\treturn ip\n}\n\nfunc shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool {\n\treturn isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip)\n}\n\n// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.\n// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —\n// the real SSRF guard is newSafeDialContext which checks IPs at connect time.\nfunc isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool {\n\tif allowPrivateWebFetchHosts.Load() {\n\t\treturn false\n\t}\n\n\th := strings.ToLower(strings.TrimSpace(host))\n\th = strings.TrimSuffix(h, \".\")\n\tif h == \"\" {\n\t\treturn true\n\t}\n\n\tif h == \"localhost\" || strings.HasSuffix(h, \".localhost\") {\n\t\treturn true\n\t}\n\n\tif ip := net.ParseIP(h); ip != nil {\n\t\treturn shouldBlockPrivateIP(ip, whitelist)\n\t}\n\n\treturn false\n}\n\n// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch:\n// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT,\n// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32).\nfunc isPrivateOrRestrictedIP(ip net.IP) bool {\n\tif ip == nil {\n\t\treturn true\n\t}\n\n\tif ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||\n\t\tip.IsMulticast() || ip.IsUnspecified() {\n\t\treturn true\n\t}\n\n\tif ip4 := ip.To4(); ip4 != nil {\n\t\t// IPv4 private, loopback, link-local, and carrier-grade NAT ranges.\n\t\tif ip4[0] == 10 ||\n\t\t\tip4[0] == 127 ||\n\t\t\tip4[0] == 0 ||\n\t\t\t(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||\n\t\t\t(ip4[0] == 192 && ip4[1] == 168) ||\n\t\t\t(ip4[0] == 169 && ip4[1] == 254) ||\n\t\t\t(ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) {\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\tif len(ip) == net.IPv6len {\n\t\t// IPv6 unique local addresses (fc00::/7)\n\t\tif (ip[0] & 0xfe) == 0xfc {\n\t\t\treturn true\n\t\t}\n\t\t// 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6].\n\t\tif ip[0] == 0x20 && ip[1] == 0x02 {\n\t\t\tembedded := net.IPv4(ip[2], ip[3], ip[4], ip[5])\n\t\t\treturn isPrivateOrRestrictedIP(embedded)\n\t\t}\n\t\t// Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted.\n\t\tif ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 {\n\t\t\tclient := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff)\n\t\t\treturn isPrivateOrRestrictedIP(client)\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pkg/tools/web_test.go",
    "content": "package tools\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nconst (\n\ttestFetchLimit = int64(10 * 1024 * 1024)\n\tformat         = \"plaintext\"\n)\n\n// TestWebTool_WebFetch_Success verifies successful URL fetching\nfunc TestWebTool_WebFetch_Success(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"<html><body><h1>Test Page</h1><p>Content here</p></body></html>\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": server.URL,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should contain the fetched content (full JSON result)\n\tif !strings.Contains(result.ForLLM, \"Test Page\") {\n\t\tt.Errorf(\"Expected ForLLM to contain 'Test Page', got: %s\", result.ForLLM)\n\t}\n\n\t// ForUser should contain summary\n\tif !strings.Contains(result.ForUser, \"bytes\") && !strings.Contains(result.ForUser, \"extractor\") {\n\t\tt.Errorf(\"Expected ForUser to contain summary, got: %s\", result.ForUser)\n\t}\n}\n\n// TestWebTool_WebFetch_JSON verifies JSON content handling\nfunc TestWebTool_WebFetch_JSON(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\ttestData := map[string]string{\"key\": \"value\", \"number\": \"123\"}\n\texpectedJSON, _ := json.MarshalIndent(testData, \"\", \"  \")\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write(expectedJSON)\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": server.URL,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should contain formatted JSON\n\tif !strings.Contains(result.ForLLM, \"key\") && !strings.Contains(result.ForLLM, \"value\") {\n\t\tt.Errorf(\"Expected ForLLM to contain JSON data, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL\nfunc TestWebTool_WebFetch_InvalidURL(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": \"not-a-valid-url\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for invalid URL\")\n\t}\n\n\t// Should contain error message (either \"invalid URL\" or scheme error)\n\tif !strings.Contains(result.ForLLM, \"URL\") && !strings.Contains(result.ForUser, \"URL\") {\n\t\tt.Errorf(\"Expected error message for invalid URL, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs\nfunc TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": \"ftp://example.com/file.txt\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for unsupported URL scheme\")\n\t}\n\n\t// Should mention only http/https allowed\n\tif !strings.Contains(result.ForLLM, \"http/https\") && !strings.Contains(result.ForUser, \"http/https\") {\n\t\tt.Errorf(\"Expected scheme error message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL\nfunc TestWebTool_WebFetch_MissingURL(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when URL is missing\")\n\t}\n\n\t// Should mention URL is required\n\tif !strings.Contains(result.ForLLM, \"url is required\") && !strings.Contains(result.ForUser, \"url is required\") {\n\t\tt.Errorf(\"Expected 'url is required' message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\n// TestWebTool_WebFetch_Truncation verifies content truncation\nfunc TestWebTool_WebFetch_Truncation(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tlongContent := strings.Repeat(\"x\", 20000)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(longContent))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": server.URL,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should contain truncated content (not the full 20000 chars)\n\tresultMap := make(map[string]any)\n\tjson.Unmarshal([]byte(result.ForLLM), &resultMap)\n\tif text, ok := resultMap[\"text\"].(string); ok {\n\t\tif len(text) > 1100 { // Allow some margin\n\t\t\tt.Errorf(\"Expected content to be truncated to ~1000 chars, got: %d\", len(text))\n\t\t}\n\t}\n\n\t// Should be marked as truncated\n\tif truncated, ok := resultMap[\"truncated\"].(bool); !ok || !truncated {\n\t\tt.Errorf(\"Expected 'truncated' to be true in result\")\n\t}\n\n\t// Text should end with the truncation notice\n\tif text, ok := resultMap[\"text\"].(string); ok {\n\t\tif !strings.HasSuffix(text, \"[Content truncated due to size limit]\") {\n\t\t\tt.Errorf(\"Expected text to end with truncation notice, got: %q\", text[max(0, len(text)-60):])\n\t\t}\n\t}\n}\n\n// TestWebTool_WebFetch_TruncationNotice verifies the truncation notice is appended\n// for all content formats (text/plain, text/html, markdown, application/json).\nfunc TestWebTool_WebFetch_TruncationNotice(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tconst truncationNotice = \"[Content truncated due to size limit]\"\n\tconst maxChars = 100\n\n\ttests := []struct {\n\t\tname        string\n\t\tcontentType string\n\t\tbody        string\n\t\tformat      string\n\t}{\n\t\t{\n\t\t\tname:        \"plain text\",\n\t\t\tcontentType: \"text/plain\",\n\t\t\tbody:        strings.Repeat(\"a\", 500),\n\t\t\tformat:      \"plaintext\",\n\t\t},\n\t\t{\n\t\t\tname:        \"html plaintext extractor\",\n\t\t\tcontentType: \"text/html\",\n\t\t\tbody:        \"<html><body>\" + strings.Repeat(\"b\", 500) + \"</body></html>\",\n\t\t\tformat:      \"plaintext\",\n\t\t},\n\t\t{\n\t\t\tname:        \"html markdown extractor\",\n\t\t\tcontentType: \"text/html\",\n\t\t\tbody:        \"<html><body>\" + strings.Repeat(\"c\", 500) + \"</body></html>\",\n\t\t\tformat:      \"markdown\",\n\t\t},\n\t\t{\n\t\t\tname:        \"json\",\n\t\t\tcontentType: \"application/json\",\n\t\t\tbody:        `\"` + strings.Repeat(\"d\", 500) + `\"`,\n\t\t\tformat:      \"plaintext\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.Header().Set(\"Content-Type\", tt.contentType)\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tw.Write([]byte(tt.body))\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\ttool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"NewWebFetchTool() error: %v\", err)\n\t\t\t}\n\n\t\t\tresult := tool.Execute(context.Background(), map[string]any{\"url\": server.URL})\n\t\t\tif result.IsError {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", result.ForLLM)\n\t\t\t}\n\n\t\t\tvar resultMap map[string]any\n\t\t\tif err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil {\n\t\t\t\tt.Fatalf(\"failed to unmarshal result JSON: %v\", err)\n\t\t\t}\n\n\t\t\ttext, ok := resultMap[\"text\"].(string)\n\t\t\tif !ok {\n\t\t\t\tt.Fatal(\"missing 'text' field in result\")\n\t\t\t}\n\n\t\t\tif !strings.HasSuffix(text, truncationNotice) {\n\t\t\t\tt.Errorf(\"expected text to end with %q, got suffix: %q\", truncationNotice, text[max(0, len(text)-60):])\n\t\t\t}\n\n\t\t\tif truncated, ok := resultMap[\"truncated\"].(bool); !ok || !truncated {\n\t\t\t\tt.Errorf(\"expected truncated=true in result\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit verifies that the notice\n// is NOT appended when the content fits within the limit.\nfunc TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tconst truncationNotice = \"[Content truncated due to size limit]\"\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"short content\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebFetchTool() error: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"url\": server.URL})\n\tif result.IsError {\n\t\tt.Fatalf(\"unexpected error: %s\", result.ForLLM)\n\t}\n\n\tvar resultMap map[string]any\n\tif err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal result JSON: %v\", err)\n\t}\n\n\ttext, _ := resultMap[\"text\"].(string)\n\tif strings.Contains(text, truncationNotice) {\n\t\tt.Errorf(\"expected no truncation notice for content within limit, got: %q\", text)\n\t}\n\n\tif truncated, _ := resultMap[\"truncated\"].(bool); truncated {\n\t\tt.Errorf(\"expected truncated=false for content within limit\")\n\t}\n}\n\nfunc TestWebFetchTool_PayloadTooLarge(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\t// Create a mock HTTP server\n\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.WriteHeader(http.StatusOK)\n\n\t\t// Generate a payload intentionally larger than our limit.\n\t\t// Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'.\n\t\tlargeData := bytes.Repeat([]byte(\"A\"), int(testFetchLimit)+100)\n\n\t\tw.Write(largeData)\n\t}))\n\t// Ensure the server is shut down at the end of the test\n\tdefer ts.Close()\n\n\t// Initialize the tool\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\t// Prepare the arguments pointing to the URL of our local mock server\n\targs := map[string]any{\n\t\t\"url\": ts.URL,\n\t}\n\n\t// Execute the tool\n\tctx := context.Background()\n\tresult := tool.Execute(ctx, args)\n\n\t// Assuming ErrorResult sets the ForLLM field with the error text.\n\tif result == nil {\n\t\tt.Fatal(\"expected a ToolResult, got nil\")\n\t}\n\n\t// Search for the exact error string we set earlier in the Execute method\n\texpectedErrorMsg := fmt.Sprintf(\"size exceeded %d bytes limit\", testFetchLimit)\n\n\tif !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) {\n\t\tt.Errorf(\"test failed: expected error %q, but got: %+v\", expectedErrorMsg, result)\n\t}\n}\n\n// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing\nfunc TestWebTool_WebSearch_NoApiKey(t *testing.T) {\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil})\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\tif tool != nil {\n\t\tt.Errorf(\"Expected nil tool when Brave API key is empty\")\n\t}\n\n\t// Also nil when nothing is enabled\n\ttool, err = NewWebSearchTool(WebSearchToolOptions{})\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\tif tool != nil {\n\t\tt.Errorf(\"Expected nil tool when no provider is enabled\")\n\t}\n}\n\n// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query\nfunc TestWebTool_WebSearch_MissingQuery(t *testing.T) {\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tBraveEnabled:    true,\n\t\tBraveAPIKeys:    []string{\"test-key\"},\n\t\tBraveMaxResults: 5,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\tctx := context.Background()\n\targs := map[string]any{}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error when query is missing\")\n\t}\n}\n\n// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction\nfunc TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write(\n\t\t\t[]byte(\n\t\t\t\t`<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`,\n\t\t\t),\n\t\t)\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": server.URL,\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForLLM should contain extracted text (without script/style tags)\n\tif !strings.Contains(result.ForLLM, \"Title\") && !strings.Contains(result.ForLLM, \"Content\") {\n\t\tt.Errorf(\"Expected ForLLM to contain extracted text, got: %s\", result.ForLLM)\n\t}\n\n\t// Should NOT contain script or style tags in ForLLM\n\tif strings.Contains(result.ForLLM, \"<script>\") || strings.Contains(result.ForLLM, \"<style>\") {\n\t\tt.Errorf(\"Expected script/style tags to be removed, got: %s\", result.ForLLM)\n\t}\n}\n\n// TestWebFetchTool_extractText verifies text extraction preserves newlines\nfunc TestWebFetchTool_extractText(t *testing.T) {\n\ttool := &WebFetchTool{}\n\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\twantFunc func(t *testing.T, got string)\n\t}{\n\t\t{\n\t\t\tname:  \"preserves newlines between block elements\",\n\t\t\tinput: \"<html><body><h1>Title</h1>\\n<p>Paragraph 1</p>\\n<p>Paragraph 2</p></body></html>\",\n\t\t\twantFunc: func(t *testing.T, got string) {\n\t\t\t\tlines := strings.Split(got, \"\\n\")\n\t\t\t\tif len(lines) < 2 {\n\t\t\t\t\tt.Errorf(\"Expected multiple lines, got %d: %q\", len(lines), got)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(got, \"Title\") || !strings.Contains(got, \"Paragraph 1\") ||\n\t\t\t\t\t!strings.Contains(got, \"Paragraph 2\") {\n\t\t\t\t\tt.Errorf(\"Missing expected text: %q\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"removes script and style tags\",\n\t\t\tinput: \"<script>alert('x');</script><style>body{}</style><p>Keep this</p>\",\n\t\t\twantFunc: func(t *testing.T, got string) {\n\t\t\t\tif strings.Contains(got, \"alert\") || strings.Contains(got, \"body{}\") {\n\t\t\t\t\tt.Errorf(\"Expected script/style content removed, got: %q\", got)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(got, \"Keep this\") {\n\t\t\t\t\tt.Errorf(\"Expected 'Keep this' to remain, got: %q\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"collapses excessive blank lines\",\n\t\t\tinput: \"<p>A</p>\\n\\n\\n\\n\\n<p>B</p>\",\n\t\t\twantFunc: func(t *testing.T, got string) {\n\t\t\t\tif strings.Contains(got, \"\\n\\n\\n\") {\n\t\t\t\t\tt.Errorf(\"Expected excessive blank lines collapsed, got: %q\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"collapses horizontal whitespace\",\n\t\t\tinput: \"<p>hello     world</p>\",\n\t\t\twantFunc: func(t *testing.T, got string) {\n\t\t\t\tif strings.Contains(got, \"     \") {\n\t\t\t\t\tt.Errorf(\"Expected spaces collapsed, got: %q\", got)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(got, \"hello world\") {\n\t\t\t\t\tt.Errorf(\"Expected 'hello world', got: %q\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"empty input\",\n\t\t\tinput: \"\",\n\t\t\twantFunc: func(t *testing.T, got string) {\n\t\t\t\tif got != \"\" {\n\t\t\t\t\tt.Errorf(\"Expected empty string, got: %q\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tool.extractText(tt.input)\n\t\t\ttt.wantFunc(t, got)\n\t\t})\n\t}\n}\n\nfunc withPrivateWebFetchHostsAllowed(t *testing.T) {\n\tt.Helper()\n\tprevious := allowPrivateWebFetchHosts.Load()\n\tallowPrivateWebFetchHosts.Store(true)\n\tt.Cleanup(func() {\n\t\tallowPrivateWebFetchHosts.Store(previous)\n\t})\n}\n\nfunc serverHostAndPort(t *testing.T, rawURL string) (string, string) {\n\tt.Helper()\n\thostPort := strings.TrimPrefix(rawURL, \"http://\")\n\thostPort = strings.TrimPrefix(hostPort, \"https://\")\n\thost, port, err := net.SplitHostPort(hostPort)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to split host/port from %q: %v\", rawURL, err)\n\t}\n\treturn host, port\n}\n\nfunc singleHostCIDR(t *testing.T, host string) string {\n\tt.Helper()\n\tip := net.ParseIP(host)\n\tif ip == nil {\n\t\tt.Fatalf(\"failed to parse IP %q\", host)\n\t}\n\tif ip.To4() != nil {\n\t\treturn ip.String() + \"/32\"\n\t}\n\treturn ip.String() + \"/128\"\n}\n\nfunc TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://127.0.0.1:0\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Errorf(\"expected error for private host URL, got success\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"private or local network\") &&\n\t\t!strings.Contains(result.ForUser, \"private or local network\") {\n\t\tt.Errorf(\"expected private host block message, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestWebTool_WebFetch_PrivateHostAllowedByExactWhitelist(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"exact whitelist ok\"))\n\t}))\n\tdefer server.Close()\n\n\thost, _ := serverHostAndPort(t, server.URL)\n\ttool, err := NewWebFetchToolWithConfig(50000, \"\", format, testFetchLimit, []string{host})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": server.URL,\n\t})\n\tif result.IsError {\n\t\tt.Fatalf(\"expected success for exact whitelisted private IP, got %q\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"exact whitelist ok\") {\n\t\tt.Fatalf(\"expected fetched content, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"cidr whitelist ok\"))\n\t}))\n\tdefer server.Close()\n\n\thost, _ := serverHostAndPort(t, server.URL)\n\ttool, err := NewWebFetchToolWithConfig(50000, \"\", format, testFetchLimit, []string{singleHostCIDR(t, host)})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": server.URL,\n\t})\n\tif result.IsError {\n\t\tt.Fatalf(\"expected success for CIDR-whitelisted private IP, got %q\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"cidr whitelist ok\") {\n\t\tt.Fatalf(\"expected fetched content, got %q\", result.ForLLM)\n\t}\n}\n\nfunc TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": server.URL,\n\t})\n\n\tif result.IsError {\n\t\tt.Errorf(\"expected success when private host access is allowed in tests, got %q\", result.ForLLM)\n\t}\n}\n\n// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked\nfunc TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://[::ffff:127.0.0.1]:0\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for IPv4-mapped IPv6 loopback URL, got success\")\n\t}\n}\n\n// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked\nfunc TestWebFetch_BlocksMetadataIP(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://169.254.169.254/latest/meta-data\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for cloud metadata IP, got success\")\n\t}\n}\n\n// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked\nfunc TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://[fd00::1]:0\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for IPv6 unique local address, got success\")\n\t}\n}\n\n// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked\nfunc TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\t// 2002:7f00:0001::1 embeds 127.0.0.1\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://[2002:7f00:0001::1]:0\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error for 6to4 with private embedded IPv4, got success\")\n\t}\n}\n\n// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked\nfunc TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\t// 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass,\n\t// connection will fail (no listener) but that's after the SSRF check.\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": \"http://[2002:0801:0101::1]:0\",\n\t})\n\n\t// Should NOT be blocked by SSRF check — error should be connection failure, not \"private\"\n\tif result.IsError && strings.Contains(result.ForLLM, \"private\") {\n\t\tt.Error(\"6to4 with public embedded IPv4 should not be blocked as private\")\n\t}\n}\n\n// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked\nfunc TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Redirect to a private IP\n\t\thttp.Redirect(w, r, \"http://10.0.0.1/secret\", http.StatusFound)\n\t}))\n\tdefer server.Close()\n\n\t// Temporarily disable private host allowance for the redirect check\n\tallowPrivateWebFetchHosts.Store(false)\n\tdefer allowPrivateWebFetchHosts.Store(true)\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create web fetch tool: %v\", err)\n\t}\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"url\": server.URL,\n\t})\n\n\tif !result.IsError {\n\t\tt.Error(\"expected error when redirecting to private IP, got success\")\n\t}\n}\n\nfunc TestNewSafeDialContext_BlocksPrivateDNSResolutionWithoutWhitelist(t *testing.T) {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to listen on loopback: %v\", err)\n\t}\n\tdefer listener.Close()\n\n\t_, port, err := net.SplitHostPort(listener.Addr().String())\n\tif err != nil {\n\t\tt.Fatalf(\"failed to split listener address: %v\", err)\n\t}\n\n\tdialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, nil)\n\t_, err = dialContext(context.Background(), \"tcp\", net.JoinHostPort(\"localhost\", port))\n\tif err == nil {\n\t\tt.Fatal(\"expected localhost DNS resolution to be blocked without whitelist\")\n\t}\n\tif !strings.Contains(err.Error(), \"private\") && !strings.Contains(err.Error(), \"whitelisted\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestNewSafeDialContext_AllowsWhitelistedPrivateDNSResolution(t *testing.T) {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to listen on loopback: %v\", err)\n\t}\n\tdefer listener.Close()\n\n\taccepted := make(chan struct{}, 1)\n\tgo func() {\n\t\tconn, acceptErr := listener.Accept()\n\t\tif acceptErr != nil {\n\t\t\treturn\n\t\t}\n\t\tconn.Close()\n\t\taccepted <- struct{}{}\n\t}()\n\n\t_, port, err := net.SplitHostPort(listener.Addr().String())\n\tif err != nil {\n\t\tt.Fatalf(\"failed to split listener address: %v\", err)\n\t}\n\n\twhitelist, err := newPrivateHostWhitelist([]string{\"127.0.0.0/8\"})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to parse whitelist: %v\", err)\n\t}\n\n\tdialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, whitelist)\n\tconn, err := dialContext(context.Background(), \"tcp\", net.JoinHostPort(\"localhost\", port))\n\tif err != nil {\n\t\tt.Fatalf(\"expected localhost DNS resolution to succeed with whitelist, got %v\", err)\n\t}\n\tconn.Close()\n\n\tselect {\n\tcase <-accepted:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"expected localhost listener to accept a connection\")\n\t}\n}\n\n// TestIsPrivateOrRestrictedIP_Table tests IP classification logic\nfunc TestIsPrivateOrRestrictedIP_Table(t *testing.T) {\n\ttests := []struct {\n\t\tip      string\n\t\tblocked bool\n\t\tdesc    string\n\t}{\n\t\t{\"127.0.0.1\", true, \"IPv4 loopback\"},\n\t\t{\"10.0.0.1\", true, \"IPv4 private class A\"},\n\t\t{\"172.16.0.1\", true, \"IPv4 private class B\"},\n\t\t{\"192.168.1.1\", true, \"IPv4 private class C\"},\n\t\t{\"169.254.169.254\", true, \"link-local / cloud metadata\"},\n\t\t{\"100.64.0.1\", true, \"carrier-grade NAT\"},\n\t\t{\"0.0.0.0\", true, \"unspecified\"},\n\t\t{\"8.8.8.8\", false, \"public DNS\"},\n\t\t{\"1.1.1.1\", false, \"public DNS\"},\n\t\t{\"::1\", true, \"IPv6 loopback\"},\n\t\t{\"::ffff:127.0.0.1\", true, \"IPv4-mapped IPv6 loopback\"},\n\t\t{\"::ffff:10.0.0.1\", true, \"IPv4-mapped IPv6 private\"},\n\t\t{\"fc00::1\", true, \"IPv6 unique local\"},\n\t\t{\"fd00::1\", true, \"IPv6 unique local\"},\n\t\t{\"2002:7f00:0001::1\", true, \"6to4 with embedded 127.x (private)\"},\n\t\t{\"2002:0a00:0001::1\", true, \"6to4 with embedded 10.0.0.1 (private)\"},\n\t\t{\"2002:0801:0101::1\", false, \"6to4 with embedded 8.1.1.1 (public)\"},\n\t\t{\"2001:0000:4136:e378:8000:63bf:f5ff:fffe\", true, \"Teredo with client 10.0.0.1 (private)\"},\n\t\t{\"2001:0000:4136:e378:8000:63bf:f7f6:fefe\", false, \"Teredo with client 8.9.1.1 (public)\"},\n\t\t{\"2607:f8b0:4004:800::200e\", false, \"public IPv6 (Google)\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.desc, func(t *testing.T) {\n\t\t\tip := net.ParseIP(tt.ip)\n\t\t\tif ip == nil {\n\t\t\t\tt.Fatalf(\"failed to parse IP: %s\", tt.ip)\n\t\t\t}\n\t\t\tgot := isPrivateOrRestrictedIP(ip)\n\t\t\tif got != tt.blocked {\n\t\t\t\tt.Errorf(\"isPrivateOrRestrictedIP(%s) = %v, want %v\", tt.ip, got, tt.blocked)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain\nfunc TestWebTool_WebFetch_MissingDomain(t *testing.T) {\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"url\": \"https://\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Should return error result\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected error for URL without domain\")\n\t}\n\n\t// Should mention missing domain\n\tif !strings.Contains(result.ForLLM, \"domain\") && !strings.Contains(result.ForUser, \"domain\") {\n\t\tt.Errorf(\"Expected domain error message, got ForLLM: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestNewWebFetchToolWithProxy(t *testing.T) {\n\ttool, err := NewWebFetchToolWithProxy(1024, \"http://127.0.0.1:7890\", format, testFetchLimit, nil)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t} else if tool.maxChars != 1024 {\n\t\tt.Fatalf(\"maxChars = %d, want %d\", tool.maxChars, 1024)\n\t}\n\n\tif tool.proxy != \"http://127.0.0.1:7890\" {\n\t\tt.Fatalf(\"proxy = %q, want %q\", tool.proxy, \"http://127.0.0.1:7890\")\n\t}\n\n\ttool, err = NewWebFetchToolWithProxy(0, \"http://127.0.0.1:7890\", format, testFetchLimit, nil)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"agent\", \"Failed to create web fetch tool\", map[string]any{\"error\": err.Error()})\n\t}\n\n\tif tool.maxChars != 50000 {\n\t\tt.Fatalf(\"default maxChars = %d, want %d\", tool.maxChars, 50000)\n\t}\n}\n\nfunc TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) {\n\t_, err := NewWebFetchToolWithConfig(1024, \"\", format, testFetchLimit, []string{\"not-an-ip-or-cidr\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid whitelist entry to fail\")\n\t}\n\tif !strings.Contains(err.Error(), \"invalid entry\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestNewWebSearchTool_PropagatesProxy(t *testing.T) {\n\tt.Run(\"perplexity\", func(t *testing.T) {\n\t\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\t\tPerplexityEnabled:    true,\n\t\t\tPerplexityAPIKeys:    []string{\"k\"},\n\t\t\tPerplexityMaxResults: 3,\n\t\t\tProxy:                \"http://127.0.0.1:7890\",\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t\t}\n\t\tp, ok := tool.provider.(*PerplexitySearchProvider)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"provider type = %T, want *PerplexitySearchProvider\", tool.provider)\n\t\t}\n\t\tif p.proxy != \"http://127.0.0.1:7890\" {\n\t\t\tt.Fatalf(\"provider proxy = %q, want %q\", p.proxy, \"http://127.0.0.1:7890\")\n\t\t}\n\t})\n\n\tt.Run(\"brave\", func(t *testing.T) {\n\t\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\t\tBraveEnabled:    true,\n\t\t\tBraveAPIKeys:    []string{\"k\"},\n\t\t\tBraveMaxResults: 3,\n\t\t\tProxy:           \"http://127.0.0.1:7890\",\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t\t}\n\t\tp, ok := tool.provider.(*BraveSearchProvider)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"provider type = %T, want *BraveSearchProvider\", tool.provider)\n\t\t}\n\t\tif p.proxy != \"http://127.0.0.1:7890\" {\n\t\t\tt.Fatalf(\"provider proxy = %q, want %q\", p.proxy, \"http://127.0.0.1:7890\")\n\t\t}\n\t})\n\n\tt.Run(\"duckduckgo\", func(t *testing.T) {\n\t\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\t\tDuckDuckGoEnabled:    true,\n\t\t\tDuckDuckGoMaxResults: 3,\n\t\t\tProxy:                \"http://127.0.0.1:7890\",\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t\t}\n\t\tp, ok := tool.provider.(*DuckDuckGoSearchProvider)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"provider type = %T, want *DuckDuckGoSearchProvider\", tool.provider)\n\t\t}\n\t\tif p.proxy != \"http://127.0.0.1:7890\" {\n\t\t\tt.Fatalf(\"provider proxy = %q, want %q\", p.proxy, \"http://127.0.0.1:7890\")\n\t\t}\n\t})\n}\n\n// TestWebTool_TavilySearch_Success verifies successful Tavily search\nfunc TestWebTool_TavilySearch_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != \"POST\" {\n\t\t\tt.Errorf(\"Expected POST request, got %s\", r.Method)\n\t\t}\n\t\tif r.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\tt.Errorf(\"Expected Content-Type application/json, got %s\", r.Header.Get(\"Content-Type\"))\n\t\t}\n\n\t\t// Verify payload\n\t\tvar payload map[string]any\n\t\tjson.NewDecoder(r.Body).Decode(&payload)\n\t\tif payload[\"api_key\"] != \"test-key\" {\n\t\t\tt.Errorf(\"Expected api_key test-key, got %v\", payload[\"api_key\"])\n\t\t}\n\t\tif payload[\"query\"] != \"test query\" {\n\t\t\tt.Errorf(\"Expected query 'test query', got %v\", payload[\"query\"])\n\t\t}\n\n\t\t// Return mock response\n\t\tresponse := map[string]any{\n\t\t\t\"results\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"title\":   \"Test Result 1\",\n\t\t\t\t\t\"url\":     \"https://example.com/1\",\n\t\t\t\t\t\"content\": \"Content for result 1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"title\":   \"Test Result 2\",\n\t\t\t\t\t\"url\":     \"https://example.com/2\",\n\t\t\t\t\t\"content\": \"Content for result 2\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(response)\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tTavilyEnabled:    true,\n\t\tTavilyAPIKeys:    []string{\"test-key\"},\n\t\tTavilyBaseURL:    server.URL,\n\t\tTavilyMaxResults: 5,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"query\": \"test query\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\t// Success should not be an error\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\n\t// ForUser should contain result titles and URLs\n\tif !strings.Contains(result.ForUser, \"Test Result 1\") ||\n\t\t!strings.Contains(result.ForUser, \"https://example.com/1\") {\n\t\tt.Errorf(\"Expected results in output, got: %s\", result.ForUser)\n\t}\n\n\t// Should mention via Tavily\n\tif !strings.Contains(result.ForUser, \"via Tavily\") {\n\t\tt.Errorf(\"Expected 'via Tavily' in output, got: %s\", result.ForUser)\n\t}\n}\n\n// TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response\n// with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent,\n// and that the retry response is returned when it succeeds.\nfunc TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\trequestCount := 0\n\tvar receivedUAs []string\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequestCount++\n\t\treceivedUAs = append(receivedUAs, r.Header.Get(\"User-Agent\"))\n\n\t\tif requestCount == 1 {\n\t\t\t// First request: simulate Cloudflare challenge\n\t\t\tw.Header().Set(\"Cf-Mitigated\", \"challenge\")\n\t\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\tw.Write([]byte(\"<html><body>Cloudflare challenge</body></html>\"))\n\t\t\treturn\n\t\t}\n\t\t// Second request (honest UA retry): success\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"real content\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebFetchTool() error: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"url\": server.URL})\n\n\tif result.IsError {\n\t\tt.Fatalf(\"expected success after retry, got error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForLLM, \"real content\") {\n\t\tt.Errorf(\"expected retry response content, got: %s\", result.ForLLM)\n\t}\n\tif requestCount != 2 {\n\t\tt.Errorf(\"expected exactly 2 requests, got %d\", requestCount)\n\t}\n\n\t// First request must use the generic user agent\n\tif receivedUAs[0] != userAgent {\n\t\tt.Errorf(\"first request UA = %q, want %q\", receivedUAs[0], userAgent)\n\t}\n\t// Second request must use the honest picoclaw user agent\n\tif !strings.Contains(receivedUAs[1], \"picoclaw\") {\n\t\tt.Errorf(\"retry request UA = %q, want it to contain 'picoclaw'\", receivedUAs[1])\n\t}\n}\n\n// TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors verifies that a plain 403\n// (without cf-mitigated: challenge) does NOT trigger a retry.\nfunc TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\trequestCount := 0\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequestCount++\n\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\tw.WriteHeader(http.StatusForbidden)\n\t\tw.Write([]byte(\"plain forbidden\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebFetchTool() error: %v\", err)\n\t}\n\n\ttool.Execute(context.Background(), map[string]any{\"url\": server.URL})\n\n\tif requestCount != 1 {\n\t\tt.Errorf(\"expected exactly 1 request for plain 403, got %d\", requestCount)\n\t}\n}\n\n// TestWebFetchTool_CloudflareChallenge_RetryFailsToo verifies that if the honest-UA\n// retry also fails (e.g. still blocked), the error from the retry is returned.\nfunc TestWebFetchTool_CloudflareChallenge_RetryFailsToo(t *testing.T) {\n\twithPrivateWebFetchHostsAllowed(t)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Always return CF challenge regardless of UA\n\t\tw.Header().Set(\"Cf-Mitigated\", \"challenge\")\n\t\tw.Header().Set(\"Content-Type\", \"text/html\")\n\t\tw.WriteHeader(http.StatusForbidden)\n\t\tw.Write([]byte(\"<html><body>still blocked</body></html>\"))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebFetchTool(50000, format, testFetchLimit)\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebFetchTool() error: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\"url\": server.URL})\n\n\t// Should not be an error — the retry response is used as-is (403 is a valid HTTP response)\n\tif result.IsError {\n\t\tt.Fatalf(\"expected non-error result even when retry is also blocked, got: %s\", result.ForLLM)\n\t}\n\t// Status in the JSON result should reflect the 403\n\tif !strings.Contains(result.ForLLM, \"403\") {\n\t\tt.Errorf(\"expected status 403 in result, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestAPIKeyPool(t *testing.T) {\n\tpool := NewAPIKeyPool([]string{\"key1\", \"key2\", \"key3\"})\n\tif len(pool.keys) != 3 {\n\t\tt.Fatalf(\"expected 3 keys, got %d\", len(pool.keys))\n\t}\n\tif pool.keys[0] != \"key1\" || pool.keys[1] != \"key2\" || pool.keys[2] != \"key3\" {\n\t\tt.Fatalf(\"unexpected keys: %v\", pool.keys)\n\t}\n\n\t// Test Iterator: each iterator should cover all keys exactly once\n\titer := pool.NewIterator()\n\texpected := []string{\"key1\", \"key2\", \"key3\"}\n\tfor i, want := range expected {\n\t\tk, ok := iter.Next()\n\t\tif !ok {\n\t\t\tt.Fatalf(\"iter.Next() returned false at step %d\", i)\n\t\t}\n\t\tif k != want {\n\t\t\tt.Errorf(\"step %d: expected %s, got %s\", i, want, k)\n\t\t}\n\t}\n\t// Should be exhausted\n\tif _, ok := iter.Next(); ok {\n\t\tt.Errorf(\"expected iterator exhausted after all keys\")\n\t}\n\n\t// Second iterator starts at next position (load balancing)\n\titer2 := pool.NewIterator()\n\tk, ok := iter2.Next()\n\tif !ok {\n\t\tt.Fatal(\"iter2.Next() returned false\")\n\t}\n\tif k != \"key2\" {\n\t\tt.Errorf(\"expected key2 (round-robin), got %s\", k)\n\t}\n\n\t// Empty pool\n\temptyPool := NewAPIKeyPool([]string{})\n\temptyIter := emptyPool.NewIterator()\n\tif _, ok := emptyIter.Next(); ok {\n\t\tt.Errorf(\"expected false for empty pool\")\n\t}\n\n\t// Single key pool\n\tsinglePool := NewAPIKeyPool([]string{\"single\"})\n\tsingleIter := singlePool.NewIterator()\n\tif k, ok := singleIter.Next(); !ok || k != \"single\" {\n\t\tt.Errorf(\"expected single, got %s (ok=%v)\", k, ok)\n\t}\n\tif _, ok := singleIter.Next(); ok {\n\t\tt.Errorf(\"expected exhausted after single key\")\n\t}\n}\n\nfunc TestWebTool_TavilySearch_Failover(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar payload map[string]any\n\t\tif err := json.NewDecoder(r.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"failed to decode payload: %v\", err)\n\t\t}\n\n\t\tapiKey := payload[\"api_key\"].(string)\n\n\t\tif apiKey == \"key1\" {\n\t\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t\tw.Write([]byte(\"Rate limited\"))\n\t\t\treturn\n\t\t}\n\n\t\tif apiKey == \"key2\" {\n\t\t\t// Success\n\t\t\tresponse := map[string]any{\n\t\t\t\t\"results\": []map[string]any{\n\t\t\t\t\t{\n\t\t\t\t\t\t\"title\":   \"Success Result\",\n\t\t\t\t\t\t\"url\":     \"https://example.com/success\",\n\t\t\t\t\t\t\"content\": \"Success content\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tjson.NewEncoder(w).Encode(response)\n\t\t\treturn\n\t\t}\n\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tTavilyEnabled:    true,\n\t\tTavilyAPIKeys:    []string{\"key1\", \"key2\"},\n\t\tTavilyBaseURL:    server.URL,\n\t\tTavilyMaxResults: 5,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\n\tctx := context.Background()\n\targs := map[string]any{\n\t\t\"query\": \"test query\",\n\t}\n\n\tresult := tool.Execute(ctx, args)\n\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got Error: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForUser, \"Success Result\") {\n\t\tt.Errorf(\"Expected failover to second key and success result, got: %s\", result.ForUser)\n\t}\n}\n\nfunc TestWebTool_GLMSearch_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != \"POST\" {\n\t\t\tt.Errorf(\"Expected POST request, got %s\", r.Method)\n\t\t}\n\t\tif r.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\tt.Errorf(\"Expected Content-Type application/json, got %s\", r.Header.Get(\"Content-Type\"))\n\t\t}\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer test-glm-key\" {\n\t\t\tt.Errorf(\"Expected Authorization Bearer test-glm-key, got %s\", r.Header.Get(\"Authorization\"))\n\t\t}\n\n\t\tvar payload map[string]any\n\t\tjson.NewDecoder(r.Body).Decode(&payload)\n\t\tif payload[\"search_query\"] != \"test query\" {\n\t\t\tt.Errorf(\"Expected search_query 'test query', got %v\", payload[\"search_query\"])\n\t\t}\n\t\tif payload[\"search_engine\"] != \"search_std\" {\n\t\t\tt.Errorf(\"Expected search_engine 'search_std', got %v\", payload[\"search_engine\"])\n\t\t}\n\n\t\tresponse := map[string]any{\n\t\t\t\"id\":      \"web-search-test\",\n\t\t\t\"created\": 1709568000,\n\t\t\t\"search_result\": []map[string]any{\n\t\t\t\t{\n\t\t\t\t\t\"title\":        \"Test GLM Result\",\n\t\t\t\t\t\"content\":      \"GLM search snippet\",\n\t\t\t\t\t\"link\":         \"https://example.com/glm\",\n\t\t\t\t\t\"media\":        \"Example\",\n\t\t\t\t\t\"publish_date\": \"2026-03-04\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tjson.NewEncoder(w).Encode(response)\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tGLMSearchEnabled: true,\n\t\tGLMSearchAPIKey:  \"test-glm-key\",\n\t\tGLMSearchBaseURL: server.URL,\n\t\tGLMSearchEngine:  \"search_std\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"query\": \"test query\",\n\t})\n\n\tif result.IsError {\n\t\tt.Errorf(\"Expected success, got IsError=true: %s\", result.ForLLM)\n\t}\n\tif !strings.Contains(result.ForUser, \"Test GLM Result\") {\n\t\tt.Errorf(\"Expected 'Test GLM Result' in output, got: %s\", result.ForUser)\n\t}\n\tif !strings.Contains(result.ForUser, \"https://example.com/glm\") {\n\t\tt.Errorf(\"Expected URL in output, got: %s\", result.ForUser)\n\t}\n\tif !strings.Contains(result.ForUser, \"via GLM Search\") {\n\t\tt.Errorf(\"Expected 'via GLM Search' in output, got: %s\", result.ForUser)\n\t}\n}\n\nfunc TestWebTool_GLMSearch_APIError(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\tw.Write([]byte(`{\"error\":\"invalid api key\"}`))\n\t}))\n\tdefer server.Close()\n\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tGLMSearchEnabled: true,\n\t\tGLMSearchAPIKey:  \"bad-key\",\n\t\tGLMSearchBaseURL: server.URL,\n\t\tGLMSearchEngine:  \"search_std\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\n\tresult := tool.Execute(context.Background(), map[string]any{\n\t\t\"query\": \"test query\",\n\t})\n\n\tif !result.IsError {\n\t\tt.Errorf(\"Expected IsError=true for 401 response\")\n\t}\n\tif !strings.Contains(result.ForLLM, \"status 401\") {\n\t\tt.Errorf(\"Expected status 401 in error, got: %s\", result.ForLLM)\n\t}\n}\n\nfunc TestWebTool_GLMSearch_Priority(t *testing.T) {\n\t// GLM Search should only be selected when all other providers are disabled\n\ttool, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tDuckDuckGoEnabled:    true,\n\t\tDuckDuckGoMaxResults: 5,\n\t\tGLMSearchEnabled:     true,\n\t\tGLMSearchAPIKey:      \"test-key\",\n\t\tGLMSearchBaseURL:     \"https://example.com\",\n\t\tGLMSearchEngine:      \"search_std\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\n\t// DuckDuckGo should win over GLM Search\n\tif _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok {\n\t\tt.Errorf(\"Expected DuckDuckGoSearchProvider when both enabled, got %T\", tool.provider)\n\t}\n\n\t// With DuckDuckGo disabled, GLM Search should be selected\n\ttool2, err := NewWebSearchTool(WebSearchToolOptions{\n\t\tDuckDuckGoEnabled: false,\n\t\tGLMSearchEnabled:  true,\n\t\tGLMSearchAPIKey:   \"test-key\",\n\t\tGLMSearchBaseURL:  \"https://example.com\",\n\t\tGLMSearchEngine:   \"search_std\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewWebSearchTool() error: %v\", err)\n\t}\n\tif _, ok := tool2.provider.(*GLMSearchProvider); !ok {\n\t\tt.Errorf(\"Expected GLMSearchProvider when only GLM enabled, got %T\", tool2.provider)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/bm25.go",
    "content": "// Package utils provides shared, reusable algorithms.\n// This file implements a generic BM25 search engine.\n//\n// Usage:\n//\n//\ttype MyDoc struct { ID string; Body string }\n//\n//\tcorpus := []MyDoc{...}\n//\tengine := bm25.New(corpus, func(d MyDoc) string {\n//\t    return d.ID + \" \" + d.Body\n//\t})\n//\tresults := engine.Search(\"my query\", 5)\npackage utils\n\nimport (\n\t\"math\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// ── Tuning defaults ───────────────────────────────────────────────────────────\n\nconst (\n\t// DefaultBM25K1 is the term-frequency saturation factor (typical range 1.2–2.0).\n\t// Higher values give more weight to repeated terms.\n\tDefaultBM25K1 = 1.2\n\n\t// DefaultBM25B is the document-length normalization factor (0 = none, 1 = full).\n\tDefaultBM25B = 0.75\n)\n\n// BM25Engine is a query-time BM25 search engine over a generic corpus.\n// T is the document type; the caller supplies a TextFunc that extracts the\n// searchable text from each document.\n//\n// The engine is stateless between queries: no caching, no invalidation logic.\n// All indexing work is performed inside Search() on every call, making it\n// safe to use on corpora that change frequently.\ntype BM25Engine[T any] struct {\n\tcorpus   []T\n\ttextFunc func(T) string\n\tk1       float64\n\tb        float64\n}\n\n// BM25Option is a functional option to configure a BM25Engine.\ntype BM25Option func(*bm25Config)\n\ntype bm25Config struct {\n\tk1 float64\n\tb  float64\n}\n\n// WithK1 overrides the term-frequency saturation constant (default 1.2).\nfunc WithK1(k1 float64) BM25Option {\n\treturn func(c *bm25Config) { c.k1 = k1 }\n}\n\n// WithB overrides the document-length normalization factor (default 0.75).\nfunc WithB(b float64) BM25Option {\n\treturn func(c *bm25Config) { c.b = b }\n}\n\n// NewBM25Engine creates a BM25Engine for the given corpus.\n//\n//   - corpus   : slice of documents of any type T.\n//   - textFunc : function that returns the searchable text for a document.\n//   - opts     : optional tuning (WithK1, WithB).\n//\n// The corpus slice is referenced, not copied. Callers must not mutate it\n// concurrently with Search().\nfunc NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] {\n\tcfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B}\n\tfor _, o := range opts {\n\t\to(&cfg)\n\t}\n\treturn &BM25Engine[T]{\n\t\tcorpus:   corpus,\n\t\ttextFunc: textFunc,\n\t\tk1:       cfg.k1,\n\t\tb:        cfg.b,\n\t}\n}\n\n// BM25Result is a single ranked result from a Search call.\ntype BM25Result[T any] struct {\n\tDocument T\n\tScore    float32\n}\n\n// Search ranks the corpus against query and returns the top-k results.\n// Returns an empty slice (not nil) when there are no matches.\n//\n// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,\n// where N = corpus size, L = average document length, Q = query terms.\n// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).\nfunc (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {\n\tif topK <= 0 {\n\t\treturn []BM25Result[T]{}\n\t}\n\n\tqueryTerms := bm25Tokenize(query)\n\tif len(queryTerms) == 0 {\n\t\treturn []BM25Result[T]{}\n\t}\n\n\tN := len(e.corpus)\n\tif N == 0 {\n\t\treturn []BM25Result[T]{}\n\t}\n\n\t// Step 1: build per-document tf + raw doc lengths\n\ttype docEntry struct {\n\t\ttf     map[string]uint32\n\t\trawLen int\n\t}\n\n\tentries := make([]docEntry, N)\n\tdf := make(map[string]int, 64)\n\ttotalLen := 0\n\n\tfor i, doc := range e.corpus {\n\t\ttokens := bm25Tokenize(e.textFunc(doc))\n\t\ttotalLen += len(tokens)\n\n\t\ttf := make(map[string]uint32, len(tokens))\n\t\tfor _, t := range tokens {\n\t\t\ttf[t]++\n\t\t}\n\t\t// df: each term counts once per document (iterate the map, keys are unique)\n\t\tfor t := range tf {\n\t\t\tdf[t]++\n\t\t}\n\n\t\tentries[i] = docEntry{tf: tf, rawLen: len(tokens)}\n\t}\n\n\tavgDocLen := float64(totalLen) / float64(N)\n\n\t// Step 2: pre-compute IDF and per-doc length normalization\n\t// IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )\n\tidf := make(map[string]float32, len(df))\n\tfor term, freq := range df {\n\t\tidf[term] = float32(math.Log(\n\t\t\t(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,\n\t\t))\n\t}\n\n\t// docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)\n\t// Stored as float32 — sufficient precision for ranking.\n\tdocLenNorm := make([]float32, N)\n\tfor i, entry := range entries {\n\t\tdocLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))\n\t}\n\n\t// Step 3: build inverted index (posting lists)\n\t// Iterate the tf map directly — map keys are already unique, no seen-set needed.\n\tposting := make(map[string][]int32, len(df))\n\tfor i, entry := range entries {\n\t\tfor term := range entry.tf {\n\t\t\tposting[term] = append(posting[term], int32(i))\n\t\t}\n\t}\n\n\t// Step 4: score via posting lists\n\t// Deduplicate query terms to avoid double-weighting the same term.\n\tunique := bm25Dedupe(queryTerms)\n\n\tscores := make(map[int32]float32)\n\tfor _, term := range unique {\n\t\ttermIDF, ok := idf[term]\n\t\tif !ok {\n\t\t\tcontinue // term not in vocabulary → zero contribution\n\t\t}\n\t\tfor _, docID := range posting[term] {\n\t\t\tfreq := float32(entries[docID].tf[term])\n\t\t\t// TF_norm = freq * (k1+1) / (freq + docLenNorm)\n\t\t\ttfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])\n\t\t\tscores[docID] += termIDF * tfNorm\n\t\t}\n\t}\n\n\tif len(scores) == 0 {\n\t\treturn []BM25Result[T]{}\n\t}\n\n\t// Step 5: top-K via fixed-size min-heap\n\theap := make([]bm25ScoredDoc, 0, topK)\n\n\tfor docID, sc := range scores {\n\t\tswitch {\n\t\tcase len(heap) < topK:\n\t\t\theap = append(heap, bm25ScoredDoc{docID: docID, score: sc})\n\t\t\tif len(heap) == topK {\n\t\t\t\tbm25MinHeapify(heap)\n\t\t\t}\n\t\tcase sc > heap[0].score:\n\t\t\theap[0] = bm25ScoredDoc{docID: docID, score: sc}\n\t\t\tbm25SiftDown(heap, 0)\n\t\t}\n\t}\n\n\tsort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score })\n\n\tout := make([]BM25Result[T], len(heap))\n\tfor i, h := range heap {\n\t\tout[i] = BM25Result[T]{\n\t\t\tDocument: e.corpus[h.docID],\n\t\t\tScore:    h.score,\n\t\t}\n\t}\n\treturn out\n}\n\n// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.\nfunc bm25Tokenize(s string) []string {\n\traw := strings.Fields(strings.ToLower(s))\n\tout := raw[:0] // reuse backing array to avoid extra allocation\n\tfor _, t := range raw {\n\t\tt = strings.Trim(t, \".,;:!?\\\"'()/\\\\-_\")\n\t\tif t != \"\" {\n\t\t\tout = append(out, t)\n\t\t}\n\t}\n\treturn out\n}\n\n// bm25Dedupe returns a new slice with duplicate tokens removed,\n// preserving first-occurrence order.\nfunc bm25Dedupe(tokens []string) []string {\n\tseen := make(map[string]struct{}, len(tokens))\n\tout := make([]string, 0, len(tokens))\n\tfor _, t := range tokens {\n\t\tif _, ok := seen[t]; !ok {\n\t\t\tseen[t] = struct{}{}\n\t\t\tout = append(out, t)\n\t\t}\n\t}\n\treturn out\n}\n\ntype bm25ScoredDoc struct {\n\tdocID int32\n\tscore float32\n}\n\n// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k).\nfunc bm25MinHeapify(h []bm25ScoredDoc) {\n\tfor i := len(h)/2 - 1; i >= 0; i-- {\n\t\tbm25SiftDown(h, i)\n\t}\n}\n\n// bm25SiftDown restores the min-heap property starting at node i: O(log k).\nfunc bm25SiftDown(h []bm25ScoredDoc, i int) {\n\tn := len(h)\n\tfor {\n\t\tsmallest := i\n\t\tl, r := 2*i+1, 2*i+2\n\t\tif l < n && h[l].score < h[smallest].score {\n\t\t\tsmallest = l\n\t\t}\n\t\tif r < n && h[r].score < h[smallest].score {\n\t\t\tsmallest = r\n\t\t}\n\t\tif smallest == i {\n\t\t\tbreak\n\t\t}\n\t\th[i], h[smallest] = h[smallest], h[i]\n\t\ti = smallest\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/bm25_test.go",
    "content": "package utils\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\n// testDoc is a generic structure for use in tests.\ntype testDoc struct {\n\tID   int\n\tText string\n}\n\nfunc extractText(d testDoc) string {\n\treturn d.Text\n}\n\nfunc TestBM25Search_EdgeCases(t *testing.T) {\n\tcorpus := []testDoc{\n\t\t{1, \"hello world\"},\n\t\t{2, \"foo bar\"},\n\t}\n\tengine := NewBM25Engine(corpus, extractText)\n\n\ttests := []struct {\n\t\tname  string\n\t\tquery string\n\t\ttopK  int\n\t}{\n\t\t{\"Zero topK\", \"hello\", 0},\n\t\t{\"Negative topK\", \"hello\", -1},\n\t\t{\"Empty query\", \"\", 5},\n\t\t{\"Query with only punctuation\", \"...,,,!!!\", 5},\n\t\t{\"No matches found\", \"golang\", 5},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresults := engine.Search(tt.query, tt.topK)\n\t\t\tif len(results) != 0 {\n\t\t\t\tt.Errorf(\"expected 0 results, got %d\", len(results))\n\t\t\t}\n\t\t\t// Check that it never returns nil, but an empty slice\n\t\t\tif results == nil {\n\t\t\t\tt.Errorf(\"expected empty slice, got nil\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBM25Search_EmptyCorpus(t *testing.T) {\n\tengine := NewBM25Engine([]testDoc{}, extractText)\n\tresults := engine.Search(\"hello\", 5)\n\tif len(results) != 0 || results == nil {\n\t\tt.Errorf(\"expected empty slice from empty corpus, got %v\", results)\n\t}\n}\n\nfunc TestBM25Search_RankingLogic(t *testing.T) {\n\tcorpus := []testDoc{\n\t\t{1, \"the quick brown fox jumps over the lazy dog\"},\n\t\t{2, \"quick fox\"},\n\t\t{3, \"quick quick quick fox\"}, // High Term Frequency (TF)\n\t\t{4, \"completely irrelevant document here\"},\n\t}\n\tengine := NewBM25Engine(corpus, extractText)\n\n\tt.Run(\"Term Frequency (TF) boosts score\", func(t *testing.T) {\n\t\tresults := engine.Search(\"quick\", 5)\n\t\tif len(results) < 3 {\n\t\t\tt.Fatalf(\"expected at least 3 results, got %d\", len(results))\n\t\t}\n\t\t// Doc 3 has the word \"quick\" repeated 3 times, it should beat Doc 2\n\t\tif results[0].Document.ID != 3 {\n\t\t\tt.Errorf(\"expected doc 3 to rank first due to high TF, got doc %d\", results[0].Document.ID)\n\t\t}\n\t})\n\n\tt.Run(\"Document Length penalty\", func(t *testing.T) {\n\t\tresults := engine.Search(\"fox\", 5)\n\t\tif len(results) < 3 {\n\t\t\tt.Fatalf(\"expected at least 3 results, got %d\", len(results))\n\t\t}\n\t\t// Doc 2 (\"quick fox\") is much shorter than Doc 1 (\"the quick brown fox...\"),\n\t\t// so, with equal Term Frequency for the word \"fox\" (1 time), Doc 2 wins.\n\t\tif results[0].Document.ID != 2 {\n\t\t\tt.Errorf(\"expected doc 2 to rank first due to shorter length, got doc %d\", results[0].Document.ID)\n\t\t}\n\t})\n\n\tt.Run(\"TopK limits results\", func(t *testing.T) {\n\t\tresults := engine.Search(\"quick\", 2)\n\t\tif len(results) != 2 {\n\t\t\tt.Errorf(\"expected exactly 2 results, got %d\", len(results))\n\t\t}\n\t})\n}\n\nfunc TestBM25Tokenize(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected []string\n\t}{\n\t\t{\"Hello World\", []string{\"hello\", \"world\"}},\n\t\t{\"  spaces   everywhere  \", []string{\"spaces\", \"everywhere\"}},\n\t\t{\"punctuation... test!!!\", []string{\"punctuation\", \"test\"}},\n\t\t{\"(parentheses) and-hyphens\", []string{\"parentheses\", \"and-hyphens\"}}, // hyphens trimmed from edges\n\t\t{\"internal-hyphen is kept\", []string{\"internal-hyphen\", \"is\", \"kept\"}},\n\t\t{\".,;?!\", []string{}}, // Becomes empty after trim\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := bm25Tokenize(tt.input)\n\t\t\tif len(got) == 0 && len(tt.expected) == 0 {\n\t\t\t\treturn // Both empty\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.expected) {\n\t\t\t\tt.Errorf(\"bm25Tokenize(%q) = %v, want %v\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBM25Dedupe(t *testing.T) {\n\tinput := []string{\"apple\", \"banana\", \"apple\", \"orange\", \"banana\"}\n\texpected := []string{\"apple\", \"banana\", \"orange\"}\n\n\tgot := bm25Dedupe(input)\n\tif !reflect.DeepEqual(got, expected) {\n\t\tt.Errorf(\"bm25Dedupe() = %v, want %v\", got, expected)\n\t}\n}\n\nfunc TestBM25Options(t *testing.T) {\n\tcorpus := []testDoc{{1, \"test\"}}\n\n\tengine := NewBM25Engine(\n\t\tcorpus,\n\t\textractText,\n\t\tWithK1(2.5),\n\t\tWithB(0.9),\n\t)\n\n\tif engine.k1 != 2.5 {\n\t\tt.Errorf(\"expected k1 to be 2.5, got %v\", engine.k1)\n\t}\n\tif engine.b != 0.9 {\n\t\tt.Errorf(\"expected b to be 0.9, got %v\", engine.b)\n\t}\n}\n\nfunc TestBM25Search_SortingStability(t *testing.T) {\n\t// Ensure that sorting by heap returns in correct descending order\n\tcorpus := []testDoc{\n\t\t{1, \"golang is good\"},\n\t\t{2, \"golang golang\"},\n\t\t{3, \"golang golang golang\"},\n\t\t{4, \"golang golang golang golang\"},\n\t}\n\tengine := NewBM25Engine(corpus, extractText)\n\tresults := engine.Search(\"golang\", 10)\n\n\tif len(results) != 4 {\n\t\tt.Fatalf(\"expected 4 results, got %d\", len(results))\n\t}\n\n\t// Score should be strictly decreasing\n\tfor i := 1; i < len(results); i++ {\n\t\tif results[i].Score > results[i-1].Score {\n\t\t\tt.Errorf(\"results not sorted correctly: result %d score (%v) > result %d score (%v)\",\n\t\t\t\ti, results[i].Score, i-1, results[i-1].Score)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/download.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// DownloadToFile streams an HTTP response body to a temporary file in small\n// chunks (~32KB), keeping peak memory usage constant regardless of file size.\n//\n// Parameters:\n//   - ctx:      context for cancellation/timeout\n//   - client:   HTTP client to use (caller controls timeouts, transport, etc.)\n//   - req:      fully prepared *http.Request (method, URL, headers, etc.)\n//   - maxBytes: maximum bytes to download; 0 means no limit\n//\n// Returns the path to the temporary file. The caller is responsible for\n// removing it when done (defer os.Remove(path)).\n//\n// On any error the temp file is cleaned up automatically.\nfunc DownloadToFile(ctx context.Context, client *http.Client, req *http.Request, maxBytes int64) (string, error) {\n\t// Attach context.\n\treq = req.WithContext(ctx)\n\n\tlogger.DebugCF(\"download\", \"Starting download\", map[string]any{\n\t\t\"url\":       req.URL.String(),\n\t\t\"max_bytes\": maxBytes,\n\t})\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"request failed: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t// Read a small amount for the error message.\n\t\terrBody := make([]byte, 512)\n\t\tn, _ := io.ReadFull(resp.Body, errBody)\n\t\treturn \"\", fmt.Errorf(\"HTTP %d: %s\", resp.StatusCode, string(errBody[:n]))\n\t}\n\n\t// Create temp file.\n\ttmpFile, err := os.CreateTemp(\"\", \"picoclaw-dl-*\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create temp file: %w\", err)\n\t}\n\ttmpPath := tmpFile.Name()\n\n\tlogger.DebugCF(\"download\", \"Streaming to temp file\", map[string]any{\n\t\t\"path\": tmpPath,\n\t})\n\n\t// Cleanup helper — removes the temp file on any error.\n\tcleanup := func() {\n\t\t_ = tmpFile.Close()\n\t\t_ = os.Remove(tmpPath)\n\t}\n\n\t// Optionally limit the download size.\n\tvar src io.Reader = resp.Body\n\tif maxBytes > 0 {\n\t\tsrc = io.LimitReader(resp.Body, maxBytes+1) // +1 to detect overflow\n\t}\n\n\twritten, err := io.Copy(tmpFile, src)\n\tif err != nil {\n\t\tcleanup()\n\t\treturn \"\", fmt.Errorf(\"download write failed: %w\", err)\n\t}\n\n\tif maxBytes > 0 && written > maxBytes {\n\t\tcleanup()\n\t\treturn \"\", fmt.Errorf(\"download too large: %d bytes (max %d)\", written, maxBytes)\n\t}\n\n\tif err := tmpFile.Close(); err != nil {\n\t\t_ = os.Remove(tmpPath)\n\t\treturn \"\", fmt.Errorf(\"failed to close temp file: %w\", err)\n\t}\n\n\tlogger.DebugCF(\"download\", \"Download complete\", map[string]any{\n\t\t\"path\":          tmpPath,\n\t\t\"bytes_written\": written,\n\t})\n\n\treturn tmpPath, nil\n}\n"
  },
  {
    "path": "pkg/utils/http_client.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\n// CreateHTTPClient creates an HTTP client with optional proxy support.\n// If proxyURL is empty, it uses the system environment proxy settings.\n// Supported proxy schemes: http, https, socks5, socks5h.\nfunc CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {\n\tclient := &http.Client{\n\t\tTimeout: timeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        10,\n\t\t\tIdleConnTimeout:     30 * time.Second,\n\t\t\tDisableCompression:  false,\n\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t},\n\t}\n\n\tif proxyURL != \"\" {\n\t\tproxy, err := url.Parse(proxyURL)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid proxy URL: %w\", err)\n\t\t}\n\t\tscheme := strings.ToLower(proxy.Scheme)\n\t\tswitch scheme {\n\t\tcase \"http\", \"https\", \"socks5\", \"socks5h\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\n\t\t\t\t\"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)\",\n\t\t\t\tproxy.Scheme,\n\t\t\t)\n\t\t}\n\t\tif proxy.Host == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"invalid proxy URL: missing host\")\n\t\t}\n\t\tclient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)\n\t} else {\n\t\tclient.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment\n\t}\n\n\treturn client, nil\n}\n"
  },
  {
    "path": "pkg/utils/http_client_test.go",
    "content": "package utils\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestCreateHTTPClient_ProxyConfigured(t *testing.T) {\n\tclient, err := CreateHTTPClient(\"http://127.0.0.1:7890\", 12*time.Second)\n\tif err != nil {\n\t\tt.Fatalf(\"createHTTPClient() error: %v\", err)\n\t}\n\tif client.Timeout != 12*time.Second {\n\t\tt.Fatalf(\"client.Timeout = %v, want %v\", client.Timeout, 12*time.Second)\n\t}\n\n\ttr, ok := client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"client.Transport type = %T, want *http.Transport\", client.Transport)\n\t}\n\tif tr.Proxy == nil {\n\t\tt.Fatal(\"transport.Proxy is nil, want non-nil\")\n\t}\n\n\treq, err := http.NewRequest(\"GET\", \"https://example.com\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"http.NewRequest() error: %v\", err)\n\t}\n\tproxyURL, err := tr.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"transport.Proxy(req) error: %v\", err)\n\t}\n\tif proxyURL == nil || proxyURL.String() != \"http://127.0.0.1:7890\" {\n\t\tt.Fatalf(\"proxy URL = %v, want %q\", proxyURL, \"http://127.0.0.1:7890\")\n\t}\n}\n\nfunc TestCreateHTTPClient_InvalidProxy(t *testing.T) {\n\t_, err := CreateHTTPClient(\"://bad-proxy\", 10*time.Second)\n\tif err == nil {\n\t\tt.Fatal(\"createHTTPClient() expected error for invalid proxy URL, got nil\")\n\t}\n}\n\nfunc TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {\n\tclient, err := CreateHTTPClient(\"socks5://127.0.0.1:1080\", 8*time.Second)\n\tif err != nil {\n\t\tt.Fatalf(\"createHTTPClient() error: %v\", err)\n\t}\n\n\ttr, ok := client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"client.Transport type = %T, want *http.Transport\", client.Transport)\n\t}\n\treq, err := http.NewRequest(\"GET\", \"https://example.com\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"http.NewRequest() error: %v\", err)\n\t}\n\tproxyURL, err := tr.Proxy(req)\n\tif err != nil {\n\t\tt.Fatalf(\"transport.Proxy(req) error: %v\", err)\n\t}\n\tif proxyURL == nil || proxyURL.String() != \"socks5://127.0.0.1:1080\" {\n\t\tt.Fatalf(\"proxy URL = %v, want %q\", proxyURL, \"socks5://127.0.0.1:1080\")\n\t}\n}\n\nfunc TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {\n\t_, err := CreateHTTPClient(\"ftp://127.0.0.1:21\", 10*time.Second)\n\tif err == nil {\n\t\tt.Fatal(\"createHTTPClient() expected error for unsupported scheme, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"unsupported proxy scheme\") {\n\t\tt.Fatalf(\"error = %q, want to contain %q\", err.Error(), \"unsupported proxy scheme\")\n\t}\n}\n\nfunc TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {\n\tt.Setenv(\"HTTP_PROXY\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"http_proxy\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"HTTPS_PROXY\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"https_proxy\", \"http://127.0.0.1:8888\")\n\tt.Setenv(\"ALL_PROXY\", \"\")\n\tt.Setenv(\"all_proxy\", \"\")\n\tt.Setenv(\"NO_PROXY\", \"\")\n\tt.Setenv(\"no_proxy\", \"\")\n\n\tclient, err := CreateHTTPClient(\"\", 10*time.Second)\n\tif err != nil {\n\t\tt.Fatalf(\"createHTTPClient() error: %v\", err)\n\t}\n\n\ttr, ok := client.Transport.(*http.Transport)\n\tif !ok {\n\t\tt.Fatalf(\"client.Transport type = %T, want *http.Transport\", client.Transport)\n\t}\n\tif tr.Proxy == nil {\n\t\tt.Fatal(\"transport.Proxy is nil, want proxy function from environment\")\n\t}\n\n\treq, err := http.NewRequest(\"GET\", \"https://example.com\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"http.NewRequest() error: %v\", err)\n\t}\n\tif _, err := tr.Proxy(req); err != nil {\n\t\tt.Fatalf(\"transport.Proxy(req) error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/http_retry.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n)\n\nconst maxRetries = 3\n\nvar retryDelayUnit = time.Second\n\nfunc shouldRetry(statusCode int) bool {\n\treturn statusCode == http.StatusTooManyRequests ||\n\t\tstatusCode >= 500\n}\n\nfunc DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\n\tfor i := range maxRetries {\n\t\tif i > 0 && resp != nil {\n\t\t\tresp.Body.Close()\n\t\t}\n\n\t\tresp, err = client.Do(req)\n\t\tif err == nil {\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif !shouldRetry(resp.StatusCode) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif i < maxRetries-1 {\n\t\t\tif err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {\n\t\t\t\tif resp != nil {\n\t\t\t\t\tresp.Body.Close()\n\t\t\t\t}\n\t\t\t\treturn nil, fmt.Errorf(\"failed to sleep: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\treturn resp, err\n}\n\nfunc sleepWithCtx(ctx context.Context, d time.Duration) error {\n\ttimer := time.NewTimer(d)\n\tdefer timer.Stop()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-timer.C:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/http_retry_test.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDoRequestWithRetry(t *testing.T) {\n\tretryDelayUnit = time.Millisecond\n\tt.Cleanup(func() { retryDelayUnit = time.Second })\n\n\ttestcases := []struct {\n\t\tname           string\n\t\tserverBehavior func(*httptest.Server) int\n\t\twantSuccess    bool\n\t\twantAttempts   int\n\t}{\n\t\t{\n\t\t\tname: \"success-on-first-attempt\",\n\t\t\tserverBehavior: func(server *httptest.Server) int {\n\t\t\t\treturn 0\n\t\t\t},\n\t\t\twantSuccess:  true,\n\t\t\twantAttempts: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"fail-all-attempts\",\n\t\t\tserverBehavior: func(server *httptest.Server) int {\n\t\t\t\treturn 4\n\t\t\t},\n\t\t\twantSuccess:  false,\n\t\t\twantAttempts: 3,\n\t\t},\n\t}\n\n\tfor _, tc := range testcases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tattempts := 0\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tattempts++\n\t\t\t\tif attempts <= tc.serverBehavior(nil) {\n\t\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\tw.Write([]byte(\"success\"))\n\t\t\t}))\n\n\t\t\tt.Cleanup(func() {\n\t\t\t\tserver.Close()\n\t\t\t})\n\n\t\t\tclient := &http.Client{Timeout: 5 * time.Second}\n\t\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tresp, err := DoRequestWithRetry(client, req)\n\n\t\t\tif tc.wantSuccess {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\trequire.NotNil(t, resp)\n\t\t\t\tassert.Equal(t, http.StatusOK, resp.StatusCode)\n\t\t\t\tresp.Body.Close()\n\t\t\t} else {\n\t\t\t\trequire.NotNil(t, resp)\n\t\t\t\tassert.Equal(t, http.StatusInternalServerError, resp.StatusCode)\n\t\t\t\tresp.Body.Close()\n\t\t\t}\n\n\t\t\tassert.Equal(t, tc.wantAttempts, attempts)\n\t\t})\n\t}\n}\n\nfunc TestDoRequestWithRetry_ContextCancel(t *testing.T) {\n\t// Use a long retry delay so cancellation always hits during sleepWithCtx.\n\tretryDelayUnit = 10 * time.Second\n\tt.Cleanup(func() { retryDelayUnit = time.Second })\n\n\tbodyClosed := false\n\tfirstRoundTripDone := make(chan struct{}, 1)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\tw.Write([]byte(\"error\"))\n\t}))\n\tdefer server.Close()\n\n\tclient := server.Client()\n\tclient.Timeout = 30 * time.Second\n\tclient.Transport = &bodyCloseTracker{\n\t\trt:      client.Transport,\n\t\tonClose: func() { bodyClosed = true },\n\t\t// Signal after the first round-trip response is fully constructed on the client side.\n\t\tonRoundTrip: func() {\n\t\t\tselect {\n\t\t\tcase firstRoundTripDone <- struct{}{}:\n\t\t\tdefault:\n\t\t\t}\n\t\t},\n\t\ttrackURL: server.URL,\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Cancel the context after the first round-trip completes on the client side.\n\t// This ensures client.Do has returned a valid resp (with body) and the retry\n\t// loop is about to enter sleepWithCtx, where the cancel will be detected.\n\tgo func() {\n\t\t<-firstRoundTripDone\n\t\tcancel()\n\t}()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)\n\trequire.NoError(t, err)\n\n\tresp, err := DoRequestWithRetry(client, req)\n\tif resp != nil {\n\t\tresp.Body.Close()\n\t}\n\trequire.Error(t, err, \"expected error from context cancellation\")\n\tassert.Nil(t, resp, \"expected nil response when context is canceled\")\n\tassert.True(t, bodyClosed, \"expected resp.Body to be closed on context cancellation\")\n}\n\n// bodyCloseTracker wraps an http.RoundTripper and records when response bodies are closed.\ntype bodyCloseTracker struct {\n\trt          http.RoundTripper\n\tonClose     func()\n\tonRoundTrip func() // called after each successful round-trip\n\ttrackURL    string\n}\n\nfunc (t *bodyCloseTracker) RoundTrip(req *http.Request) (*http.Response, error) {\n\tresp, err := t.rt.RoundTrip(req)\n\tif err != nil {\n\t\treturn resp, err\n\t}\n\tif strings.HasPrefix(req.URL.String(), t.trackURL) {\n\t\tresp.Body = &closeNotifier{ReadCloser: resp.Body, onClose: t.onClose}\n\t\tif t.onRoundTrip != nil {\n\t\t\tt.onRoundTrip()\n\t\t}\n\t}\n\treturn resp, nil\n}\n\n// closeNotifier wraps an io.ReadCloser to detect Close calls.\ntype closeNotifier struct {\n\tio.ReadCloser\n\tonClose func()\n}\n\nfunc (c *closeNotifier) Close() error {\n\tc.onClose()\n\treturn c.ReadCloser.Close()\n}\n\nfunc TestDoRequestWithRetry_Delay(t *testing.T) {\n\tretryDelayUnit = time.Millisecond\n\tt.Cleanup(func() { retryDelayUnit = time.Second })\n\n\tvar start time.Time\n\tdelays := []time.Duration{}\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif len(delays) == 0 {\n\t\t\tdelays = append(delays, 0)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tif len(delays) == 1 {\n\t\t\tstart = time.Now()\n\t\t\tdelays = append(delays, 0)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tif len(delays) == 2 {\n\t\t\telapsed := time.Since(start)\n\t\t\tdelays = append(delays, elapsed)\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Write([]byte(\"success\"))\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\trequire.NoError(t, err)\n\n\tresp, err := DoRequestWithRetry(client, req)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, resp)\n\tassert.Equal(t, http.StatusOK, resp.StatusCode)\n\tresp.Body.Close()\n\n\tassert.GreaterOrEqual(t, delays[2], time.Millisecond)\n}\n"
  },
  {
    "path": "pkg/utils/markdown.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"golang.org/x/net/html\"\n)\n\nvar (\n\treSpaces           = regexp.MustCompile(`[ \\t]+`)\n\treNewlines         = regexp.MustCompile(`\\n{3,}`)\n\treEmptyListItem    = regexp.MustCompile(`(?m)^[-*]\\s*$`)\n\treImageOnlyLink    = regexp.MustCompile(`\\[!\\[\\]\\(<[^>]*>\\)\\]\\(<[^>]*>\\)`)\n\treEmptyHeader      = regexp.MustCompile(`(?m)^#{1,6}\\s*$`)\n\treLeadingLineSpace = regexp.MustCompile(`(?m)^([ \\t])([^ \\t\\n])`)\n)\n\nvar skipTags = map[string]bool{\n\t\"script\": true, \"style\": true, \"head\": true,\n\t\"noscript\": true, \"template\": true,\n\t\"nav\": true, \"footer\": true, \"aside\": true, \"header\": true, \"form\": true, \"dialog\": true,\n}\n\nfunc isSafeHref(href string) bool {\n\tlower := strings.ToLower(strings.TrimSpace(href))\n\tif strings.HasPrefix(lower, \"javascript:\") || strings.HasPrefix(lower, \"vbscript:\") ||\n\t\tstrings.HasPrefix(lower, \"data:\") {\n\t\treturn false\n\t}\n\tu, err := url.Parse(strings.TrimSpace(href))\n\tif err != nil {\n\t\treturn false\n\t}\n\tscheme := strings.ToLower(u.Scheme)\n\treturn scheme == \"\" || scheme == \"http\" || scheme == \"https\" || scheme == \"mailto\"\n}\n\nfunc isSafeImageSrc(src string) bool {\n\tlower := strings.ToLower(strings.TrimSpace(src))\n\tif strings.HasPrefix(lower, \"data:image/\") {\n\t\treturn true\n\t}\n\treturn isSafeHref(src)\n}\n\nfunc escapeMdAlt(s string) string {\n\ts = strings.ReplaceAll(s, `\\`, `\\\\`)\n\ts = strings.ReplaceAll(s, `[`, `\\[`)\n\ts = strings.ReplaceAll(s, `]`, `\\]`)\n\treturn s\n}\n\nfunc getAttr(n *html.Node, key string) string {\n\tfor _, a := range n.Attr {\n\t\tif a.Key == key {\n\t\t\treturn a.Val\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc normalizeAttr(val string) string {\n\tval = strings.ReplaceAll(val, \"\\n\", \"\")\n\tval = strings.ReplaceAll(val, \"\\r\", \"\")\n\tval = strings.ReplaceAll(val, \"\\t\", \"\")\n\treturn strings.TrimSpace(val)\n}\n\nfunc isUnlikelyNode(n *html.Node) bool {\n\tif n.Type != html.ElementNode {\n\t\treturn false\n\t}\n\tclassId := strings.ToLower(getAttr(n, \"class\") + \" \" + getAttr(n, \"id\"))\n\tif classId == \" \" {\n\t\treturn false\n\t}\n\tif strings.Contains(classId, \"article\") || strings.Contains(classId, \"main\") ||\n\t\tstrings.Contains(classId, \"content\") {\n\t\treturn false\n\t}\n\tunlikelyKeywords := []string{\n\t\t\"menu\",\n\t\t\"nav\",\n\t\t\"footer\",\n\t\t\"sidebar\",\n\t\t\"cookie\",\n\t\t\"banner\",\n\t\t\"sponsor\",\n\t\t\"advert\",\n\t\t\"popup\",\n\t\t\"modal\",\n\t\t\"newsletter\",\n\t\t\"share\",\n\t\t\"social\",\n\t}\n\tfor _, keyword := range unlikelyKeywords {\n\t\tif strings.Contains(classId, keyword) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\ntype converter struct {\n\tstack      []*bytes.Buffer\n\tlinkHrefs  []string\n\tlinkStates []bool\n\temphStack  []string // Tracks \"**\", \"*\", \"~~\" for buffered emphasis\n\tolCounters []int\n\tinPre      bool\n\tlistDepth  int\n}\n\nfunc newConverter() *converter {\n\treturn &converter{\n\t\tstack: []*bytes.Buffer{{}},\n\t}\n}\n\nfunc (c *converter) write(s string) {\n\tc.stack[len(c.stack)-1].WriteString(s)\n}\n\nfunc (c *converter) pushBuf() {\n\tc.stack = append(c.stack, &bytes.Buffer{})\n}\n\nfunc (c *converter) popBuf() string {\n\ttop := c.stack[len(c.stack)-1]\n\tc.stack = c.stack[:len(c.stack)-1]\n\treturn top.String()\n}\n\nfunc (c *converter) walk(n *html.Node) {\n\tif n.Type == html.ElementNode {\n\t\tif skipTags[n.Data] {\n\t\t\treturn\n\t\t}\n\t\tif isUnlikelyNode(n) {\n\t\t\treturn\n\t\t}\n\t}\n\n\tif n.Type == html.TextNode {\n\t\ttext := n.Data\n\t\tif !c.inPre {\n\t\t\ttext = strings.ReplaceAll(text, \"\\n\", \" \")\n\t\t\ttext = reSpaces.ReplaceAllString(text, \" \")\n\t\t}\n\t\tif text != \"\" {\n\t\t\tc.write(text)\n\t\t}\n\t\treturn\n\t}\n\n\tif n.Type != html.ElementNode {\n\t\tfor ch := n.FirstChild; ch != nil; ch = ch.NextSibling {\n\t\t\tc.walk(ch)\n\t\t}\n\t\treturn\n\t}\n\n\t// Opening Tags\n\tswitch n.Data {\n\t// Buffer emphasis content so we can TrimSpace the inner text,\n\t// avoiding the regex-across-boundaries bug.\n\tcase \"b\", \"strong\":\n\t\tc.emphStack = append(c.emphStack, \"**\")\n\t\tc.pushBuf()\n\tcase \"i\", \"em\":\n\t\tc.emphStack = append(c.emphStack, \"*\")\n\t\tc.pushBuf()\n\tcase \"del\", \"s\":\n\t\tc.emphStack = append(c.emphStack, \"~~\")\n\t\tc.pushBuf()\n\n\tcase \"a\":\n\t\thref := normalizeAttr(getAttr(n, \"href\"))\n\t\tif href != \"\" && !isSafeHref(href) {\n\t\t\thref = \"#\"\n\t\t}\n\t\thasHref := href != \"\"\n\t\tc.linkStates = append(c.linkStates, hasHref)\n\t\tif hasHref {\n\t\t\tc.linkHrefs = append(c.linkHrefs, href)\n\t\t\tc.pushBuf()\n\t\t}\n\n\tcase \"h1\":\n\t\tc.write(\"\\n\\n# \")\n\tcase \"h2\":\n\t\tc.write(\"\\n\\n## \")\n\tcase \"h3\":\n\t\tc.write(\"\\n\\n### \")\n\tcase \"h4\":\n\t\tc.write(\"\\n\\n#### \")\n\tcase \"h5\":\n\t\tc.write(\"\\n\\n##### \")\n\tcase \"h6\":\n\t\tc.write(\"\\n\\n###### \")\n\n\tcase \"p\":\n\t\tc.write(\"\\n\\n\")\n\tcase \"br\":\n\t\tc.write(\"\\n\")\n\tcase \"hr\":\n\t\tc.write(\"\\n\\n---\\n\\n\")\n\n\tcase \"ol\":\n\t\tc.olCounters = append(c.olCounters, 1)\n\t\t// Only write leading newline for top-level list.\n\t\tif c.listDepth == 0 {\n\t\t\tc.write(\"\\n\")\n\t\t}\n\t\tc.listDepth++\n\tcase \"ul\":\n\t\tif c.listDepth == 0 {\n\t\t\tc.write(\"\\n\")\n\t\t}\n\t\tc.listDepth++\n\tcase \"li\":\n\t\tc.write(\"\\n\")\n\t\tif c.listDepth > 1 {\n\t\t\tc.write(strings.Repeat(\"    \", c.listDepth-1))\n\t\t}\n\t\tif n.Parent != nil && n.Parent.Data == \"ol\" && len(c.olCounters) > 0 {\n\t\t\tidx := c.olCounters[len(c.olCounters)-1]\n\t\t\tc.write(strconv.Itoa(idx) + \". \")\n\t\t\tc.olCounters[len(c.olCounters)-1]++\n\t\t} else {\n\t\t\tc.write(\"- \")\n\t\t}\n\n\tcase \"pre\":\n\t\tc.inPre = true\n\t\tc.write(\"\\n\\n```\\n\")\n\tcase \"code\":\n\t\tif !c.inPre {\n\t\t\tc.write(\"`\")\n\t\t}\n\n\tcase \"blockquote\":\n\t\tc.pushBuf()\n\t\tfor ch := n.FirstChild; ch != nil; ch = ch.NextSibling {\n\t\t\tc.walk(ch)\n\t\t}\n\t\tinner := strings.TrimSpace(c.popBuf())\n\t\tlines := strings.Split(inner, \"\\n\")\n\t\tvar quoted []string\n\t\tfor _, l := range lines {\n\t\t\tif strings.TrimSpace(l) == \"\" {\n\t\t\t\tquoted = append(quoted, \">\")\n\t\t\t} else {\n\t\t\t\tquoted = append(quoted, \"> \"+l)\n\t\t\t}\n\t\t}\n\t\tvar deduped []string\n\t\tfor i, line := range quoted {\n\t\t\tif line == \">\" && i > 0 && deduped[len(deduped)-1] == \">\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeduped = append(deduped, line)\n\t\t}\n\t\tc.write(\"\\n\\n\" + strings.Join(deduped, \"\\n\") + \"\\n\\n\")\n\t\treturn\n\n\tcase \"img\":\n\t\tsrc := normalizeAttr(getAttr(n, \"src\"))\n\t\tif src == \"\" {\n\t\t\tsrc = normalizeAttr(getAttr(n, \"data-src\"))\n\t\t}\n\t\tif src == \"\" {\n\t\t\treturn\n\t\t}\n\t\talt := escapeMdAlt(normalizeAttr(getAttr(n, \"alt\")))\n\t\tif isSafeImageSrc(src) {\n\t\t\tc.write(\"![\" + alt + \"](\" + src + \")\")\n\t\t}\n\t\treturn\n\t}\n\n\t// Traverse Children\n\tfor ch := n.FirstChild; ch != nil; ch = ch.NextSibling {\n\t\tc.walk(ch)\n\t}\n\n\t// Closing Tags\n\tswitch n.Data {\n\t// Pop buffer, trim, wrap with the correct marker.\n\tcase \"b\", \"strong\", \"i\", \"em\", \"del\", \"s\":\n\t\tif len(c.emphStack) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tmarker := c.emphStack[len(c.emphStack)-1]\n\t\tc.emphStack = c.emphStack[:len(c.emphStack)-1]\n\t\tinner := strings.TrimSpace(c.popBuf())\n\t\tif inner != \"\" {\n\t\t\tc.write(marker + inner + marker)\n\t\t}\n\n\tcase \"a\":\n\t\tif len(c.linkStates) == 0 {\n\t\t\tbreak\n\t\t}\n\t\thasHref := c.linkStates[len(c.linkStates)-1]\n\t\tc.linkStates = c.linkStates[:len(c.linkStates)-1]\n\t\tif !hasHref {\n\t\t\tbreak\n\t\t}\n\t\thref := c.linkHrefs[len(c.linkHrefs)-1]\n\t\tc.linkHrefs = c.linkHrefs[:len(c.linkHrefs)-1]\n\t\tinner := strings.TrimSpace(c.popBuf())\n\t\tif strings.Contains(inner, \"\\n\") {\n\t\t\tlines := strings.Split(inner, \"\\n\")\n\t\t\tlinked := false\n\t\t\tfor i, l := range lines {\n\t\t\t\tcleanLine := strings.TrimSpace(l)\n\t\t\t\tif cleanLine != \"\" && !strings.HasPrefix(cleanLine, \"![\") && !linked {\n\t\t\t\t\tlines[i] = \"[\" + cleanLine + \"](\" + href + \")\"\n\t\t\t\t\tlinked = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tc.write(strings.Join(lines, \"\\n\"))\n\t\t} else {\n\t\t\tc.write(\"[\" + inner + \"](\" + href + \")\")\n\t\t}\n\n\tcase \"h1\",\n\t\t\"h2\",\n\t\t\"h3\",\n\t\t\"h4\",\n\t\t\"h5\",\n\t\t\"h6\",\n\t\t\"p\",\n\t\t\"div\",\n\t\t\"section\",\n\t\t\"article\",\n\t\t\"header\",\n\t\t\"footer\",\n\t\t\"aside\",\n\t\t\"nav\",\n\t\t\"figure\":\n\t\tc.write(\"\\n\")\n\n\tcase \"ol\":\n\t\tc.listDepth--\n\t\tif len(c.olCounters) > 0 {\n\t\t\tc.olCounters = c.olCounters[:len(c.olCounters)-1]\n\t\t}\n\t\tif c.listDepth == 0 {\n\t\t\tc.write(\"\\n\")\n\t\t}\n\tcase \"ul\":\n\t\tc.listDepth--\n\t\tif c.listDepth == 0 {\n\t\t\tc.write(\"\\n\")\n\t\t}\n\n\tcase \"pre\":\n\t\tc.inPre = false\n\t\tc.write(\"\\n```\\n\\n\")\n\tcase \"code\":\n\t\tif !c.inPre {\n\t\t\tc.write(\"`\")\n\t\t}\n\t}\n}\n\nfunc HtmlToMarkdown(htmlStr string) (string, error) {\n\tdoc, err := html.Parse(strings.NewReader(htmlStr))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tc := newConverter()\n\tc.walk(doc)\n\n\tres := c.stack[0].String()\n\n\t// Post-processing\n\tres = reImageOnlyLink.ReplaceAllString(res, \"\")\n\tres = reEmptyListItem.ReplaceAllString(res, \"\")\n\tres = reEmptyHeader.ReplaceAllString(res, \"\")\n\n\tlines := strings.Split(res, \"\\n\")\n\tvar cleanLines []string\n\tfor _, line := range lines {\n\t\tline = strings.TrimRight(line, \" \\t\")\n\t\tcleanTest := strings.TrimSpace(line)\n\t\tif cleanTest == \"[](</>)\" || cleanTest == \"[](#)\" || cleanTest == \"-\" {\n\t\t\tcleanLines = append(cleanLines, \"\")\n\t\t\tcontinue\n\t\t}\n\t\tcleanLines = append(cleanLines, line)\n\t}\n\tres = strings.Join(cleanLines, \"\\n\")\n\n\tres = strings.TrimSpace(res)\n\tres = reNewlines.ReplaceAllString(res, \"\\n\\n\")\n\n\t// Strip a single leading space from lines that are NOT list indentation.\n\t// \"(?m)^([ \\t])([^ \\t\\n])\" matches exactly one space/tab at line start followed\n\t// by a non-whitespace char, so \"    - nested\" (4 spaces) is left untouched.\n\tres = reLeadingLineSpace.ReplaceAllString(res, \"$2\")\n\n\treturn res, nil\n}\n"
  },
  {
    "path": "pkg/utils/markdown_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nfunc TestHtmlToMarkdown(t *testing.T) {\n\t// Define our test cases\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Removes scripts and styles\",\n\t\t\tinput:    `<script>alert(\"hello\");</script><style>body { color: red; }</style><p>Clean text</p>`,\n\t\t\texpected: \"Clean text\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Extracts links correctly\",\n\t\t\tinput:    `Visit my <a href=\"https://example.com\">website</a> for info.`,\n\t\t\texpected: \"Visit my [website](https://example.com) for info.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Converts headers (H1, H2, H3)\",\n\t\t\tinput:    `<h1>Main Title</h1><h2>Subtitle</h2><h3>Section</h3>`,\n\t\t\texpected: \"# Main Title\\n\\n## Subtitle\\n\\n### Section\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Handles bold and italics\",\n\t\t\tinput:    `Text <b>bold</b> and <strong>strong</strong>, then <i>italic</i> and <em>em</em>.`,\n\t\t\texpected: \"Text **bold** and **strong**, then *italic* and *em*.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Converts lists\",\n\t\t\tinput:    `<ul><li>First element</li><li>Second element</li></ul>`,\n\t\t\texpected: \"- First element\\n- Second element\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Handles paragraphs and line breaks (<br>)\",\n\t\t\tinput:    `<p>First paragraph</p><p>Second paragraph with<br>a line break.</p>`,\n\t\t\texpected: \"First paragraph\\n\\nSecond paragraph with\\na line break.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Decodes HTML entities\",\n\t\t\tinput:    `Math: 5 &gt; 3 &amp; 2 &lt; 4. A &quot;quote&quot;.`,\n\t\t\texpected: \"Math: 5 > 3 & 2 < 4. A \\\"quote\\\".\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Cleans up residual HTML tags\",\n\t\t\tinput:    `<div><span>Text inside div and span</span></div>`,\n\t\t\texpected: \"Text inside div and span\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Removes multiple spaces and excessive empty lines\",\n\t\t\tinput:    `This   text    has too many spaces. <br><br><br><br> And too many newlines.`,\n\t\t\texpected: \"This text has too many spaces.\\n\\nAnd too many newlines.\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Nested lists with indentation\",\n\t\t\tinput: \"<ul><li>One<ul><li>Two</li></ul></li></ul>\",\n\t\t\t// Expect the sub-element to have 4 spaces of indentation\n\t\t\texpected: \"- One\\n    - Two\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Image support\",\n\t\t\tinput: `<img src=\"image.jpg\" alt=\"alternative text\">`,\n\t\t\t// Correct Markdown syntax for images\n\t\t\texpected: \"![alternative text](image.jpg)\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Image support without alt-text\",\n\t\t\tinput: `<img src=\"image.jpg\">`,\n\t\t\t// If alt is missing, square brackets remain empty\n\t\t\texpected: \"![](image.jpg)\",\n\t\t},\n\t\t{\n\t\t\tname: \"XSS Bypass on Links (Obfuscated HTML entities)\",\n\t\t\t// The Go HTML parser resolves entities, so this becomes \"javascript:alert(1)\"\n\t\t\tinput: `<a href=\"jav&#x09;ascript:alert(1)\">Click here</a>`,\n\t\t\t// Our isSafeHref (if updated with net/url) should neutralize it to \"#\"\n\t\t\texpected: \"[Click here](#)\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Empty link or used as anchor\",\n\t\t\tinput: `<a name=\"top\"></a>`,\n\t\t\t// With no text or href, it shouldn't print anything (not even empty brackets)\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Link without href but with text (Textual anchor)\",\n\t\t\tinput: `<a id=\"top\">Back to top</a>`,\n\t\t\t// Should extract only plain text, without generating a broken Markdown link like [Back to top](#) or [Back to top]()\n\t\t\texpected: \"Back to top\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Badly spaced bold and italics (Edge Case)\",\n\t\t\tinput: `<b> Text </b>`,\n\t\t\t// In Markdown `** Text **` is often not formatted correctly. The ideal is `**Text**`\n\t\t\texpected: \"**Text**\",\n\t\t},\n\t\t{\n\t\t\tname: \"Complex Test - Real Article\",\n\t\t\tinput: `\n             <h1>Article Title</h1>\n             <p>This is an <strong>introductory text</strong> with a <a href=\"http://link.com\">link</a>.</p>\n             <h2>Subtitle</h2>\n             <ul>\n                <li>Point one</li>\n                <li>Point two</li>\n             </ul>\n             <script>console.log(\"do not show me\")</script>\n          `,\n\t\t\t// Note: The indentation of the real HTML test will generate spaces that\n\t\t\t// regex will clean up.\n\t\t\texpected: \"# Article Title\\n\\nThis is an **introductory text** with a [link](http://link.com).\\n\\n## Subtitle\\n\\n- Point one\\n- Point two\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Ordered list (OL)\",\n\t\t\tinput:    `<ol><li>First</li><li>Second</li><li>Third</li></ol>`,\n\t\t\texpected: \"1. First\\n2. Second\\n3. Third\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Ordered list nested in unordered list\",\n\t\t\tinput:    `<ul><li>Fruits<ol><li>Apples</li><li>Pears</li></ol></li><li>Vegetables</li></ul>`,\n\t\t\texpected: \"- Fruits\\n    1. Apples\\n    2. Pears\\n- Vegetables\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Code block (pre/code)\",\n\t\t\tinput:    \"<pre><code>func main() {\\n    fmt.Println(\\\"hello\\\")\\n}</code></pre>\",\n\t\t\texpected: \"```\\nfunc main() {\\n    fmt.Println(\\\"hello\\\")\\n}\\n```\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Inline code\",\n\t\t\tinput:    `<p>Use the command <code>go test ./...</code> to run the tests.</p>`,\n\t\t\texpected: \"Use the command `go test ./...` to run the tests.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Simple blockquote\",\n\t\t\tinput:    `<blockquote><p>An important quote.</p></blockquote>`,\n\t\t\texpected: \"> An important quote.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Multiline blockquote\",\n\t\t\tinput:    `<blockquote><p>First line of the quote.</p><p>Second line of the quote.</p></blockquote>`,\n\t\t\texpected: \"> First line of the quote.\\n>\\n> Second line of the quote.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Strikethrough text (del/s)\",\n\t\t\tinput:    `This text is <del>deleted</del> and this is <s>crossed out</s>.`,\n\t\t\texpected: \"This text is ~~deleted~~ and this is ~~crossed out~~.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Horizontal separator (HR)\",\n\t\t\tinput:    `<p>Above the line</p><hr><p>Below the line</p>`,\n\t\t\texpected: \"Above the line\\n\\n---\\n\\nBelow the line\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Bold nested in link\",\n\t\t\tinput:    `<a href=\"https://example.com\"><strong>Linked bold text</strong></a>`,\n\t\t\texpected: \"[**Linked bold text**](https://example.com)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"data-src Image (lazy loading)\",\n\t\t\tinput:    `<img data-src=\"lazy.jpg\" alt=\"Lazy image\">`,\n\t\t\texpected: \"![Lazy image](lazy.jpg)\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Image with javascript: src blocked\",\n\t\t\tinput: `<img src=\"javascript:alert(1)\" alt=\"XSS\">`,\n\t\t\t// src is not safe, so the image is not emitted\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Link with data: href blocked\",\n\t\t\tinput:    `<a href=\"data:text/html,<script>alert(1)</script>\">Click</a>`,\n\t\t\texpected: \"[Click](#)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Deeply nested divs\",\n\t\t\tinput:    `<div><div><div><div><p>Deeply nested text</p></div></div></div></div>`,\n\t\t\texpected: \"Deeply nested text\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Non-consecutive headers (H1, H3, H5)\",\n\t\t\tinput:    `<h1>Title</h1><h3>Subsection</h3><h5>Sub-subsection</h5>`,\n\t\t\texpected: \"# Title\\n\\n### Subsection\\n\\n##### Sub-subsection\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Paragraph with mixed multiple emphasis\",\n\t\t\tinput:    `<p><strong>Important:</strong> read the <strong><em>critical instructions</em></strong> <em>carefully</em>.</p>`,\n\t\t\texpected: \"**Important:** read the ***critical instructions*** *carefully*.\",\n\t\t},\n\t\t{\n\t\t\tname: \"Article with nav and aside sections (noise to filter)\",\n\t\t\tinput: `\n        <nav><a href=\"/home\">Home</a><a href=\"/about-us\">About us</a></nav>\n        <article>\n            <h2>Article title</h2>\n            <p>This is the body of the article.</p>\n        </article>\n        <aside><p>Advertisement</p></aside>\n       `,\n\t\t\texpected: \"## Article title\\n\\nThis is the body of the article.\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Text with mixed special HTML entities\",\n\t\t\tinput:    `Copyright &copy; 2024 &mdash; All rights reserved &reg;`,\n\t\t\texpected: \"Copyright © 2024 — All rights reserved ®\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Mailto link\",\n\t\t\tinput:    `Write to us at <a href=\"mailto:info@example.com\">info@example.com</a>`,\n\t\t\texpected: \"Write to us at [info@example.com](mailto:info@example.com)\",\n\t\t},\n\t\t{\n\t\t\tname:  \"Image inside a link (clickable figure)\",\n\t\t\tinput: `<a href=\"https://example.com\"><img src=\"photo.jpg\" alt=\"Photo\"></a>`,\n\t\t\t// The image-link without text must not generate broken markup\n\t\t\texpected: \"[![Photo](photo.jpg)](https://example.com)\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Empty content or only whitespace\",\n\t\t\tinput:    `   <p>  </p>  <div>   </div>  `,\n\t\t\texpected: \"\",\n\t\t},\n\t}\n\n\t// Iterate over all test cases\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := HtmlToMarkdown(tt.input)\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorCF(\"tool\", \"Failed to parse html to markdown: %s\", map[string]any{\"error\": err.Error()})\n\t\t\t}\n\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"\\nTest case failed: %s\\nInput:    %q\\nGot:      %q\\nExpected: %q\",\n\t\t\t\t\ttt.name, tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/media.go",
    "content": "package utils\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/media\"\n)\n\n// IsAudioFile checks if a file is an audio file based on its filename extension and content type.\nfunc IsAudioFile(filename, contentType string) bool {\n\taudioExtensions := []string{\".mp3\", \".wav\", \".ogg\", \".m4a\", \".flac\", \".aac\", \".wma\"}\n\taudioTypes := []string{\"audio/\", \"application/ogg\", \"application/x-ogg\"}\n\n\tfor _, ext := range audioExtensions {\n\t\tif strings.HasSuffix(strings.ToLower(filename), ext) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tfor _, audioType := range audioTypes {\n\t\tif strings.HasPrefix(strings.ToLower(contentType), audioType) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// SanitizeFilename removes potentially dangerous characters from a filename\n// and returns a safe version for local filesystem storage.\nfunc SanitizeFilename(filename string) string {\n\t// Get the base filename without path\n\tbase := filepath.Base(filename)\n\n\t// Remove any directory traversal attempts\n\tbase = strings.ReplaceAll(base, \"..\", \"\")\n\tbase = strings.ReplaceAll(base, \"/\", \"_\")\n\tbase = strings.ReplaceAll(base, \"\\\\\", \"_\")\n\n\treturn base\n}\n\n// DownloadOptions holds optional parameters for downloading files\ntype DownloadOptions struct {\n\tTimeout      time.Duration\n\tExtraHeaders map[string]string\n\tLoggerPrefix string\n\tProxyURL     string\n}\n\n// DownloadFile downloads a file from URL to a local temp directory.\n// Returns the local file path or empty string on error.\nfunc DownloadFile(urlStr, filename string, opts DownloadOptions) string {\n\t// Set defaults\n\tif opts.Timeout == 0 {\n\t\topts.Timeout = 60 * time.Second\n\t}\n\tif opts.LoggerPrefix == \"\" {\n\t\topts.LoggerPrefix = \"utils\"\n\t}\n\n\tmediaDir := media.TempDir()\n\tif err := os.MkdirAll(mediaDir, 0o700); err != nil {\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Failed to create media directory\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\t// Generate unique filename with UUID prefix to prevent conflicts\n\tsafeName := SanitizeFilename(filename)\n\tlocalPath := filepath.Join(mediaDir, uuid.New().String()[:8]+\"_\"+safeName)\n\n\t// Create HTTP request\n\treq, err := http.NewRequest(\"GET\", urlStr, nil)\n\tif err != nil {\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Failed to create download request\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\t// Add extra headers (e.g., Authorization for Slack)\n\tfor key, value := range opts.ExtraHeaders {\n\t\treq.Header.Set(key, value)\n\t}\n\n\tclient := &http.Client{Timeout: opts.Timeout}\n\tif opts.ProxyURL != \"\" {\n\t\tproxyURL, parseErr := url.Parse(opts.ProxyURL)\n\t\tif parseErr != nil {\n\t\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Invalid proxy URL for download\", map[string]any{\n\t\t\t\t\"error\": parseErr.Error(),\n\t\t\t\t\"proxy\": opts.ProxyURL,\n\t\t\t})\n\t\t\treturn \"\"\n\t\t}\n\t\tclient.Transport = &http.Transport{\n\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t}\n\t}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Failed to download file\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t\t\"url\":   urlStr,\n\t\t})\n\t\treturn \"\"\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"File download returned non-200 status\", map[string]any{\n\t\t\t\"status\": resp.StatusCode,\n\t\t\t\"url\":    urlStr,\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tout, err := os.Create(localPath)\n\tif err != nil {\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Failed to create local file\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\tdefer out.Close()\n\n\tif _, err := io.Copy(out, resp.Body); err != nil {\n\t\tout.Close()\n\t\tos.Remove(localPath)\n\t\tlogger.ErrorCF(opts.LoggerPrefix, \"Failed to write file\", map[string]any{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn \"\"\n\t}\n\n\tlogger.DebugCF(opts.LoggerPrefix, \"File downloaded successfully\", map[string]any{\n\t\t\"path\": localPath,\n\t})\n\n\treturn localPath\n}\n\n// DownloadFileSimple is a simplified version of DownloadFile without options\nfunc DownloadFileSimple(url, filename string) string {\n\treturn DownloadFile(url, filename, DownloadOptions{\n\t\tLoggerPrefix: \"media\",\n\t})\n}\n"
  },
  {
    "path": "pkg/utils/skills.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// ValidateSkillIdentifier validates that the given skill identifier (slug or registry name) is non-empty\n// and does not contain path separators (\"/\", \"\\\\\") or \"..\" for security.\nfunc ValidateSkillIdentifier(identifier string) error {\n\ttrimmed := strings.TrimSpace(identifier)\n\tif trimmed == \"\" {\n\t\treturn fmt.Errorf(\"identifier is required and must be a non-empty string\")\n\t}\n\tif strings.ContainsAny(trimmed, \"/\\\\\") || strings.Contains(trimmed, \"..\") {\n\t\treturn fmt.Errorf(\"identifier must not contain path separators or '..' to prevent directory traversal\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/utils/string.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"unicode\"\n)\n\n// Global variable to disable truncation\nvar disableTruncation atomic.Bool\n\n// SetDisableTruncation globally enables or disables string truncation\nfunc SetDisableTruncation(enabled bool) {\n\tdisableTruncation.Store(enabled)\n}\n\n// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,\n// zero-width characters), and other non-graphic characters that could confuse an LLM\n// or cause display issues in the agent UI.\nfunc SanitizeMessageContent(input string) string {\n\tvar sb strings.Builder\n\t// Pre-allocate memory to avoid multiple allocations\n\tsb.Grow(len(input))\n\n\tfor _, r := range input {\n\t\t// unicode.IsGraphic returns true if the rune is a Unicode graphic character.\n\t\t// This includes letters, marks, numbers, punctuation, and symbols.\n\t\t// It excludes control characters (Cc), format characters (Cf),\n\t\t// surrogates (Cs), and private use (Co).\n\t\tif unicode.IsGraphic(r) || r == '\\n' || r == '\\r' || r == '\\t' {\n\t\t\tsb.WriteRune(r)\n\t\t}\n\t}\n\n\treturn sb.String()\n}\n\n// Truncate returns a truncated version of s with at most maxLen runes.\n// Handles multi-byte Unicode characters properly.\n// If the string is truncated, \"...\" is appended to indicate truncation.\nfunc Truncate(s string, maxLen int) string {\n\t// If the no-truncate flag is active, it returns the full string\n\tif disableTruncation.Load() {\n\t\treturn s\n\t}\n\tif maxLen <= 0 {\n\t\treturn \"\"\n\t}\n\trunes := []rune(s)\n\tif len(runes) <= maxLen {\n\t\treturn s\n\t}\n\t// Reserve 3 chars for \"...\"\n\tif maxLen <= 3 {\n\t\treturn string(runes[:maxLen])\n\t}\n\treturn string(runes[:maxLen-3]) + \"...\"\n}\n\n// DerefStr dereferences a pointer to a string and\n// returns the value or a fallback if the pointer is nil.\nfunc DerefStr(s *string, fallback string) string {\n\tif s == nil {\n\t\treturn fallback\n\t}\n\treturn *s\n}\n"
  },
  {
    "path": "pkg/utils/string_test.go",
    "content": "package utils\n\nimport \"testing\"\n\nfunc TestTruncate(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tinput  string\n\t\tmaxLen int\n\t\twant   string\n\t}{\n\t\t{\n\t\t\tname:   \"short string unchanged\",\n\t\t\tinput:  \"hi\",\n\t\t\tmaxLen: 10,\n\t\t\twant:   \"hi\",\n\t\t},\n\t\t{\n\t\t\tname:   \"exact length unchanged\",\n\t\t\tinput:  \"hello\",\n\t\t\tmaxLen: 5,\n\t\t\twant:   \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:   \"long string truncated with ellipsis\",\n\t\t\tinput:  \"hello world\",\n\t\t\tmaxLen: 8,\n\t\t\twant:   \"hello...\",\n\t\t},\n\t\t{\n\t\t\tname:   \"maxLen equals 4 leaves 1 char plus ellipsis\",\n\t\t\tinput:  \"abcdef\",\n\t\t\tmaxLen: 4,\n\t\t\twant:   \"a...\",\n\t\t},\n\t\t{\n\t\t\tname:   \"maxLen 3 returns first 3 chars without ellipsis\",\n\t\t\tinput:  \"abcdef\",\n\t\t\tmaxLen: 3,\n\t\t\twant:   \"abc\",\n\t\t},\n\t\t{\n\t\t\tname:   \"maxLen 2 returns first 2 chars\",\n\t\t\tinput:  \"abcdef\",\n\t\t\tmaxLen: 2,\n\t\t\twant:   \"ab\",\n\t\t},\n\t\t{\n\t\t\tname:   \"maxLen 1 returns first char\",\n\t\t\tinput:  \"abcdef\",\n\t\t\tmaxLen: 1,\n\t\t\twant:   \"a\",\n\t\t},\n\t\t{\n\t\t\tname:   \"maxLen 0 returns empty\",\n\t\t\tinput:  \"hello\",\n\t\t\tmaxLen: 0,\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:   \"negative maxLen returns empty\",\n\t\t\tinput:  \"hello\",\n\t\t\tmaxLen: -1,\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:   \"empty string unchanged\",\n\t\t\tinput:  \"\",\n\t\t\tmaxLen: 5,\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:   \"empty string with zero maxLen\",\n\t\t\tinput:  \"\",\n\t\t\tmaxLen: 0,\n\t\t\twant:   \"\",\n\t\t},\n\t\t{\n\t\t\tname:   \"unicode truncated correctly\",\n\t\t\tinput:  \"\\U0001f600\\U0001f601\\U0001f602\\U0001f603\\U0001f604\",\n\t\t\tmaxLen: 4,\n\t\t\twant:   \"\\U0001f600...\",\n\t\t},\n\t\t{\n\t\t\tname:   \"unicode short enough\",\n\t\t\tinput:  \"\\u00e9\\u00e8\",\n\t\t\tmaxLen: 5,\n\t\t\twant:   \"\\u00e9\\u00e8\",\n\t\t},\n\t\t{\n\t\t\tname:   \"mixed ascii and unicode\",\n\t\t\tinput:  \"Go\\U0001f680\\U0001f525\\U0001f4a5\\U0001f30d\",\n\t\t\tmaxLen: 5,\n\t\t\twant:   \"Go...\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := Truncate(tt.input, tt.maxLen)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"Truncate(%q, %d) = %q, want %q\", tt.input, tt.maxLen, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSanitizeMessageContent(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t\twant  string\n\t}{\n\t\t{\"empty\", \"\", \"\"},\n\t\t{\"plain text unchanged\", \"Hello world\", \"Hello world\"},\n\t\t{\"strip ZWSP\", \"Hello\\u200bworld\", \"Helloworld\"},\n\t\t{\"strip RTL override\", \"Hi\\u202eevil\", \"Hievil\"},\n\t\t{\"strip BOM\", \"\\uFEFFcontent\", \"content\"},\n\t\t{\"strip multiple\", \"a\\u200c\\u202ab\\u202cc\", \"abc\"},\n\t\t{\"unicode letters preserved\", \"café \\u65e5\\u672c\\u8a9e\", \"café \\u65e5\\u672c\\u8a9e\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := SanitizeMessageContent(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"SanitizeMessageContent(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/zip.go",
    "content": "package utils\n\nimport (\n\t\"archive/zip\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// ExtractZipFile extracts a ZIP archive from disk to targetDir.\n// It reads entries one at a time from disk, keeping memory usage minimal.\n//\n// Security: rejects path traversal attempts and symlinks.\nfunc ExtractZipFile(zipPath string, targetDir string) error {\n\treader, err := zip.OpenReader(zipPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid ZIP: %w\", err)\n\t}\n\tdefer reader.Close()\n\n\tlogger.DebugCF(\"zip\", \"Extracting ZIP\", map[string]any{\n\t\t\"zip_path\":   zipPath,\n\t\t\"target_dir\": targetDir,\n\t\t\"entries\":    len(reader.File),\n\t})\n\n\tif err := os.MkdirAll(targetDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create target dir: %w\", err)\n\t}\n\n\tfor _, f := range reader.File {\n\t\t// Path traversal protection.\n\t\tcleanName := filepath.Clean(f.Name)\n\t\tif strings.HasPrefix(cleanName, \"..\") || filepath.IsAbs(cleanName) {\n\t\t\treturn fmt.Errorf(\"zip entry has unsafe path: %q\", f.Name)\n\t\t}\n\n\t\tdestPath := filepath.Join(targetDir, cleanName)\n\n\t\t// Double-check the resolved path is within target directory (defense-in-depth).\n\t\ttargetDirClean := filepath.Clean(targetDir)\n\t\tif !strings.HasPrefix(filepath.Clean(destPath), targetDirClean+string(filepath.Separator)) &&\n\t\t\tfilepath.Clean(destPath) != targetDirClean {\n\t\t\treturn fmt.Errorf(\"zip entry escapes target dir: %q\", f.Name)\n\t\t}\n\n\t\tmode := f.FileInfo().Mode()\n\n\t\t// Reject any symlink.\n\t\tif mode&os.ModeSymlink != 0 {\n\t\t\treturn fmt.Errorf(\"zip contains symlink %q; symlinks are not allowed\", f.Name)\n\t\t}\n\n\t\tif f.FileInfo().IsDir() {\n\t\t\tif err := os.MkdirAll(destPath, 0o755); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Ensure parent directory exists.\n\t\tif err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := extractSingleFile(f, destPath); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// extractSingleFile extracts one zip.File entry to destPath, with a size check.\nfunc extractSingleFile(f *zip.File, destPath string) error {\n\tconst maxFileSize = 5 * 1024 * 1024 // 5MB, adjust as appropriate\n\n\t// Check the uncompressed size from the header, if available.\n\tif f.UncompressedSize64 > maxFileSize {\n\t\treturn fmt.Errorf(\"zip entry %q is too large (%d bytes)\", f.Name, f.UncompressedSize64)\n\t}\n\n\trc, err := f.Open()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open zip entry %q: %w\", f.Name, err)\n\t}\n\tdefer rc.Close()\n\n\toutFile, err := os.Create(destPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create file %q: %w\", destPath, err)\n\t}\n\t// We don't return the close error via return, since it's not a named error return.\n\t// Instead, we log to stderr and remove the partially written file as defensive cleanup.\n\tdefer func() {\n\t\tif cerr := outFile.Close(); cerr != nil {\n\t\t\t_ = os.Remove(destPath)\n\t\t\tlogger.ErrorCF(\"zip\", \"Failed to close file\", map[string]any{\n\t\t\t\t\"dest_path\": destPath,\n\t\t\t\t\"error\":     cerr.Error(),\n\t\t\t})\n\t\t}\n\t}()\n\n\t// Streamed size check: prevent overruns and malicious/corrupt headers.\n\twritten, err := io.CopyN(outFile, rc, maxFileSize+1)\n\tif err != nil && err != io.EOF {\n\t\t_ = os.Remove(destPath)\n\t\treturn fmt.Errorf(\"failed to extract %q: %w\", f.Name, err)\n\t}\n\tif written > maxFileSize {\n\t\t_ = os.Remove(destPath)\n\t\treturn fmt.Errorf(\"zip entry %q exceeds max size (%d bytes)\", f.Name, written)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/voice/transcriber.go",
    "content": "package voice\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/utils\"\n)\n\ntype Transcriber interface {\n\tName() string\n\tTranscribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)\n}\n\ntype GroqTranscriber struct {\n\tapiKey     string\n\tapiBase    string\n\thttpClient *http.Client\n}\n\ntype TranscriptionResponse struct {\n\tText     string  `json:\"text\"`\n\tLanguage string  `json:\"language,omitempty\"`\n\tDuration float64 `json:\"duration,omitempty\"`\n}\n\nfunc NewGroqTranscriber(apiKey string) *GroqTranscriber {\n\tlogger.DebugCF(\"voice\", \"Creating Groq transcriber\", map[string]any{\"has_api_key\": apiKey != \"\"})\n\n\tapiBase := \"https://api.groq.com/openai/v1\"\n\treturn &GroqTranscriber{\n\t\tapiKey:  apiKey,\n\t\tapiBase: apiBase,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 60 * time.Second,\n\t\t},\n\t}\n}\n\nfunc (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {\n\tlogger.InfoCF(\"voice\", \"Starting transcription\", map[string]any{\"audio_file\": audioFilePath})\n\n\taudioFile, err := os.Open(audioFilePath)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to open audio file\", map[string]any{\"path\": audioFilePath, \"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to open audio file: %w\", err)\n\t}\n\tdefer audioFile.Close()\n\n\tfileInfo, err := audioFile.Stat()\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to get file info\", map[string]any{\"path\": audioFilePath, \"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to get file info: %w\", err)\n\t}\n\n\tlogger.DebugCF(\"voice\", \"Audio file details\", map[string]any{\n\t\t\"size_bytes\": fileInfo.Size(),\n\t\t\"file_name\":  filepath.Base(audioFilePath),\n\t})\n\n\tvar requestBody bytes.Buffer\n\twriter := multipart.NewWriter(&requestBody)\n\n\tpart, err := writer.CreateFormFile(\"file\", filepath.Base(audioFilePath))\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to create form file\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to create form file: %w\", err)\n\t}\n\n\tcopied, err := io.Copy(part, audioFile)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to copy file content\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to copy file content: %w\", err)\n\t}\n\n\tlogger.DebugCF(\"voice\", \"File copied to request\", map[string]any{\"bytes_copied\": copied})\n\n\tif err = writer.WriteField(\"model\", \"whisper-large-v3\"); err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to write model field\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to write model field: %w\", err)\n\t}\n\n\tif err = writer.WriteField(\"response_format\", \"json\"); err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to write response_format field\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to write response_format field: %w\", err)\n\t}\n\n\tif err = writer.Close(); err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to close multipart writer\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to close multipart writer: %w\", err)\n\t}\n\n\turl := t.apiBase + \"/audio/transcriptions\"\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", url, &requestBody)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to create request\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\treq.Header.Set(\"Authorization\", \"Bearer \"+t.apiKey)\n\n\tlogger.DebugCF(\"voice\", \"Sending transcription request to Groq API\", map[string]any{\n\t\t\"url\":                url,\n\t\t\"request_size_bytes\": requestBody.Len(),\n\t\t\"file_size_bytes\":    fileInfo.Size(),\n\t})\n\n\tresp, err := t.httpClient.Do(req)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to send request\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to read response\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tlogger.ErrorCF(\"voice\", \"API error\", map[string]any{\n\t\t\t\"status_code\": resp.StatusCode,\n\t\t\t\"response\":    string(body),\n\t\t})\n\t\treturn nil, fmt.Errorf(\"API error (status %d): %s\", resp.StatusCode, string(body))\n\t}\n\n\tlogger.DebugCF(\"voice\", \"Received response from Groq API\", map[string]any{\n\t\t\"status_code\":         resp.StatusCode,\n\t\t\"response_size_bytes\": len(body),\n\t})\n\n\tvar result TranscriptionResponse\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\tlogger.ErrorCF(\"voice\", \"Failed to unmarshal response\", map[string]any{\"error\": err})\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal response: %w\", err)\n\t}\n\n\tlogger.InfoCF(\"voice\", \"Transcription completed successfully\", map[string]any{\n\t\t\"text_length\":           len(result.Text),\n\t\t\"language\":              result.Language,\n\t\t\"duration_seconds\":      result.Duration,\n\t\t\"transcription_preview\": utils.Truncate(result.Text, 50),\n\t})\n\n\treturn &result, nil\n}\n\nfunc (t *GroqTranscriber) Name() string {\n\treturn \"groq\"\n}\n\n// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or\n// nil if no supported transcription provider is configured.\nfunc DetectTranscriber(cfg *config.Config) Transcriber {\n\t// Direct Groq provider config takes priority.\n\tif key := cfg.Providers.Groq.APIKey; key != \"\" {\n\t\treturn NewGroqTranscriber(key)\n\t}\n\t// Fall back to any model-list entry that uses the groq/ protocol.\n\tfor _, mc := range cfg.ModelList {\n\t\tif strings.HasPrefix(mc.Model, \"groq/\") && mc.APIKey != \"\" {\n\t\t\treturn NewGroqTranscriber(mc.APIKey)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/voice/transcriber_test.go",
    "content": "package voice\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// Ensure GroqTranscriber satisfies the Transcriber interface at compile time.\nvar _ Transcriber = (*GroqTranscriber)(nil)\n\nfunc TestGroqTranscriberName(t *testing.T) {\n\ttr := NewGroqTranscriber(\"sk-test\")\n\tif got := tr.Name(); got != \"groq\" {\n\t\tt.Errorf(\"Name() = %q, want %q\", got, \"groq\")\n\t}\n}\n\nfunc TestDetectTranscriber(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcfg      *config.Config\n\t\twantNil  bool\n\t\twantName string\n\t}{\n\t\t{\n\t\t\tname:    \"no config\",\n\t\t\tcfg:     &config.Config{},\n\t\t\twantNil: true,\n\t\t},\n\t\t{\n\t\t\tname: \"groq provider key\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tProviders: config.ProvidersConfig{\n\t\t\t\t\tGroq: config.ProviderConfig{APIKey: \"sk-groq-direct\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantName: \"groq\",\n\t\t},\n\t\t{\n\t\t\tname: \"groq via model list\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tModelList: []config.ModelConfig{\n\t\t\t\t\t{Model: \"openai/gpt-4o\", APIKey: \"sk-openai\"},\n\t\t\t\t\t{Model: \"groq/llama-3.3-70b\", APIKey: \"sk-groq-model\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantName: \"groq\",\n\t\t},\n\t\t{\n\t\t\tname: \"groq model list entry without key is skipped\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tModelList: []config.ModelConfig{\n\t\t\t\t\t{Model: \"groq/llama-3.3-70b\", APIKey: \"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantNil: true,\n\t\t},\n\t\t{\n\t\t\tname: \"provider key takes priority over model list\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tProviders: config.ProvidersConfig{\n\t\t\t\t\tGroq: config.ProviderConfig{APIKey: \"sk-groq-direct\"},\n\t\t\t\t},\n\t\t\t\tModelList: []config.ModelConfig{\n\t\t\t\t\t{Model: \"groq/llama-3.3-70b\", APIKey: \"sk-groq-model\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantName: \"groq\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ttr := DetectTranscriber(tc.cfg)\n\t\t\tif tc.wantNil {\n\t\t\t\tif tr != nil {\n\t\t\t\t\tt.Errorf(\"DetectTranscriber() = %v, want nil\", tr)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tr == nil {\n\t\t\t\tt.Fatal(\"DetectTranscriber() = nil, want non-nil\")\n\t\t\t}\n\t\t\tif got := tr.Name(); got != tc.wantName {\n\t\t\t\tt.Errorf(\"Name() = %q, want %q\", got, tc.wantName)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTranscribe(t *testing.T) {\n\t// Write a minimal fake audio file so the transcriber can open and send it.\n\ttmpDir := t.TempDir()\n\taudioPath := filepath.Join(tmpDir, \"clip.ogg\")\n\tif err := os.WriteFile(audioPath, []byte(\"fake-audio-data\"), 0o644); err != nil {\n\t\tt.Fatalf(\"failed to write fake audio file: %v\", err)\n\t}\n\n\tt.Run(\"success\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.URL.Path != \"/audio/transcriptions\" {\n\t\t\t\tt.Errorf(\"unexpected path: %s\", r.URL.Path)\n\t\t\t}\n\t\t\tif r.Header.Get(\"Authorization\") != \"Bearer sk-test\" {\n\t\t\t\tt.Errorf(\"unexpected Authorization header: %s\", r.Header.Get(\"Authorization\"))\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_ = json.NewEncoder(w).Encode(TranscriptionResponse{\n\t\t\t\tText:     \"hello world\",\n\t\t\t\tLanguage: \"en\",\n\t\t\t\tDuration: 1.5,\n\t\t\t})\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\ttr := NewGroqTranscriber(\"sk-test\")\n\t\ttr.apiBase = srv.URL\n\n\t\tresp, err := tr.Transcribe(context.Background(), audioPath)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Transcribe() error: %v\", err)\n\t\t}\n\t\tif resp.Text != \"hello world\" {\n\t\t\tt.Errorf(\"Text = %q, want %q\", resp.Text, \"hello world\")\n\t\t}\n\t\tif resp.Language != \"en\" {\n\t\t\tt.Errorf(\"Language = %q, want %q\", resp.Language, \"en\")\n\t\t}\n\t})\n\n\tt.Run(\"api error\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\thttp.Error(w, `{\"error\":\"invalid_api_key\"}`, http.StatusUnauthorized)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\ttr := NewGroqTranscriber(\"sk-bad\")\n\t\ttr.apiBase = srv.URL\n\n\t\t_, err := tr.Transcribe(context.Background(), audioPath)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for non-200 response, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"missing file\", func(t *testing.T) {\n\t\ttr := NewGroqTranscriber(\"sk-test\")\n\t\t_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, \"nonexistent.ogg\"))\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for missing file, got nil\")\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "scripts/build-macos-app.sh",
    "content": "#!/bin/bash\n# Build macOS .app bundle for PicoClaw Launcher\n\nset -e\n\nEXECUTABLE=$1\n\nif [ -z \"$EXECUTABLE\" ]; then\n    echo \"Usage: $0 <executable>\"\n    exit 1\nfi\n\necho \"executable: $EXECUTABLE\"\n\nAPP_NAME=\"PicoClaw Launcher\"\nAPP_PATH=\"./build/${APP_NAME}.app\"\nAPP_CONTENTS=\"${APP_PATH}/Contents\"\nAPP_MACOS=\"${APP_CONTENTS}/MacOS\"\nAPP_RESOURCES=\"${APP_CONTENTS}/Resources\"\nAPP_EXECUTABLE=\"picoclaw-launcher\"\nICON_SOURCE=\"./scripts/icon.icns\"\n\n# Clean up existing .app\nif [ -d \"$APP_PATH\" ]; then\n    echo \"Removing existing ${APP_PATH}\"\n    rm -rf \"$APP_PATH\"\nfi\n\n# Create directory structure\necho \"Creating .app bundle structure...\"\nmkdir -p \"$APP_MACOS\"\nmkdir -p \"$APP_RESOURCES\"\n\n# Copy executable\necho \"Copying executable...\"\nif [ -f \"./web/build/${APP_EXECUTABLE}\" ]; then\n    cp \"./web/build/${APP_EXECUTABLE}\" \"${APP_MACOS}/\"\nelse\n    echo \"Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first.\"\n    echo \"Run: make build in web dir\"\n    exit 1\nfi\nif [ -f \"./build/picoclaw\" ]; then\n    cp \"./build/picoclaw\" \"${APP_MACOS}/\"\nelse\n    echo \"Error: ./build/picoclaw not found. Please build the main file first.\"\n    echo \"Run: make build\"\n    exit 1\nfi\nchmod +x \"${APP_MACOS}/\"*\n\n# Create Info.plist\necho \"Creating Info.plist...\"\ncat > \"${APP_CONTENTS}/Info.plist\" << 'EOF'\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleExecutable</key>\n    <string>picoclaw-launcher</string>\n    <key>CFBundleIdentifier</key>\n    <string>com.picoclaw.launcher</string>\n    <key>CFBundleName</key>\n    <string>PicoClaw Launcher</string>\n    <key>CFBundleDisplayName</key>\n    <string>PicoClaw Launcher</string>\n    <key>CFBundleIconFile</key>\n    <string>icon.icns</string>\n    <key>CFBundlePackageType</key>\n    <string>APPL</string>\n    <key>CFBundleShortVersionString</key>\n    <string>1.0</string>\n    <key>CFBundleVersion</key>\n    <string>1</string>\n    <key>NSHighResolutionCapable</key>\n    <true/>\n    <key>NSSupportsAutomaticGraphicsSwitching</key>\n    <true/>\n    <key>LSRequiresCarbon</key>\n    <true/>\n    <key>LSUIElement</key>\n    <string>1</string>\n</dict>\n</plist>\nEOF\n\n#sips -z 128 128 \"$ICON_SOURCE\" --out \"${ICONSET_PATH}/icon_128x128.png\" > /dev/null 2>&1\n#\n## Create icns file\n#iconutil -c icns \"$ICONSET_PATH\" -o \"$ICON_OUTPUT\" 2>/dev/null || {\n#    echo \"Warning: iconutil failed\"\n#}\n\ncp $ICON_SOURCE \"${APP_RESOURCES}/icon.icns\"\n\necho \"\"\necho \"==========================================\"\necho \"Successfully created: ${APP_PATH}\"\necho \"==========================================\"\necho \"\"\necho \"To launch PicoClaw:\"\necho \"  1. Double-click ${APP_NAME}.app in Finder\"\necho \"  2. Or use: open ${APP_PATH}\"\necho \"\"\necho \"Note: The app will run in the menu bar (systray) without a terminal window.\"\necho \"\"\n"
  },
  {
    "path": "scripts/setup.iss",
    "content": "; Script generated by the Inno Setup Script Wizard.\n; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!\n\n#define MyAppName \"PicoClaw Launcher\"\n#define MyAppVersion \"1.0\"\n#define MyAppPublisher \"PicoClaw\"\n#define MyAppURL \"https://github.com/sipeed/picoclaw\"\n#define MyAppExeName \"picoclaw-launcher.exe\"\n\n[Setup]\n; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.\n; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)\nAppId={{C8A1B4E7-D5F9-4C2A-8A6E-5F4D3C2A1B0E}\nAppName={#MyAppName}\nAppVersion={#MyAppVersion}\n;AppVerName={#MyAppName} {#MyAppVersion}\nAppPublisher={#MyAppPublisher}\nAppPublisherURL={#MyAppURL}\nAppSupportURL={#MyAppURL}\nAppUpdatesURL={#MyAppURL}\nDefaultDirName={autopf}\\PicoClaw\nDefaultGroupName={#MyAppName}\n; \"ArchitecturesAllowed=x64compatible\" specifies that Setup cannot run\n; on anything but x64 and Windows 11 on Arm.\nArchitecturesAllowed=x64compatible\n; \"ArchitecturesInstallIn64BitMode=x64compatible\" requests that the\n; install be done in \"64-bit mode\" on x64 or Windows 11 on Arm,\n; meaning it should use the native 64-bit Program Files directory and\n; the 64-bit view of the registry.\nArchitecturesInstallIn64BitMode=x64compatible\nDisableProgramGroupPage=yes\n; Remove the following line to run in administrative install mode (install for all users.)\nPrivilegesRequired=lowest\nOutputDir=build\nOutputBaseFilename=PicoClawSetup\nCompression=lzma\nSolidCompression=yes\nWizardStyle=modern\n; SourceDir=windows\nSetupIconFile=icon.ico\n\n[Languages]\nName: \"english\"; MessagesFile: \"compiler:Default.isl\"\n\n[Tasks]\nName: \"desktopicon\"; Description: \"{cm:CreateDesktopIcon}\"; GroupDescription: \"{cm:AdditionalIcons}\"; Flags: unchecked\n\n[Dirs]\n\n[Files]\nSource: \"..\\web\\build\\picoclaw-launcher.exe\"; DestDir: \"{app}\"; DestName: \"{#MyAppExeName}\"; Flags: ignoreversion\nSource: \"..\\build\\picoclaw.exe\"; DestDir: \"{app}\"; Flags: ignoreversion\nSource: \"..\\web\\backend\\icon.ico\"; DestDir: \"{app}\"; Flags: ignoreversion\n; NOTE: Don't use \"Flags: ignoreversion\" on any shared system files\n\n[UninstallDelete]\n\n[Icons]\nName: \"{group}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"; WorkingDir: \"{app}\"; IconFilename: \"{app}\\icon.ico\"\nName: \"{group}\\Uninstall {#MyAppName}\"; Filename: \"{uninstallexe}\"\nName: \"{autodesktop}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"; WorkingDir: \"{app}\"; Tasks: desktopicon; IconFilename: \"{app}\\icon.ico\"\n\n[Run]\nFilename:\"{app}\\{#MyAppExeName}\"; WorkingDir: \"{app}\"; Description: \"{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}\"; Flags: nowait postinstall skipifsilent\n\n"
  },
  {
    "path": "scripts/test-docker-mcp.sh",
    "content": "#!/bin/sh\n# Test script for MCP tools in Docker (full-featured image)\n\nset -e\n\nCOMPOSE_FILE=\"docker/docker-compose.full.yml\"\nSERVICE=\"picoclaw-agent\"\n\necho \"🧪 Testing MCP tools in Docker container (full-featured image)...\"\necho \"\"\n\n# Build the image\necho \"📦 Building Docker image...\"\ndocker compose -f \"$COMPOSE_FILE\" build \"$SERVICE\"\n\n# Test npx\necho \"✅ Testing npx...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'npx --version'\n\n# Test npm\necho \"✅ Testing npm...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'npm --version'\n\n# Test node\necho \"✅ Testing Node.js...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'node --version'\n\n# Test git\necho \"✅ Testing git...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'git --version'\n\n# Test python\necho \"✅ Testing Python...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'python3 --version'\n\n# Test uv\necho \"✅ Testing uv...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c 'uv --version'\n\n# Test MCP server installation (quick)\necho \"✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx...\"\ndocker compose -f \"$COMPOSE_FILE\" run --rm --entrypoint sh \"$SERVICE\" -c '</dev/null timeout 5 npx -y @modelcontextprotocol/server-filesystem /tmp || true'\n\necho \"\"\necho \"🎉 All MCP tools are working correctly!\"\necho \"\"\necho \"Next steps:\"\necho \"  1. Configure MCP servers in config/config.json\"\necho \"  2. Run: docker compose -f $COMPOSE_FILE --profile gateway up\"\n"
  },
  {
    "path": "scripts/test-irc.sh",
    "content": "#!/bin/sh\n# Starts a local Ergo IRC server for testing the IRC channel.\n#\n# Requirements: docker\n# Usage: ./scripts/test-irc.sh\n\nset -e\n\nCONTAINER_NAME=\"picoclaw-test-ergo\"\nIRC_PORT=6667\n\n# Clean up any previous instance\ndocker rm -f \"$CONTAINER_NAME\" >/dev/null 2>&1 || true\n\necho \"Starting Ergo IRC server on port $IRC_PORT...\"\ndocker run -d \\\n    --name \"$CONTAINER_NAME\" \\\n    -p \"$IRC_PORT:6667\" \\\n    ghcr.io/ergochat/ergo:stable\n\nfor i in $(seq 1 10); do\n    if nc -z localhost \"$IRC_PORT\" 2>/dev/null; then\n        break\n    fi\n    if [ \"$i\" -eq 10 ]; then\n        echo \"ERROR: Server did not start within 10s\"\n        exit 1\n    fi\n    sleep 1\ndone\n\necho \"\"\necho \"IRC server ready on localhost:$IRC_PORT\"\necho \"\"\necho \"Add this to your ~/.picoclaw/config.json under \\\"channels\\\":\"\necho \"\"\necho '  \"irc\": {'\necho '    \"enabled\": true,'\necho '    \"server\": \"localhost:6667\",'\necho '    \"tls\": false,'\necho '    \"nick\": \"picobot\",'\necho '    \"channels\": [\"#test\"],'\necho '    \"allow_from\": [],'\necho '    \"group_trigger\": { \"mention_only\": true }'\necho '  }'\necho \"\"\necho \"Then run picoclaw:\"\necho \"  cd packages/picoclaw && go run ./cmd/picoclaw gateway\"\necho \"\"\necho \"Connect with an IRC client:\"\necho \"  irssi:   /connect localhost $IRC_PORT\"\necho \"  weechat: /server add test localhost/$IRC_PORT && /connect test\"\necho \"  Join #test, then: picobot: hello\"\necho \"\"\necho \"To stop the IRC server:\"\necho \"  docker rm -f $CONTAINER_NAME\"\n"
  },
  {
    "path": "web/Makefile",
    "content": ".PHONY: dev dev-frontend dev-backend build test lint clean\n\n# Go variables\nGO?=CGO_ENABLED=0 go\nWEB_GO?=$(GO)\nGOFLAGS?=-v -tags stdjson\n\n# Build variables\nBUILD_DIR=build\n\n# Version\nVERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo \"dev\")\nGIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo \"dev\")\nBUILD_TIME=$(shell date +%FT%T%z)\nGO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')\nCONFIG_PKG=github.com/sipeed/picoclaw/pkg/config\nLDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w\n\n\n# OS detection\nUNAME_S:=$(shell uname -s)\nUNAME_M:=$(shell uname -m)\n\n# Platform-specific settings\nifeq ($(UNAME_S),Linux)\n\tPLATFORM=linux\n\tifeq ($(UNAME_M),x86_64)\n\t\tARCH=amd64\n\telse ifeq ($(UNAME_M),aarch64)\n\t\tARCH=arm64\n\telse ifeq ($(UNAME_M),armv81)\n\t\tARCH=arm64\n\telse ifeq ($(UNAME_M),loongarch64)\n\t\tARCH=loong64\n\telse ifeq ($(UNAME_M),riscv64)\n\t\tARCH=riscv64\n\telse ifeq ($(UNAME_M),mipsel)\n\t\tARCH=mipsle\n\telse\n\t\tARCH=$(UNAME_M)\n\tendif\nelse ifeq ($(UNAME_S),Darwin)\n\tPLATFORM=darwin\n\tWEB_GO=CGO_ENABLED=1 go\n\tifeq ($(UNAME_M),x86_64)\n\t\tARCH=amd64\n\telse ifeq ($(UNAME_M),arm64)\n\t\tARCH=arm64\n\telse\n\t\tARCH=$(UNAME_M)\n\tendif\nelse ifeq ($(UNAME_S),Windows)\n\tPLATFORM=windows\n\tARCH=$(UNAME_M)\n\tLDFLAGS=-H=windowsgui $(LDFLAGS)\nelse\n\tPLATFORM=$(UNAME_S)\n\tARCH=$(UNAME_M)\nendif\n\n# Run both frontend and backend dev servers\ndev:\n\t@if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \\\n\t\techo \"Build artifacts not found, building...\"; \\\n\t\t$(MAKE) build; \\\n\tfi\n\t@echo \"Starting backend and frontend dev servers...\"\n\t@$(MAKE) dev-backend & $(MAKE) dev-frontend\n\n# Start frontend dev server (Vite, with proxy to backend)\ndev-frontend:\n\tcd frontend && pnpm dev\n\n# Start backend dev server\ndev-backend:\n\tcd backend && ${WEB_GO} run -ldflags \"$(LDFLAGS)\" .\n\n# Build frontend and embed into Go binary\nbuild:\n\tcd frontend && pnpm build:backend\n\t${WEB_GO} build $(GOFLAGS) -ldflags \"$(LDFLAGS)\" -o $(BUILD_DIR)/picoclaw-launcher ./backend/\n\n# Run all tests\ntest:\n\tcd backend && ${WEB_GO} test ./...\n\tcd frontend && pnpm lint\n\n# Lint and format\nlint:\n\tcd backend && ${WEB_GO} vet ./...\n\tcd frontend && pnpm check\n\n# Clean build artifacts\nclean:\n\trm -rf frontend/dist backend/dist $(BUILD_DIR)\n\tmkdir -p backend/dist && touch backend/dist/.gitkeep\n"
  },
  {
    "path": "web/README.md",
    "content": "# Picoclaw Web\n\nThis directory contains the standalone web service for `picoclaw`.\nIt provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.\n\n## Architecture\n\nThe service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.\n\n*   **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.\n*   **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.\n\n## Getting Started\n\n### Prerequisites\n\n*   Go 1.25+\n*   Node.js 20+ with pnpm\n\n### Development\n\nRun both the frontend dev server and the Go backend simultaneously:\n\n```bash\nmake dev\n```\n\nOr run them separately:\n\n```bash\nmake dev-frontend   # Vite dev server\nmake dev-backend    # Go backend\n```\n\n### Build\n\nBuild the frontend and embed it into a single Go binary:\n\n```bash\nmake build\n```\n\nThe output binary is `backend/picoclaw-web`.\n\n### Other Commands\n\n```bash\nmake test    # Run backend tests and frontend lint\nmake lint    # Run go vet and prettier/eslint\nmake clean   # Remove all build artifacts\n```\n"
  },
  {
    "path": "web/backend/.gitignore",
    "content": "# Go build output\n*.exe\n*.dll\n*.so\n*.dylib\n*.test\n*.out\npicoclaw-web\n\n# Frontend build artifacts (embedded by Go)\ndist/*\n!dist/.gitkeep\n\n# OS\n.DS_Store\n\n# Editors\n.vscode/\n.idea/"
  },
  {
    "path": "web/backend/api/channels.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n)\n\ntype channelCatalogItem struct {\n\tName      string `json:\"name\"`\n\tConfigKey string `json:\"config_key\"`\n\tVariant   string `json:\"variant,omitempty\"`\n}\n\nvar channelCatalog = []channelCatalogItem{\n\t{Name: \"telegram\", ConfigKey: \"telegram\"},\n\t{Name: \"discord\", ConfigKey: \"discord\"},\n\t{Name: \"slack\", ConfigKey: \"slack\"},\n\t{Name: \"feishu\", ConfigKey: \"feishu\"},\n\t{Name: \"dingtalk\", ConfigKey: \"dingtalk\"},\n\t{Name: \"line\", ConfigKey: \"line\"},\n\t{Name: \"qq\", ConfigKey: \"qq\"},\n\t{Name: \"onebot\", ConfigKey: \"onebot\"},\n\t{Name: \"wecom\", ConfigKey: \"wecom\"},\n\t{Name: \"wecom_app\", ConfigKey: \"wecom_app\"},\n\t{Name: \"wecom_aibot\", ConfigKey: \"wecom_aibot\"},\n\t{Name: \"whatsapp\", ConfigKey: \"whatsapp\", Variant: \"bridge\"},\n\t{Name: \"whatsapp_native\", ConfigKey: \"whatsapp\", Variant: \"native\"},\n\t{Name: \"pico\", ConfigKey: \"pico\"},\n\t{Name: \"maixcam\", ConfigKey: \"maixcam\"},\n\t{Name: \"matrix\", ConfigKey: \"matrix\"},\n\t{Name: \"irc\", ConfigKey: \"irc\"},\n}\n\n// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.\nfunc (h *Handler) registerChannelRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/channels/catalog\", h.handleListChannelCatalog)\n}\n\n// handleListChannelCatalog returns the channels supported by backend.\n//\n//\tGET /api/channels/catalog\nfunc (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"channels\": channelCatalog,\n\t})\n}\n"
  },
  {
    "path": "web/backend/api/config.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// registerConfigRoutes binds configuration management endpoints to the ServeMux.\nfunc (h *Handler) registerConfigRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/config\", h.handleGetConfig)\n\tmux.HandleFunc(\"PUT /api/config\", h.handleUpdateConfig)\n\tmux.HandleFunc(\"PATCH /api/config\", h.handlePatchConfig)\n}\n\n// handleGetConfig returns the complete system configuration.\n//\n//\tGET /api/config\nfunc (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(cfg); err != nil {\n\t\thttp.Error(w, \"Failed to encode response\", http.StatusInternalServerError)\n\t}\n}\n\n// handleUpdateConfig updates the complete system configuration.\n//\n//\tPUT /api/config\nfunc (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar cfg config.Config\n\tif err := json.Unmarshal(body, &cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tif execAllowRemoteOmitted(body) {\n\t\tcfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote\n\t}\n\n\tif errs := validateConfig(&cfg); len(errs) > 0 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\": \"validation_error\",\n\t\t\t\"errors\": errs,\n\t\t})\n\t\treturn\n\t}\n\n\tif err := config.SaveConfig(h.configPath, &cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\nfunc execAllowRemoteOmitted(body []byte) bool {\n\tvar raw struct {\n\t\tTools *struct {\n\t\t\tExec *struct {\n\t\t\t\tAllowRemote *bool `json:\"allow_remote\"`\n\t\t\t} `json:\"exec\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &raw); err != nil {\n\t\treturn false\n\t}\n\treturn raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil\n}\n\n// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).\n// Only the fields present in the request body will be updated; all other fields remain unchanged.\n//\n//\tPATCH /api/config\nfunc (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {\n\tpatchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\t// Validate the patch is valid JSON\n\tvar patch map[string]any\n\tif err = json.Unmarshal(patchBody, &patch); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Load existing config and marshal to a map for merging\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\texisting, err := json.Marshal(cfg)\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to serialize current config\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tvar base map[string]any\n\tif err = json.Unmarshal(existing, &base); err != nil {\n\t\thttp.Error(w, \"Failed to parse current config\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Recursively merge patch into base\n\tmergeMap(base, patch)\n\n\t// Convert merged map back to Config struct\n\tmerged, err := json.Marshal(base)\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to serialize merged config\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tvar newCfg config.Config\n\tif err := json.Unmarshal(merged, &newCfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Merged config is invalid: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif errs := validateConfig(&newCfg); len(errs) > 0 {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\": \"validation_error\",\n\t\t\t\"errors\": errs,\n\t\t})\n\t\treturn\n\t}\n\n\tif err := config.SaveConfig(h.configPath, &newCfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\n// validateConfig checks the config for common errors before saving.\n// Returns a list of human-readable error strings; empty means valid.\nfunc validateConfig(cfg *config.Config) []string {\n\tvar errs []string\n\n\t// Validate model_list entries\n\tif err := cfg.ValidateModelList(); err != nil {\n\t\terrs = append(errs, err.Error())\n\t}\n\n\t// Gateway port range\n\tif cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {\n\t\terrs = append(errs, fmt.Sprintf(\"gateway.port %d is out of valid range (1-65535)\", cfg.Gateway.Port))\n\t}\n\n\t// Pico channel: token required when enabled\n\tif cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == \"\" {\n\t\terrs = append(errs, \"channels.pico.token is required when pico channel is enabled\")\n\t}\n\n\t// Telegram: token required when enabled\n\tif cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == \"\" {\n\t\terrs = append(errs, \"channels.telegram.token is required when telegram channel is enabled\")\n\t}\n\n\t// Discord: token required when enabled\n\tif cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == \"\" {\n\t\terrs = append(errs, \"channels.discord.token is required when discord channel is enabled\")\n\t}\n\n\tif cfg.Tools.Exec.Enabled {\n\t\tif cfg.Tools.Exec.EnableDenyPatterns {\n\t\t\terrs = append(\n\t\t\t\terrs,\n\t\t\t\tvalidateRegexPatterns(\"tools.exec.custom_deny_patterns\", cfg.Tools.Exec.CustomDenyPatterns)...)\n\t\t}\n\t\terrs = append(\n\t\t\terrs,\n\t\t\tvalidateRegexPatterns(\"tools.exec.custom_allow_patterns\", cfg.Tools.Exec.CustomAllowPatterns)...)\n\t}\n\n\treturn errs\n}\n\nfunc validateRegexPatterns(field string, patterns []string) []string {\n\tvar errs []string\n\tfor index, pattern := range patterns {\n\t\tif _, err := regexp.Compile(pattern); err != nil {\n\t\t\terrs = append(errs, fmt.Sprintf(\"%s[%d] is not a valid regular expression: %v\", field, index, err))\n\t\t}\n\t}\n\treturn errs\n}\n\n// mergeMap recursively merges src into dst (JSON Merge Patch semantics).\n// - If a key in src has a null value, it is deleted from dst.\n// - If both dst and src have a nested object for the same key, merge recursively.\n// - Otherwise the value from src overwrites dst.\nfunc mergeMap(dst, src map[string]any) {\n\tfor key, srcVal := range src {\n\t\tif srcVal == nil {\n\t\t\tdelete(dst, key)\n\t\t\tcontinue\n\t\t}\n\t\tsrcMap, srcIsMap := srcVal.(map[string]any)\n\t\tdstMap, dstIsMap := dst[key].(map[string]any)\n\t\tif srcIsMap && dstIsMap {\n\t\t\tmergeMap(dstMap, srcMap)\n\t\t} else {\n\t\t\tdst[key] = srcVal\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/config_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/config\", bytes.NewBufferString(`{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\"\n\t\t\t}\n\t\t},\n\t\t\"model_list\": [\n\t\t\t{\n\t\t\t\t\"model_name\": \"custom-default\",\n\t\t\t\t\"model\": \"openai/gpt-4o\",\n\t\t\t\t\"api_key\": \"sk-default\"\n\t\t\t}\n\t\t]\n\t}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tif !cfg.Tools.Exec.AllowRemote {\n\t\tt.Fatal(\"tools.exec.allow_remote should remain true when omitted from PUT /api/config\")\n\t}\n}\n\nfunc TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/config\", bytes.NewBufferString(`{\n\t\t\"agents\": {\n\t\t\t\"defaults\": {\n\t\t\t\t\"workspace\": \"~/.picoclaw/workspace\"\n\t\t\t}\n\t\t},\n\t\t\"model_list\": [\n\t\t\t{\n\t\t\t\t\"model_name\": \"custom-default\",\n\t\t\t\t\"model\": \"openai/gpt-4o\",\n\t\t\t\t\"api_key\": \"sk-default\"\n\t\t\t}\n\t\t]\n\t}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tif got := cfg.ModelList[0].APIBase; got != \"\" {\n\t\tt.Fatalf(\"model_list[0].api_base = %q, want empty string\", got)\n\t}\n}\n\nfunc TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/config\", bytes.NewBufferString(`{\n\t\t\"tools\": {\n\t\t\t\"exec\": {\n\t\t\t\t\"custom_deny_patterns\": [\"(\"]\n\t\t\t}\n\t\t}\n\t}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusBadRequest, rec.Body.String())\n\t}\n\tif !bytes.Contains(rec.Body.Bytes(), []byte(\"custom_deny_patterns\")) {\n\t\tt.Fatalf(\"expected validation error mentioning custom_deny_patterns, body=%s\", rec.Body.String())\n\t}\n}\n\nfunc TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/config\", bytes.NewBufferString(`{\n\t\t\"tools\": {\n\t\t\t\"exec\": {\n\t\t\t\t\"enabled\": false,\n\t\t\t\t\"custom_deny_patterns\": [\"(\"],\n\t\t\t\t\"custom_allow_patterns\": [\"(\"]\n\t\t\t}\n\t\t}\n\t}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n}\n\nfunc TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/config\", bytes.NewBufferString(`{\n\t\t\"tools\": {\n\t\t\t\"exec\": {\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"enable_deny_patterns\": false,\n\t\t\t\t\"custom_deny_patterns\": [\"(\"]\n\t\t\t}\n\t\t}\n\t}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/gateway.go",
    "content": "package api\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/health\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/web/backend/utils\"\n)\n\n// gateway holds the state for the managed gateway process.\nvar gateway = struct {\n\tmu               sync.Mutex\n\tcmd              *exec.Cmd\n\towned            bool // true if we started the process, false if we attached to an existing one\n\tbootDefaultModel string\n\truntimeStatus    string\n\tstartupDeadline  time.Time\n\tlogs             *LogBuffer\n}{\n\truntimeStatus: \"stopped\",\n\tlogs:          NewLogBuffer(200),\n}\n\nvar (\n\tgatewayStartupWindow          = 15 * time.Second\n\tgatewayRestartGracePeriod     = 5 * time.Second\n\tgatewayRestartForceKillWindow = 3 * time.Second\n\tgatewayRestartPollInterval    = 100 * time.Millisecond\n)\n\nvar gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {\n\tclient := http.Client{Timeout: timeout}\n\treturn client.Get(url)\n}\n\n// getGatewayHealth checks the gateway health endpoint and returns the status response\n// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.\nfunc (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {\n\tport := 18790\n\tif cfg != nil && cfg.Gateway.Port != 0 {\n\t\tport = cfg.Gateway.Port\n\t}\n\n\tprobeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))\n\turl := \"http://\" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + \"/health\"\n\n\treturn getGatewayHealthByURL(url, timeout)\n}\n\nfunc getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusResponse, int, error) {\n\tresp, err := gatewayHealthGet(url, timeout)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar healthResponse health.StatusResponse\n\tif decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil {\n\t\treturn nil, resp.StatusCode, decErr\n\t}\n\n\treturn &healthResponse, resp.StatusCode, nil\n}\n\n// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.\nfunc (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/gateway/status\", h.handleGatewayStatus)\n\tmux.HandleFunc(\"GET /api/gateway/logs\", h.handleGatewayLogs)\n\tmux.HandleFunc(\"POST /api/gateway/logs/clear\", h.handleGatewayClearLogs)\n\tmux.HandleFunc(\"POST /api/gateway/start\", h.handleGatewayStart)\n\tmux.HandleFunc(\"POST /api/gateway/stop\", h.handleGatewayStop)\n\tmux.HandleFunc(\"POST /api/gateway/restart\", h.handleGatewayRestart)\n}\n\n// TryAutoStartGateway checks whether gateway start preconditions are met and\n// starts it when possible. Intended to be called by the backend at startup.\nfunc (h *Handler) TryAutoStartGateway() {\n\t// Check if gateway is already running via health endpoint\n\tcfg, cfgErr := config.LoadConfig(h.configPath)\n\tif cfgErr == nil && cfg != nil {\n\t\thealthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)\n\t\tif err == nil && statusCode == http.StatusOK {\n\t\t\t// Gateway is already running, attach to the existing process\n\t\t\tpid := healthResp.Pid\n\t\t\tgateway.mu.Lock()\n\t\t\tdefer gateway.mu.Unlock()\n\t\t\tready, reason, err := h.gatewayStartReady()\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Skip auto-starting gateway: %v\", err))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !ready {\n\t\t\t\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Skip auto-starting gateway: %s\", reason))\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_, err = h.startGatewayLocked(\"starting\", pid)\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Failed to attach to running gateway (PID: %d): %v\", pid, err))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\n\tgateway.mu.Lock()\n\tdefer gateway.mu.Unlock()\n\n\tif gateway.cmd != nil && gateway.cmd.Process != nil {\n\t\tgateway.cmd = nil\n\t}\n\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Skip auto-starting gateway: %v\", err))\n\t\treturn\n\t}\n\tif !ready {\n\t\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Skip auto-starting gateway: %s\", reason))\n\t\treturn\n\t}\n\n\tpid, err := h.startGatewayLocked(\"starting\", 0)\n\tif err != nil {\n\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Failed to auto-start gateway: %v\", err))\n\t\treturn\n\t}\n\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Gateway auto-started (PID: %d)\", pid))\n}\n\n// gatewayStartReady validates whether current config can start the gateway.\nfunc (h *Handler) gatewayStartReady() (bool, string, error) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\treturn false, \"\", fmt.Errorf(\"failed to load config: %w\", err)\n\t}\n\n\tmodelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())\n\tif modelName == \"\" {\n\t\treturn false, \"no default model configured\", nil\n\t}\n\n\tmodelCfg := lookupModelConfig(cfg, modelName)\n\tif modelCfg == nil {\n\t\treturn false, fmt.Sprintf(\"default model %q is invalid\", modelName), nil\n\t}\n\n\tif !hasModelConfiguration(*modelCfg) {\n\t\treturn false, fmt.Sprintf(\"default model %q has no credentials configured\", modelName), nil\n\t}\n\tif requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {\n\t\treturn false, fmt.Sprintf(\"default model %q is not reachable\", modelName), nil\n\t}\n\n\treturn true, \"\", nil\n}\n\nfunc lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {\n\tmodelCfg, err := cfg.GetModelConfig(modelName)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn modelCfg\n}\n\nfunc gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool {\n\tif gatewayStatus != \"running\" {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(configDefaultModel) == \"\" || strings.TrimSpace(bootDefaultModel) == \"\" {\n\t\treturn false\n\t}\n\treturn configDefaultModel != bootDefaultModel\n}\n\nfunc isCmdProcessAliveLocked(cmd *exec.Cmd) bool {\n\tif cmd == nil || cmd.Process == nil {\n\t\treturn false\n\t}\n\n\t// Wait() sets ProcessState when the process exits; use it when available.\n\tif cmd.ProcessState != nil && cmd.ProcessState.Exited() {\n\t\treturn false\n\t}\n\n\t// Windows does not support Signal(0) probing. If we still own cmd and it\n\t// has not reported exit, treat it as alive.\n\tif runtime.GOOS == \"windows\" {\n\t\treturn true\n\t}\n\n\treturn cmd.Process.Signal(syscall.Signal(0)) == nil\n}\n\nfunc setGatewayRuntimeStatusLocked(status string) {\n\tgateway.runtimeStatus = status\n\tif status == \"starting\" || status == \"restarting\" {\n\t\tgateway.startupDeadline = time.Now().Add(gatewayStartupWindow)\n\t\treturn\n\t}\n\tgateway.startupDeadline = time.Time{}\n}\n\n// attachToGatewayProcess attaches to an existing gateway process by PID\n// and updates the gateway state accordingly.\n// Assumes gateway.mu is held by the caller.\nfunc attachToGatewayProcessLocked(pid int, cfg *config.Config) error {\n\tprocess, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to find process for PID %d: %w\", pid, err)\n\t}\n\n\tgateway.cmd = &exec.Cmd{Process: process}\n\tgateway.owned = false // We didn't start this process\n\tsetGatewayRuntimeStatusLocked(\"running\")\n\n\t// Update bootDefaultModel from config\n\tif cfg != nil {\n\t\tdefaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())\n\t\tgateway.bootDefaultModel = defaultModelName\n\t}\n\n\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Attached to gateway process (PID: %d)\", pid))\n\treturn nil\n}\n\nfunc gatewayStatusWithoutHealthLocked() string {\n\tif gateway.runtimeStatus == \"starting\" || gateway.runtimeStatus == \"restarting\" {\n\t\tif gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {\n\t\t\treturn gateway.runtimeStatus\n\t\t}\n\t\treturn \"error\"\n\t}\n\tif gateway.runtimeStatus == \"running\" {\n\t\treturn \"running\"\n\t}\n\tif gateway.runtimeStatus == \"error\" {\n\t\treturn \"error\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {\n\tif cmd == nil || cmd.Process == nil {\n\t\treturn true\n\t}\n\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tif !isCmdProcessAliveLocked(cmd) {\n\t\t\treturn true\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn false\n\t\t}\n\t\ttime.Sleep(gatewayRestartPollInterval)\n\t}\n}\n\n// StopGateway stops the gateway process if it was started by this handler.\n// This method is called during application shutdown to ensure the gateway subprocess\n// is properly terminated. It only stops processes that were started by this handler,\n// not processes that were attached to from existing instances.\nfunc (h *Handler) StopGateway() {\n\tgateway.mu.Lock()\n\tdefer gateway.mu.Unlock()\n\n\t// Only stop if we own the process (started it ourselves)\n\tif !gateway.owned || gateway.cmd == nil || gateway.cmd.Process == nil {\n\t\treturn\n\t}\n\n\tpid, err := stopGatewayLocked()\n\tif err != nil {\n\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Failed to stop gateway (PID %d): %v\", pid, err))\n\t\treturn\n\t}\n\n\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Gateway stopped (PID: %d)\", pid))\n}\n\n// stopGatewayLocked sends a stop signal to the gateway process.\n// Assumes gateway.mu is held by the caller.\n// Returns the PID of the stopped process and any error encountered.\nfunc stopGatewayLocked() (int, error) {\n\tif gateway.cmd == nil || gateway.cmd.Process == nil {\n\t\treturn 0, nil\n\t}\n\n\tpid := gateway.cmd.Process.Pid\n\n\t// Send SIGTERM for graceful shutdown (SIGKILL on Windows)\n\tvar sigErr error\n\tif runtime.GOOS == \"windows\" {\n\t\tsigErr = gateway.cmd.Process.Kill()\n\t} else {\n\t\tsigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)\n\t}\n\n\tif sigErr != nil {\n\t\treturn pid, sigErr\n\t}\n\n\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Sent stop signal to gateway (PID: %d)\", pid))\n\tgateway.cmd = nil\n\tgateway.owned = false\n\tgateway.bootDefaultModel = \"\"\n\tsetGatewayRuntimeStatusLocked(\"stopped\")\n\n\treturn pid, nil\n}\n\nfunc stopGatewayProcessForRestart(cmd *exec.Cmd) error {\n\tif cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) {\n\t\treturn nil\n\t}\n\n\tvar stopErr error\n\tif runtime.GOOS == \"windows\" {\n\t\tstopErr = cmd.Process.Kill()\n\t} else {\n\t\tstopErr = cmd.Process.Signal(syscall.SIGTERM)\n\t}\n\tif stopErr != nil && isCmdProcessAliveLocked(cmd) {\n\t\treturn fmt.Errorf(\"failed to stop existing gateway: %w\", stopErr)\n\t}\n\n\tif waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) {\n\t\treturn nil\n\t}\n\n\tif runtime.GOOS != \"windows\" {\n\t\tkillErr := cmd.Process.Signal(syscall.SIGKILL)\n\t\tif killErr != nil && isCmdProcessAliveLocked(cmd) {\n\t\t\treturn fmt.Errorf(\"failed to force-stop existing gateway: %w\", killErr)\n\t\t}\n\t\tif waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"existing gateway did not exit before restart\")\n}\n\nfunc (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to load config: %w\", err)\n\t}\n\tdefaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())\n\n\tvar cmd *exec.Cmd\n\tvar pid int\n\n\tif existingPid > 0 {\n\t\t// Attach to existing process\n\t\tpid = existingPid\n\t\tgateway.cmd = nil // Clear first to ensure clean state\n\t\tif err = attachToGatewayProcessLocked(pid, cfg); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\n\t\treturn pid, nil\n\t}\n\n\t// Start new process\n\t// Locate the picoclaw executable\n\texecPath := utils.FindPicoclawBinary()\n\n\tcmd = exec.Command(execPath, \"gateway\", \"-E\")\n\tcmd.Env = os.Environ()\n\t// Forward the launcher's config path via the environment variable that\n\t// GetConfigPath() already reads, so the gateway sub-process uses the same\n\t// config file without requiring a --config flag on the gateway subcommand.\n\tif h.configPath != \"\" {\n\t\tcmd.Env = append(cmd.Env, config.EnvConfig+\"=\"+h.configPath)\n\t}\n\tif host := h.gatewayHostOverride(); host != \"\" {\n\t\tcmd.Env = append(cmd.Env, config.EnvGatewayHost+\"=\"+host)\n\t}\n\n\tstdoutPipe, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to create stdout pipe: %w\", err)\n\t}\n\n\tstderrPipe, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to create stderr pipe: %w\", err)\n\t}\n\n\t// Clear old logs for this new run\n\tgateway.logs.Reset()\n\n\t// Ensure Pico Channel is configured before starting gateway\n\tif _, err := h.ensurePicoChannel(\"\"); err != nil {\n\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Warning: failed to ensure pico channel: %v\", err))\n\t\t// Non-fatal: gateway can still start without pico channel\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to start gateway: %w\", err)\n\t}\n\n\tgateway.cmd = cmd\n\tgateway.owned = true // We started this process\n\tgateway.bootDefaultModel = defaultModelName\n\tsetGatewayRuntimeStatusLocked(initialStatus)\n\tpid = cmd.Process.Pid\n\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Started picoclaw gateway (PID: %d) from %s\", pid, execPath))\n\n\t// Capture stdout/stderr in background\n\tgo scanPipe(stdoutPipe, gateway.logs)\n\tgo scanPipe(stderrPipe, gateway.logs)\n\n\t// Wait for exit in background and clean up\n\tgo func() {\n\t\tif err := cmd.Wait(); err != nil {\n\t\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Gateway process exited: %v\", err))\n\t\t} else {\n\t\t\tlogger.InfoC(\"gateway\", \"Gateway process exited normally\")\n\t\t}\n\n\t\tgateway.mu.Lock()\n\t\tif gateway.cmd == cmd {\n\t\t\tgateway.cmd = nil\n\t\t\tgateway.bootDefaultModel = \"\"\n\t\t\tif gateway.runtimeStatus != \"restarting\" {\n\t\t\t\tsetGatewayRuntimeStatusLocked(\"stopped\")\n\t\t\t}\n\t\t}\n\t\tgateway.mu.Unlock()\n\t}()\n\n\t// Start a goroutine to probe health and update the runtime state once ready.\n\tgo func() {\n\t\tfor i := 0; i < 30; i++ { // try for up to 15 seconds\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tgateway.mu.Lock()\n\t\t\tstillOurs := gateway.cmd == cmd\n\t\t\tgateway.mu.Unlock()\n\t\t\tif !stillOurs {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcfg, err := config.LoadConfig(h.configPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thealthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)\n\t\t\tif err == nil && statusCode == http.StatusOK && healthResp.Pid == pid {\n\t\t\t\t// Verify the health endpoint returns the expected pid\n\t\t\t\tgateway.mu.Lock()\n\t\t\t\tif gateway.cmd == cmd {\n\t\t\t\t\tsetGatewayRuntimeStatusLocked(\"running\")\n\t\t\t\t}\n\t\t\t\tgateway.mu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn pid, nil\n}\n\n// handleGatewayStart starts the picoclaw gateway subprocess.\n//\n//\tPOST /api/gateway/start\nfunc (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {\n\t// Prevent duplicate starts by checking health endpoint\n\tcfg, cfgErr := config.LoadConfig(h.configPath)\n\tif cfgErr == nil && cfg != nil {\n\t\thealthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)\n\t\tif err == nil && statusCode == http.StatusOK {\n\t\t\t// Gateway is already running, attach to the existing process\n\t\t\tpid := healthResp.Pid\n\t\t\tgateway.mu.Lock()\n\t\t\tready, reason, err := h.gatewayStartReady()\n\t\t\tif err != nil {\n\t\t\t\tgateway.mu.Unlock()\n\t\t\t\thttp.Error(\n\t\t\t\t\tw,\n\t\t\t\t\tfmt.Sprintf(\"Failed to validate gateway start conditions: %v\", err),\n\t\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !ready {\n\t\t\t\tgateway.mu.Unlock()\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\"status\":  \"precondition_failed\",\n\t\t\t\t\t\"message\": reason,\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_, err = h.startGatewayLocked(\"starting\", pid)\n\t\t\tgateway.mu.Unlock()\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Failed to attach to running gateway (PID: %d): %v\", pid, err))\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"Failed to attach to gateway: %v\", err), http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\"status\": \"ok\",\n\t\t\t\t\"pid\":    pid,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tgateway.mu.Lock()\n\tdefer gateway.mu.Unlock()\n\n\tif gateway.cmd != nil && gateway.cmd.Process != nil {\n\t\tgateway.cmd = nil\n\t\tsetGatewayRuntimeStatusLocked(\"stopped\")\n\t}\n\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\thttp.Error(\n\t\t\tw,\n\t\t\tfmt.Sprintf(\"Failed to validate gateway start conditions: %v\", err),\n\t\t\thttp.StatusInternalServerError,\n\t\t)\n\t\treturn\n\t}\n\tif !ready {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\":  \"precondition_failed\",\n\t\t\t\"message\": reason,\n\t\t})\n\t\treturn\n\t}\n\n\tpid, err := h.startGatewayLocked(\"starting\", 0)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to start gateway: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\": \"ok\",\n\t\t\"pid\":    pid,\n\t})\n}\n\n// handleGatewayStop stops the running gateway subprocess gracefully.\n// Note: Unlike StopGateway (which only stops self-started processes), this API endpoint\n// stops any gateway process, including attached ones. This is intentional for user control.\n//\n//\tPOST /api/gateway/stop\nfunc (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {\n\tgateway.mu.Lock()\n\tdefer gateway.mu.Unlock()\n\n\tif gateway.cmd == nil || gateway.cmd.Process == nil {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\": \"not_running\",\n\t\t})\n\t\treturn\n\t}\n\n\tpid, err := stopGatewayLocked()\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to stop gateway (PID %d): %v\", pid, err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\": \"ok\",\n\t\t\"pid\":    pid,\n\t})\n}\n\n// RestartGateway restarts the gateway process. This is a non-blocking operation\n// that stops the current gateway (if running) and starts a new one.\n// Returns the PID of the new gateway process or an error.\nfunc (h *Handler) RestartGateway() (int, error) {\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to validate gateway start conditions: %w\", err)\n\t}\n\tif !ready {\n\t\treturn 0, &preconditionFailedError{reason: reason}\n\t}\n\n\tgateway.mu.Lock()\n\tpreviousCmd := gateway.cmd\n\tsetGatewayRuntimeStatusLocked(\"restarting\")\n\tgateway.mu.Unlock()\n\n\tif err = stopGatewayProcessForRestart(previousCmd); err != nil {\n\t\tgateway.mu.Lock()\n\t\tif gateway.cmd == previousCmd {\n\t\t\tif isCmdProcessAliveLocked(previousCmd) {\n\t\t\t\tsetGatewayRuntimeStatusLocked(\"running\")\n\t\t\t} else {\n\t\t\t\tgateway.cmd = nil\n\t\t\t\tgateway.bootDefaultModel = \"\"\n\t\t\t\tsetGatewayRuntimeStatusLocked(\"error\")\n\t\t\t}\n\t\t}\n\t\tgateway.mu.Unlock()\n\t\treturn 0, fmt.Errorf(\"failed to stop gateway: %w\", err)\n\t}\n\n\tgateway.mu.Lock()\n\tif gateway.cmd == previousCmd {\n\t\tgateway.cmd = nil\n\t\tgateway.bootDefaultModel = \"\"\n\t}\n\tpid, err := h.startGatewayLocked(\"restarting\", 0)\n\tif err != nil {\n\t\tgateway.cmd = nil\n\t\tgateway.bootDefaultModel = \"\"\n\t\tsetGatewayRuntimeStatusLocked(\"error\")\n\t}\n\tgateway.mu.Unlock()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to start gateway: %w\", err)\n\t}\n\n\treturn pid, nil\n}\n\n// preconditionFailedError is returned when gateway restart preconditions are not met\ntype preconditionFailedError struct {\n\treason string\n}\n\nfunc (e *preconditionFailedError) Error() string {\n\treturn e.reason\n}\n\n// IsBadRequest returns true if the error should result in a 400 Bad Request status\nfunc (e *preconditionFailedError) IsBadRequest() bool {\n\treturn true\n}\n\n// handleGatewayRestart stops the gateway (if running) and starts a new instance.\n//\n//\tPOST /api/gateway/restart\nfunc (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {\n\tpid, err := h.RestartGateway()\n\tif err != nil {\n\t\t// Check if it's a precondition failed error\n\t\tvar precondErr *preconditionFailedError\n\t\tif errors.As(err, &precondErr) {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\"status\":  \"precondition_failed\",\n\t\t\t\t\"message\": precondErr.reason,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to restart gateway: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\": \"ok\",\n\t\t\"pid\":    pid,\n\t})\n}\n\n// handleGatewayClearLogs clears the in-memory gateway log buffer.\n//\n//\tPOST /api/gateway/logs/clear\nfunc (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) {\n\tgateway.logs.Clear()\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\":     \"cleared\",\n\t\t\"log_total\":  0,\n\t\t\"log_run_id\": gateway.logs.RunID(),\n\t})\n}\n\n// handleGatewayStatus returns the gateway run status and health info.\n//\n//\tGET /api/gateway/status\nfunc (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {\n\tdata := h.gatewayStatusData()\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(data)\n}\n\nfunc (h *Handler) gatewayStatusData() map[string]any {\n\tdata := map[string]any{}\n\tconfigDefaultModel := \"\"\n\tcfg, cfgErr := config.LoadConfig(h.configPath)\n\tif cfgErr == nil && cfg != nil {\n\t\tconfigDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())\n\t\tif configDefaultModel != \"\" {\n\t\t\tdata[\"config_default_model\"] = configDefaultModel\n\t\t}\n\t}\n\n\t// Probe health endpoint to get pid and status\n\thealthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)\n\tif err != nil {\n\t\tgateway.mu.Lock()\n\t\tdata[\"gateway_status\"] = gatewayStatusWithoutHealthLocked()\n\t\tgateway.mu.Unlock()\n\t\tlogger.ErrorC(\"gateway\", fmt.Sprintf(\"Gateway health check failed: %v\", err))\n\t} else {\n\t\tlogger.InfoC(\"gateway\", fmt.Sprintf(\"Gateway health status: %d\", statusCode))\n\t\tif statusCode != http.StatusOK {\n\t\t\tgateway.mu.Lock()\n\t\t\tsetGatewayRuntimeStatusLocked(\"error\")\n\t\t\tgateway.mu.Unlock()\n\t\t\tdata[\"gateway_status\"] = \"error\"\n\t\t\tdata[\"status_code\"] = statusCode\n\t\t} else {\n\t\t\tgateway.mu.Lock()\n\t\t\tsetGatewayRuntimeStatusLocked(\"running\")\n\t\t\tif gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid {\n\t\t\t\toldPid := \"none\"\n\t\t\t\tif gateway.cmd != nil && gateway.cmd.Process != nil {\n\t\t\t\t\toldPid = fmt.Sprintf(\"%d\", gateway.cmd.Process.Pid)\n\t\t\t\t}\n\t\t\t\tlogger.InfoC(\n\t\t\t\t\t\"gateway\",\n\t\t\t\t\tfmt.Sprintf(\n\t\t\t\t\t\t\"Detected new gateway PID (old: %s, new: %d), attempting to attach\",\n\t\t\t\t\t\toldPid,\n\t\t\t\t\t\thealthResp.Pid,\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\tif err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {\n\t\t\t\t\t// Failed to find the process, treat as error\n\t\t\t\t\tsetGatewayRuntimeStatusLocked(\"error\")\n\t\t\t\t\tdata[\"gateway_status\"] = \"error\"\n\t\t\t\t\tdata[\"pid\"] = healthResp.Pid\n\t\t\t\t\tlogger.ErrorC(\n\t\t\t\t\t\t\"gateway\",\n\t\t\t\t\t\tfmt.Sprintf(\"Failed to attach to new gateway process (PID: %d): %v\", healthResp.Pid, err),\n\t\t\t\t\t)\n\t\t\t\t} else {\n\t\t\t\t\t// Successfully attached, update response data\n\t\t\t\t\tbootDefaultModel := gateway.bootDefaultModel\n\t\t\t\t\tif bootDefaultModel != \"\" {\n\t\t\t\t\t\tdata[\"boot_default_model\"] = bootDefaultModel\n\t\t\t\t\t}\n\t\t\t\t\tdata[\"gateway_status\"] = \"running\"\n\t\t\t\t\tdata[\"pid\"] = healthResp.Pid\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tbootDefaultModel := gateway.bootDefaultModel\n\t\t\tif bootDefaultModel != \"\" {\n\t\t\t\tdata[\"boot_default_model\"] = bootDefaultModel\n\t\t\t}\n\t\t\tdata[\"gateway_status\"] = \"running\"\n\t\t\tdata[\"pid\"] = healthResp.Pid\n\t\t\tgateway.mu.Unlock()\n\t\t}\n\t}\n\n\tbootDefaultModel, _ := data[\"boot_default_model\"].(string)\n\tgatewayStatus, _ := data[\"gateway_status\"].(string)\n\tdata[\"gateway_restart_required\"] = gatewayRestartRequired(\n\t\tconfigDefaultModel,\n\t\tbootDefaultModel,\n\t\tgatewayStatus,\n\t)\n\n\tready, reason, readyErr := h.gatewayStartReady()\n\tif readyErr != nil {\n\t\tdata[\"gateway_start_allowed\"] = false\n\t\tdata[\"gateway_start_reason\"] = readyErr.Error()\n\t} else {\n\t\tdata[\"gateway_start_allowed\"] = ready\n\t\tif !ready {\n\t\t\tdata[\"gateway_start_reason\"] = reason\n\t\t}\n\t}\n\n\treturn data\n}\n\n// handleGatewayLogs returns buffered gateway logs, optionally incrementally.\n//\n//\tGET /api/gateway/logs\nfunc (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) {\n\tdata := gatewayLogsData(r)\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(data)\n}\n\n// gatewayLogsData reads log_offset and log_run_id query params from the request\n// and returns incremental log lines.\nfunc gatewayLogsData(r *http.Request) map[string]any {\n\tdata := map[string]any{}\n\tclientOffset := 0\n\tclientRunID := -1\n\n\tif v := r.URL.Query().Get(\"log_offset\"); v != \"\" {\n\t\tif n, err := strconv.Atoi(v); err == nil {\n\t\t\tclientOffset = n\n\t\t}\n\t}\n\n\tif v := r.URL.Query().Get(\"log_run_id\"); v != \"\" {\n\t\tif n, err := strconv.Atoi(v); err == nil {\n\t\t\tclientRunID = n\n\t\t}\n\t}\n\n\trunID := gateway.logs.RunID()\n\n\tif runID == 0 {\n\t\tdata[\"logs\"] = []string{}\n\t\tdata[\"log_total\"] = 0\n\t\tdata[\"log_run_id\"] = 0\n\t\treturn data\n\t}\n\n\t// If runID changed, reset offset to get all logs from new run\n\toffset := clientOffset\n\tif clientRunID != runID {\n\t\toffset = 0\n\t}\n\n\tlines, total, runID := gateway.logs.LinesSince(offset)\n\tif lines == nil {\n\t\tlines = []string{}\n\t}\n\n\tdata[\"logs\"] = lines\n\tdata[\"log_total\"] = total\n\tdata[\"log_run_id\"] = runID\n\treturn data\n}\n\n// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.\nfunc scanPipe(r io.Reader, buf *LogBuffer) {\n\tscanner := bufio.NewScanner(r)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\tfor scanner.Scan() {\n\t\tbuf.Append(scanner.Text())\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/gateway_host.go",
    "content": "package api\n\nimport (\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc (h *Handler) effectiveLauncherPublic() bool {\n\tif h.serverPublicExplicit {\n\t\treturn h.serverPublic\n\t}\n\n\tcfg, err := h.loadLauncherConfig()\n\tif err == nil {\n\t\treturn cfg.Public\n\t}\n\n\treturn h.serverPublic\n}\n\nfunc (h *Handler) gatewayHostOverride() string {\n\tif h.effectiveLauncherPublic() {\n\t\treturn \"0.0.0.0\"\n\t}\n\treturn \"\"\n}\n\nfunc (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {\n\tif override := h.gatewayHostOverride(); override != \"\" {\n\t\treturn override\n\t}\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cfg.Gateway.Host)\n}\n\nfunc gatewayProbeHost(bindHost string) string {\n\tif bindHost == \"\" || bindHost == \"0.0.0.0\" {\n\t\treturn \"127.0.0.1\"\n\t}\n\treturn bindHost\n}\n\nfunc (h *Handler) gatewayProxyURL() *url.URL {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tport := 18790\n\tbindHost := \"\"\n\tif err == nil && cfg != nil {\n\t\tif cfg.Gateway.Port != 0 {\n\t\t\tport = cfg.Gateway.Port\n\t\t}\n\t\tbindHost = h.effectiveGatewayBindHost(cfg)\n\t}\n\n\treturn &url.URL{\n\t\tScheme: \"http\",\n\t\tHost:   net.JoinHostPort(gatewayProbeHost(bindHost), strconv.Itoa(port)),\n\t}\n}\n\nfunc requestHostName(r *http.Request) string {\n\treqHost, _, err := net.SplitHostPort(r.Host)\n\tif err == nil {\n\t\treturn reqHost\n\t}\n\tif strings.TrimSpace(r.Host) != \"\" {\n\t\treturn r.Host\n\t}\n\treturn \"127.0.0.1\"\n}\n\nfunc requestWSScheme(r *http.Request) string {\n\tif forwarded := strings.TrimSpace(r.Header.Get(\"X-Forwarded-Proto\")); forwarded != \"\" {\n\t\tproto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, \",\")[0]))\n\t\tif proto == \"https\" || proto == \"wss\" {\n\t\t\treturn \"wss\"\n\t\t}\n\t\tif proto == \"http\" || proto == \"ws\" {\n\t\t\treturn \"ws\"\n\t\t}\n\t}\n\n\tif r.TLS != nil {\n\t\treturn \"wss\"\n\t}\n\n\treturn \"ws\"\n}\n\nfunc (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {\n\thost := h.effectiveGatewayBindHost(cfg)\n\tif host == \"\" || host == \"0.0.0.0\" {\n\t\thost = requestHostName(r)\n\t}\n\t// Use web server port instead of gateway port to avoid exposing extra ports\n\t// The WebSocket connection will be proxied by the backend to the gateway\n\twsPort := h.serverPort\n\tif wsPort == 0 {\n\t\twsPort = 18800 // default web server port\n\t}\n\treturn requestWSScheme(r) + \"://\" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + \"/pico/ws\"\n}\n"
  },
  {
    "path": "web/backend/api/gateway_host_test.go",
    "content": "package api\n\nimport (\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n)\n\nfunc TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tlauncherPath := launcherconfig.PathForAppConfig(configPath)\n\tif err := launcherconfig.Save(launcherPath, launcherconfig.Config{\n\t\tPort:   18800,\n\t\tPublic: false,\n\t}); err != nil {\n\t\tt.Fatalf(\"launcherconfig.Save() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\th.SetServerOptions(18800, true, true, nil)\n\n\tif got := h.gatewayHostOverride(); got != \"0.0.0.0\" {\n\t\tt.Fatalf(\"gatewayHostOverride() = %q, want %q\", got, \"0.0.0.0\")\n\t}\n}\n\nfunc TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tlauncherPath := launcherconfig.PathForAppConfig(configPath)\n\tif err := launcherconfig.Save(launcherPath, launcherconfig.Config{\n\t\tPort:   18800,\n\t\tPublic: true,\n\t}); err != nil {\n\t\tt.Fatalf(\"launcherconfig.Save() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\th.SetServerOptions(18800, false, false, nil)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"127.0.0.1\"\n\tcfg.Gateway.Port = 18790\n\n\treq := httptest.NewRequest(\"GET\", \"http://launcher.local/api/pico/token\", nil)\n\treq.Host = \"192.168.1.9:18800\"\n\n\tif got := h.buildWsURL(req, cfg); got != \"ws://192.168.1.9:18800/pico/ws\" {\n\t\tt.Fatalf(\"buildWsURL() = %q, want %q\", got, \"ws://192.168.1.9:18800/pico/ws\")\n\t}\n}\n\nfunc TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {\n\tif got := gatewayProbeHost(\"0.0.0.0\"); got != \"127.0.0.1\" {\n\t\tt.Fatalf(\"gatewayProbeHost() = %q, want %q\", got, \"127.0.0.1\")\n\t}\n}\n\nfunc TestGatewayProxyURLUsesConfiguredHost(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"192.168.1.10\"\n\tcfg.Gateway.Port = 18791\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tif got := h.gatewayProxyURL().String(); got != \"http://192.168.1.10:18791\" {\n\t\tt.Fatalf(\"gatewayProxyURL() = %q, want %q\", got, \"http://192.168.1.10:18791\")\n\t}\n}\n\nfunc TestGetGatewayHealthUsesConfiguredHost(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"192.168.1.10\"\n\tcfg.Gateway.Port = 18791\n\n\toriginalHealthGet := gatewayHealthGet\n\tt.Cleanup(func() {\n\t\tgatewayHealthGet = originalHealthGet\n\t})\n\n\tvar requestedURL string\n\tgatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {\n\t\trequestedURL = url\n\t\treturn nil, errors.New(\"probe failed\")\n\t}\n\n\t_, statusCode, err := h.getGatewayHealth(cfg, time.Second)\n\t_ = statusCode\n\t_ = err\n\n\tif requestedURL != \"http://192.168.1.10:18791/health\" {\n\t\tt.Fatalf(\"health url = %q, want %q\", requestedURL, \"http://192.168.1.10:18791/health\")\n\t}\n}\n\nfunc TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\th.SetServerOptions(18800, true, true, nil)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"127.0.0.1\"\n\tcfg.Gateway.Port = 18791\n\n\toriginalHealthGet := gatewayHealthGet\n\tt.Cleanup(func() {\n\t\tgatewayHealthGet = originalHealthGet\n\t})\n\n\tvar requestedURL string\n\tgatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {\n\t\trequestedURL = url\n\t\treturn nil, errors.New(\"probe failed\")\n\t}\n\n\t_, statusCode, err := h.getGatewayHealth(cfg, time.Second)\n\t_ = statusCode\n\t_ = err\n\n\tif requestedURL != \"http://127.0.0.1:18791/health\" {\n\t\tt.Fatalf(\"health url = %q, want %q\", requestedURL, \"http://127.0.0.1:18791/health\")\n\t}\n}\n\nfunc TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"0.0.0.0\"\n\tcfg.Gateway.Port = 18790\n\n\treq := httptest.NewRequest(\"GET\", \"http://launcher.local/api/pico/token\", nil)\n\treq.Host = \"chat.example.com\"\n\treq.Header.Set(\"X-Forwarded-Proto\", \"https\")\n\n\tif got := h.buildWsURL(req, cfg); got != \"wss://chat.example.com:18800/pico/ws\" {\n\t\tt.Fatalf(\"buildWsURL() = %q, want %q\", got, \"wss://chat.example.com:18800/pico/ws\")\n\t}\n}\n\nfunc TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"0.0.0.0\"\n\tcfg.Gateway.Port = 18790\n\n\treq := httptest.NewRequest(\"GET\", \"https://launcher.local/api/pico/token\", nil)\n\treq.Host = \"secure.example.com\"\n\treq.TLS = &tls.ConnectionState{}\n\n\tif got := h.buildWsURL(req, cfg); got != \"wss://secure.example.com:18800/pico/ws\" {\n\t\tt.Fatalf(\"buildWsURL() = %q, want %q\", got, \"wss://secure.example.com:18800/pico/ws\")\n\t}\n}\n\nfunc TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"0.0.0.0\"\n\tcfg.Gateway.Port = 18790\n\n\treq := httptest.NewRequest(\"GET\", \"https://launcher.local/api/pico/token\", nil)\n\treq.Host = \"chat.example.com\"\n\treq.TLS = &tls.ConnectionState{}\n\treq.Header.Set(\"X-Forwarded-Proto\", \"http\")\n\n\tif got := h.buildWsURL(req, cfg); got != \"ws://chat.example.com:18800/pico/ws\" {\n\t\tt.Fatalf(\"buildWsURL() = %q, want %q\", got, \"ws://chat.example.com:18800/pico/ws\")\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/gateway_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/web/backend/utils\"\n)\n\nfunc startLongRunningProcess(t *testing.T) *exec.Cmd {\n\tt.Helper()\n\n\tvar cmd *exec.Cmd\n\tif runtime.GOOS == \"windows\" {\n\t\tcmd = exec.Command(\"powershell\", \"-NoProfile\", \"-Command\", \"Start-Sleep -Seconds 30\")\n\t} else {\n\t\tcmd = exec.Command(\"sleep\", \"30\")\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\tt.Fatalf(\"Start() error = %v\", err)\n\t}\n\n\treturn cmd\n}\n\nfunc mockGatewayHealthResponse(statusCode, pid int) *http.Response {\n\treturn &http.Response{\n\t\tStatusCode: statusCode,\n\t\tBody: io.NopCloser(strings.NewReader(\n\t\t\t`{\"status\":\"ok\",\"uptime\":\"1s\",\"pid\":` + strconv.Itoa(pid) + `}`,\n\t\t)),\n\t}\n}\n\nfunc startIgnoringTermProcess(t *testing.T) *exec.Cmd {\n\tt.Helper()\n\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"TERM handling differs on Windows\")\n\t}\n\n\tcmd := exec.Command(\"sh\", \"-c\", \"trap '' TERM; sleep 30\")\n\tif err := cmd.Start(); err != nil {\n\t\tt.Fatalf(\"Start() error = %v\", err)\n\t}\n\n\treturn cmd\n}\n\nfunc resetGatewayTestState(t *testing.T) {\n\tt.Helper()\n\n\toriginalHealthGet := gatewayHealthGet\n\toriginalRestartGracePeriod := gatewayRestartGracePeriod\n\toriginalRestartForceKillWindow := gatewayRestartForceKillWindow\n\toriginalRestartPollInterval := gatewayRestartPollInterval\n\tt.Cleanup(func() {\n\t\tgatewayHealthGet = originalHealthGet\n\t\tgatewayRestartGracePeriod = originalRestartGracePeriod\n\t\tgatewayRestartForceKillWindow = originalRestartForceKillWindow\n\t\tgatewayRestartPollInterval = originalRestartPollInterval\n\n\t\tgateway.mu.Lock()\n\t\tgateway.cmd = nil\n\t\tgateway.bootDefaultModel = \"\"\n\t\tsetGatewayRuntimeStatusLocked(\"stopped\")\n\t\tgateway.mu.Unlock()\n\t})\n}\n\nfunc TestGatewayStartReady_NoDefaultModel(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = true, want false\")\n\t}\n\tif reason != \"no default model configured\" {\n\t\tt.Fatalf(\"gatewayStartReady() reason = %q, want %q\", reason, \"no default model configured\")\n\t}\n}\n\nfunc TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.Model = \"missing-model\"\n\terr := config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = true, want false\")\n\t}\n\tif reason == \"\" {\n\t\tt.Fatalf(\"gatewayStartReady() reason is empty\")\n\t}\n}\n\nfunc TestGatewayStartReady_ValidDefaultModel(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"test-key\"\n\terr := config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif !ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = false, want true (reason=%q)\", reason)\n\t}\n}\n\nfunc TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"\"\n\tcfg.ModelList[0].AuthMethod = \"\"\n\terr := config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = true, want false\")\n\t}\n\tif !strings.Contains(reason, \"no credentials configured\") {\n\t\tt.Fatalf(\"gatewayStartReady() reason = %q, want contains %q\", reason, \"no credentials configured\")\n\t}\n}\n\nfunc TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetModelProbeHooks(t)\n\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\treturn false\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"local-vllm\",\n\t\tModel:     \"vllm/custom-model\",\n\t\tAPIBase:   \"http://localhost:8000/v1\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"local-vllm\"\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = true, want false without a running local service\")\n\t}\n\tif !strings.Contains(reason, \"not reachable\") {\n\t\tt.Fatalf(\"gatewayStartReady() reason = %q, want contains %q\", reason, \"not reachable\")\n\t}\n}\n\nfunc TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetModelProbeHooks(t)\n\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\treturn apiBase == \"http://127.0.0.1:8000/v1\" && modelID == \"custom-model\"\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"local-vllm\",\n\t\tModel:     \"vllm/custom-model\",\n\t\tAPIBase:   \"http://127.0.0.1:8000/v1\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"local-vllm\"\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif !ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = false, want true with a running local service (reason=%q)\", reason)\n\t}\n}\n\nfunc TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetModelProbeHooks(t)\n\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\tt.Fatalf(\"unexpected OpenAI-compatible probe for %q (%q)\", apiBase, modelID)\n\t\treturn false\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"remote-vllm\",\n\t\tModel:     \"vllm/custom-model\",\n\t\tAPIBase:   \"https://models.example.com/v1\",\n\t\tAPIKey:    \"remote-key\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"remote-vllm\"\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif !ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)\", reason)\n\t}\n}\n\nfunc TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetModelProbeHooks(t)\n\n\tprobeOllamaModelFunc = func(apiBase, modelID string) bool {\n\t\treturn apiBase == \"http://localhost:11434/v1\" && modelID == \"llama3\"\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"local-ollama\",\n\t\tModel:     \"ollama/llama3\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"local-ollama\"\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif !ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)\", reason)\n\t}\n}\n\nfunc TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName:  \"openai-oauth\",\n\t\tModel:      \"openai/gpt-5.4\",\n\t\tAuthMethod: \"oauth\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"openai-oauth\"\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tready, reason, err := h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = true, want false without stored credential\")\n\t}\n\tif !strings.Contains(reason, \"no credentials configured\") {\n\t\tt.Fatalf(\"gatewayStartReady() reason = %q, want contains %q\", reason, \"no credentials configured\")\n\t}\n\n\terr = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{\n\t\tAccessToken: \"openai-token\",\n\t\tProvider:    oauthProviderOpenAI,\n\t\tAuthMethod:  \"oauth\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"SetCredential() error = %v\", err)\n\t}\n\n\tready, reason, err = h.gatewayStartReady()\n\tif err != nil {\n\t\tt.Fatalf(\"gatewayStartReady() error = %v\", err)\n\t}\n\tif !ready {\n\t\tt.Fatalf(\"gatewayStartReady() ready = false, want true with stored credential (reason=%q)\", reason)\n\t}\n}\n\nfunc TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tallowed, ok := body[\"gateway_start_allowed\"].(bool)\n\tif !ok {\n\t\tt.Fatalf(\"gateway_start_allowed missing or not bool: %#v\", body[\"gateway_start_allowed\"])\n\t}\n\tif allowed {\n\t\tt.Fatalf(\"gateway_start_allowed = true, want false\")\n\t}\n\tif _, ok := body[\"gateway_start_reason\"].(string); !ok {\n\t\tt.Fatalf(\"gateway_start_reason missing or not string: %#v\", body[\"gateway_start_reason\"])\n\t}\n}\n\nfunc TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tcmd := startLongRunningProcess(t)\n\tt.Cleanup(func() {\n\t\tif cmd.Process != nil {\n\t\t\t_ = cmd.Process.Kill()\n\t\t}\n\t\t_ = cmd.Wait()\n\t})\n\n\tgateway.mu.Lock()\n\tgateway.cmd = cmd\n\tgateway.bootDefaultModel = \"existing-model\"\n\t// Simulate a process that has already reached the running state.\n\tsetGatewayRuntimeStatusLocked(\"running\")\n\tgateway.mu.Unlock()\n\n\tgatewayHealthGet = func(string, time.Duration) (*http.Response, error) {\n\t\treturn nil, errors.New(\"probe failed\")\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"running\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"running\")\n\t}\n}\n\nfunc TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tcmd := startLongRunningProcess(t)\n\tt.Cleanup(func() {\n\t\tif cmd.Process != nil {\n\t\t\t_ = cmd.Process.Kill()\n\t\t}\n\t\t_ = cmd.Wait()\n\t})\n\n\tgateway.mu.Lock()\n\tsetGatewayRuntimeStatusLocked(\"stopped\")\n\tgateway.mu.Unlock()\n\n\tgatewayHealthGet = func(string, time.Duration) (*http.Response, error) {\n\t\treturn mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"running\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"running\")\n\t}\n\tif got := body[\"pid\"]; got != float64(cmd.Process.Pid) {\n\t\tt.Fatalf(\"pid = %#v, want %d\", got, cmd.Process.Pid)\n\t}\n\tif got := body[\"gateway_restart_required\"]; got != false {\n\t\tt.Fatalf(\"gateway_restart_required = %#v, want false\", got)\n\t}\n}\n\nfunc TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"test-key\"\n\tcfg.ModelList = append(cfg.ModelList, config.ModelConfig{\n\t\tModelName: \"second-model\",\n\t\tModel:     \"openai/gpt-4.1\",\n\t\tAPIKey:    \"second-key\",\n\t})\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tprocess, err := os.FindProcess(os.Getpid())\n\tif err != nil {\n\t\tt.Fatalf(\"FindProcess() error = %v\", err)\n\t}\n\n\tgateway.mu.Lock()\n\tgateway.cmd = &exec.Cmd{Process: process}\n\tgateway.bootDefaultModel = cfg.ModelList[0].ModelName\n\tsetGatewayRuntimeStatusLocked(\"running\")\n\tgateway.mu.Unlock()\n\n\tupdatedCfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tupdatedCfg.Agents.Defaults.ModelName = \"second-model\"\n\tif err := config.SaveConfig(configPath, updatedCfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tgatewayHealthGet = func(string, time.Duration) (*http.Response, error) {\n\t\treturn mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"running\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"running\")\n\t}\n\tif got := body[\"boot_default_model\"]; got != cfg.ModelList[0].ModelName {\n\t\tt.Fatalf(\"boot_default_model = %#v, want %q\", got, cfg.ModelList[0].ModelName)\n\t}\n\tif got := body[\"config_default_model\"]; got != \"second-model\" {\n\t\tt.Fatalf(\"config_default_model = %#v, want %q\", got, \"second-model\")\n\t}\n\tif got := body[\"gateway_restart_required\"]; got != true {\n\t\tt.Fatalf(\"gateway_restart_required = %#v, want true\", got)\n\t}\n}\n\nfunc TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tcmd := startLongRunningProcess(t)\n\tt.Cleanup(func() {\n\t\tif cmd.Process != nil {\n\t\t\t_ = cmd.Process.Kill()\n\t\t}\n\t\t_ = cmd.Wait()\n\t})\n\n\tgateway.mu.Lock()\n\tgateway.cmd = cmd\n\tgateway.bootDefaultModel = \"existing-model\"\n\tsetGatewayRuntimeStatusLocked(\"starting\")\n\tgateway.startupDeadline = time.Now().Add(-time.Second)\n\tgateway.mu.Unlock()\n\n\tgatewayHealthGet = func(string, time.Duration) (*http.Response, error) {\n\t\treturn nil, errors.New(\"probe failed\")\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"error\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"error\")\n\t}\n}\n\nfunc TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tgateway.mu.Lock()\n\tsetGatewayRuntimeStatusLocked(\"restarting\")\n\tgateway.mu.Unlock()\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"restarting\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"restarting\")\n\t}\n}\n\nfunc TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"\"\n\tcfg.ModelList[0].AuthMethod = \"\"\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tcmd := startLongRunningProcess(t)\n\tt.Cleanup(func() {\n\t\tgateway.mu.Lock()\n\t\tif gateway.cmd == cmd {\n\t\t\tgateway.cmd = nil\n\t\t\tgateway.bootDefaultModel = \"\"\n\t\t}\n\t\tgateway.mu.Unlock()\n\n\t\tif cmd.Process != nil {\n\t\t\t_ = cmd.Process.Kill()\n\t\t}\n\t\t_ = cmd.Wait()\n\t})\n\n\tgateway.mu.Lock()\n\tgateway.cmd = cmd\n\tgateway.bootDefaultModel = \"existing-model\"\n\tgateway.mu.Unlock()\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodPost, \"/api/gateway/restart\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusBadRequest)\n\t}\n\n\tgateway.mu.Lock()\n\tstillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)\n\tgateway.mu.Unlock()\n\n\tif !stillRunning {\n\t\tt.Fatalf(\"gateway process was stopped when restart preconditions failed\")\n\t}\n}\n\nfunc TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"test-key\"\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tcmd := startIgnoringTermProcess(t)\n\tt.Cleanup(func() {\n\t\tgateway.mu.Lock()\n\t\tif gateway.cmd == cmd {\n\t\t\tgateway.cmd = nil\n\t\t\tgateway.bootDefaultModel = \"\"\n\t\t}\n\t\tgateway.mu.Unlock()\n\n\t\tif cmd.Process != nil {\n\t\t\t_ = cmd.Process.Kill()\n\t\t}\n\t\t_ = cmd.Wait()\n\t})\n\n\tgatewayRestartGracePeriod = 150 * time.Millisecond\n\tgatewayRestartForceKillWindow = 150 * time.Millisecond\n\tgatewayRestartPollInterval = 10 * time.Millisecond\n\n\tgateway.mu.Lock()\n\tgateway.cmd = cmd\n\tgateway.bootDefaultModel = \"existing-model\"\n\tsetGatewayRuntimeStatusLocked(\"running\")\n\tgateway.mu.Unlock()\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodPost, \"/api/gateway/restart\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusInternalServerError {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusInternalServerError)\n\t}\n\n\tgateway.mu.Lock()\n\tstillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)\n\tstatus := gateway.runtimeStatus\n\tgateway.mu.Unlock()\n\n\tif !stillRunning {\n\t\tt.Fatalf(\"gateway process was replaced before the old process exited\")\n\t}\n\tif status != \"running\" {\n\t\tt.Fatalf(\"runtimeStatus = %q, want %q\", status, \"running\")\n\t}\n}\n\nfunc TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) {\n\tresetGatewayTestState(t)\n\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tcfg := config.DefaultConfig()\n\tcfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName\n\tcfg.ModelList[0].APIKey = \"test-key\"\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tinvalidBinaryPath := filepath.Join(t.TempDir(), \"fake-picoclaw\")\n\tif err := os.WriteFile(invalidBinaryPath, []byte(\"#!/bin/sh\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\tt.Setenv(\"PICOCLAW_BINARY\", invalidBinaryPath)\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodPost, \"/api/gateway/restart\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusInternalServerError {\n\t\tt.Fatalf(\"restart status = %d, want %d\", rec.Code, http.StatusInternalServerError)\n\t}\n\n\tstatusRec := httptest.NewRecorder()\n\tstatusReq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(statusRec, statusReq)\n\n\tif statusRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", statusRec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif got := body[\"gateway_status\"]; got != \"error\" {\n\t\tt.Fatalf(\"gateway_status = %#v, want %q\", got, \"error\")\n\t}\n}\n\nfunc TestGatewayStatusExcludesLogsFields(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/gateway/status\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\n\tif _, ok := body[\"logs\"]; ok {\n\t\tt.Fatalf(\"logs unexpectedly present in status response: %#v\", body[\"logs\"])\n\t}\n\tif _, ok := body[\"log_total\"]; ok {\n\t\tt.Fatalf(\"log_total unexpectedly present in status response: %#v\", body[\"log_total\"])\n\t}\n\tif _, ok := body[\"log_run_id\"]; ok {\n\t\tt.Fatalf(\"log_run_id unexpectedly present in status response: %#v\", body[\"log_run_id\"])\n\t}\n}\n\nfunc TestGatewayLogsReturnsIncrementalHistory(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tgateway.logs.Clear()\n\tgateway.logs.Append(\"first line\")\n\tgateway.logs.Append(\"second line\")\n\trunID := gateway.logs.RunID()\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodGet,\n\t\t\"/api/gateway/logs?log_offset=1&log_run_id=\"+strconv.Itoa(runID),\n\t\tnil,\n\t)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"logs status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar body map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {\n\t\tt.Fatalf(\"unmarshal logs response: %v\", err)\n\t}\n\n\tlogs, ok := body[\"logs\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"logs missing or not array: %#v\", body[\"logs\"])\n\t}\n\tif len(logs) != 1 || logs[0] != \"second line\" {\n\t\tt.Fatalf(\"logs = %#v, want [\\\"second line\\\"]\", logs)\n\t}\n\tif got := body[\"log_total\"]; got != float64(2) {\n\t\tt.Fatalf(\"log_total = %#v, want 2\", got)\n\t}\n\tif got := body[\"log_run_id\"]; got != float64(runID) {\n\t\tt.Fatalf(\"log_run_id = %#v, want %d\", got, runID)\n\t}\n}\n\nfunc TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tgateway.logs.Clear()\n\tgateway.logs.Append(\"first line\")\n\tgateway.logs.Append(\"second line\")\n\tpreviousRunID := gateway.logs.RunID()\n\n\tclearRec := httptest.NewRecorder()\n\tclearReq := httptest.NewRequest(http.MethodPost, \"/api/gateway/logs/clear\", nil)\n\tmux.ServeHTTP(clearRec, clearReq)\n\n\tif clearRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"clear status = %d, want %d\", clearRec.Code, http.StatusOK)\n\t}\n\n\tvar clearBody map[string]any\n\tif err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil {\n\t\tt.Fatalf(\"unmarshal clear response: %v\", err)\n\t}\n\n\tif got := clearBody[\"status\"]; got != \"cleared\" {\n\t\tt.Fatalf(\"clear status body = %#v, want %q\", got, \"cleared\")\n\t}\n\n\tclearRunID, ok := clearBody[\"log_run_id\"].(float64)\n\tif !ok {\n\t\tt.Fatalf(\"log_run_id missing or not number: %#v\", clearBody[\"log_run_id\"])\n\t}\n\tif int(clearRunID) <= previousRunID {\n\t\tt.Fatalf(\"log_run_id = %d, want > %d\", int(clearRunID), previousRunID)\n\t}\n\n\tlogsRec := httptest.NewRecorder()\n\tlogsReq := httptest.NewRequest(\n\t\thttp.MethodGet,\n\t\t\"/api/gateway/logs?log_offset=0&log_run_id=\"+strconv.Itoa(previousRunID),\n\t\tnil,\n\t)\n\tmux.ServeHTTP(logsRec, logsReq)\n\n\tif logsRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"logs code = %d, want %d\", logsRec.Code, http.StatusOK)\n\t}\n\n\tvar logsBody map[string]any\n\tif err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {\n\t\tt.Fatalf(\"unmarshal logs response: %v\", err)\n\t}\n\n\tlogs, ok := logsBody[\"logs\"].([]any)\n\tif !ok {\n\t\tt.Fatalf(\"logs missing or not array: %#v\", logsBody[\"logs\"])\n\t}\n\tif len(logs) != 0 {\n\t\tt.Fatalf(\"logs len = %d, want 0\", len(logs))\n\t}\n\tif got := logsBody[\"log_total\"]; got != float64(0) {\n\t\tt.Fatalf(\"log_total = %#v, want 0\", got)\n\t}\n\tif got := logsBody[\"log_run_id\"]; got != clearBody[\"log_run_id\"] {\n\t\tt.Fatalf(\"log_run_id = %#v, want %#v\", got, clearBody[\"log_run_id\"])\n\t}\n}\n\nfunc TestFindPicoclawBinary_EnvOverride(t *testing.T) {\n\t// Create a temporary file to act as the mock binary\n\ttmpDir := t.TempDir()\n\tmockBinary := filepath.Join(tmpDir, \"picoclaw-mock\")\n\tif err := os.WriteFile(mockBinary, []byte(\"mock\"), 0o755); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\n\tt.Setenv(\"PICOCLAW_BINARY\", mockBinary)\n\n\tgot := utils.FindPicoclawBinary()\n\tif got != mockBinary {\n\t\tt.Errorf(\"FindPicoclawBinary() = %q, want %q\", got, mockBinary)\n\t}\n}\n\nfunc TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {\n\t// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy\n\tt.Setenv(\"PICOCLAW_BINARY\", \"/nonexistent/picoclaw-binary\")\n\n\tgot := utils.FindPicoclawBinary()\n\t// Should not return the invalid path; falls back to \"picoclaw\" or another found path\n\tif got == \"/nonexistent/picoclaw-binary\" {\n\t\tt.Errorf(\"FindPicoclawBinary() returned invalid env path %q, expected fallback\", got)\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/launcher_config.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n)\n\ntype launcherConfigPayload struct {\n\tPort         int      `json:\"port\"`\n\tPublic       bool     `json:\"public\"`\n\tAllowedCIDRs []string `json:\"allowed_cidrs\"`\n}\n\nfunc (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/system/launcher-config\", h.handleGetLauncherConfig)\n\tmux.HandleFunc(\"PUT /api/system/launcher-config\", h.handleUpdateLauncherConfig)\n}\n\nfunc (h *Handler) launcherConfigPath() string {\n\treturn launcherconfig.PathForAppConfig(h.configPath)\n}\n\nfunc (h *Handler) launcherFallbackConfig() launcherconfig.Config {\n\tport := h.serverPort\n\tif port <= 0 {\n\t\tport = launcherconfig.DefaultPort\n\t}\n\treturn launcherconfig.Config{\n\t\tPort:         port,\n\t\tPublic:       h.serverPublic,\n\t\tAllowedCIDRs: append([]string(nil), h.serverCIDRs...),\n\t}\n}\n\nfunc (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) {\n\treturn launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig())\n}\n\nfunc (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := h.loadLauncherConfig()\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load launcher config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(launcherConfigPayload{\n\t\tPort:         cfg.Port,\n\t\tPublic:       cfg.Public,\n\t\tAllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),\n\t})\n}\n\nfunc (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {\n\tvar payload launcherConfigPayload\n\tif err := json.NewDecoder(r.Body).Decode(&payload); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcfg := launcherconfig.Config{\n\t\tPort:         payload.Port,\n\t\tPublic:       payload.Public,\n\t\tAllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),\n\t}\n\tif err := launcherconfig.Validate(cfg); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save launcher config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(launcherConfigPayload{\n\t\tPort:         cfg.Port,\n\t\tPublic:       cfg.Public,\n\t\tAllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),\n\t})\n}\n"
  },
  {
    "path": "web/backend/api/launcher_config_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n)\n\nfunc TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\th.SetServerOptions(19999, true, false, []string{\"192.168.1.0/24\"})\n\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/system/launcher-config\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar got launcherConfigPayload\n\tif err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got.Port != 19999 || !got.Public {\n\t\tt.Fatalf(\"response = %+v, want port=19999 public=true\", got)\n\t}\n\tif len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != \"192.168.1.0/24\" {\n\t\tt.Fatalf(\"response allowed_cidrs = %v, want [192.168.1.0/24]\", got.AllowedCIDRs)\n\t}\n}\n\nfunc TestPutLauncherConfigPersists(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/system/launcher-config\",\n\t\tstrings.NewReader(`{\"port\":18080,\"public\":true,\"allowed_cidrs\":[\"192.168.1.0/24\"]}`),\n\t)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tpath := launcherconfig.PathForAppConfig(configPath)\n\tcfg, err := launcherconfig.Load(path, launcherconfig.Default())\n\tif err != nil {\n\t\tt.Fatalf(\"launcherconfig.Load() error = %v\", err)\n\t}\n\tif cfg.Port != 18080 || !cfg.Public {\n\t\tt.Fatalf(\"saved config = %+v, want port=18080 public=true\", cfg)\n\t}\n\tif len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != \"192.168.1.0/24\" {\n\t\tt.Fatalf(\"saved config allowed_cidrs = %v, want [192.168.1.0/24]\", cfg.AllowedCIDRs)\n\t}\n}\n\nfunc TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/system/launcher-config\",\n\t\tstrings.NewReader(`{\"port\":70000,\"public\":false}`),\n\t)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusBadRequest, rec.Body.String())\n\t}\n}\n\nfunc TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/system/launcher-config\",\n\t\tstrings.NewReader(`{\"port\":18080,\"public\":false,\"allowed_cidrs\":[\"bad-cidr\"]}`),\n\t)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusBadRequest, rec.Body.String())\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/log.go",
    "content": "package api\n\nimport \"sync\"\n\n// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.\n// It supports incremental reads via LinesSince and tracks a runID that increments\n// whenever the buffer is reset or cleared so clients can detect log history resets.\ntype LogBuffer struct {\n\tmu    sync.RWMutex\n\tlines []string\n\tcap   int\n\ttotal int // total lines ever appended in current run\n\trunID int\n}\n\n// NewLogBuffer creates a LogBuffer with the given capacity.\nfunc NewLogBuffer(capacity int) *LogBuffer {\n\treturn &LogBuffer{\n\t\tlines: make([]string, 0, capacity),\n\t\tcap:   capacity,\n\t}\n}\n\n// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted.\nfunc (b *LogBuffer) Append(line string) {\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\n\tif len(b.lines) < b.cap {\n\t\tb.lines = append(b.lines, line)\n\t} else {\n\t\tb.lines[b.total%b.cap] = line\n\t}\n\n\tb.total++\n}\n\n// Reset clears the buffer and increments the runID. Call this when starting a new gateway process.\nfunc (b *LogBuffer) Reset() {\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\n\tb.lines = b.lines[:0]\n\tb.total = 0\n\tb.runID++\n}\n\n// Clear removes all buffered lines and increments the runID so clients treat\n// subsequent reads as a new log stream.\nfunc (b *LogBuffer) Clear() {\n\tb.Reset()\n}\n\n// LinesSince returns lines appended after the given offset, the current total count, and the runID.\n// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned.\nfunc (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {\n\tb.mu.RLock()\n\tdefer b.mu.RUnlock()\n\n\ttotal = b.total\n\trunID = b.runID\n\n\tif offset >= b.total {\n\t\treturn nil, total, runID\n\t}\n\n\tbuffered := len(b.lines)\n\n\t// How many new lines since offset\n\tnewCount := b.total - offset\n\tif newCount > buffered {\n\t\tnewCount = buffered\n\t}\n\n\tresult := make([]string, newCount)\n\n\tif b.total <= b.cap {\n\t\t// Buffer hasn't wrapped yet — simple slice\n\t\tcopy(result, b.lines[buffered-newCount:])\n\t} else {\n\t\t// Buffer has wrapped — read from ring\n\t\tstart := (b.total - newCount) % b.cap\n\t\tfor i := range newCount {\n\t\t\tresult[i] = b.lines[(start+i)%b.cap]\n\t\t}\n\t}\n\n\treturn result, total, runID\n}\n\n// RunID returns the current run identifier.\nfunc (b *LogBuffer) RunID() int {\n\tb.mu.RLock()\n\tdefer b.mu.RUnlock()\n\n\treturn b.runID\n}\n"
  },
  {
    "path": "web/backend/api/model_status.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nconst modelProbeTimeout = 800 * time.Millisecond\n\nvar (\n\tprobeTCPServiceFunc            = probeTCPService\n\tprobeOllamaModelFunc           = probeOllamaModel\n\tprobeOpenAICompatibleModelFunc = probeOpenAICompatibleModel\n)\n\nfunc hasModelConfiguration(m config.ModelConfig) bool {\n\tauthMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))\n\tapiKey := strings.TrimSpace(m.APIKey)\n\n\tif authMethod == \"oauth\" || authMethod == \"token\" {\n\t\tif provider, ok := oauthProviderForModel(m.Model); ok {\n\t\t\tcred, err := oauthGetCredential(provider)\n\t\t\tif err != nil || cred == nil {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn strings.TrimSpace(cred.AccessToken) != \"\" || strings.TrimSpace(cred.RefreshToken) != \"\"\n\t\t}\n\t\treturn true\n\t}\n\n\tif requiresRuntimeProbe(m) {\n\t\treturn true\n\t}\n\n\treturn apiKey != \"\"\n}\n\n// isModelConfigured reports whether a model is currently available to use.\n// Local models must be reachable; remote/API-key models only need saved config.\nfunc isModelConfigured(m config.ModelConfig) bool {\n\tif !hasModelConfiguration(m) {\n\t\treturn false\n\t}\n\tif requiresRuntimeProbe(m) {\n\t\treturn probeLocalModelAvailability(m)\n\t}\n\treturn true\n}\n\nfunc requiresRuntimeProbe(m config.ModelConfig) bool {\n\tauthMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))\n\tif authMethod == \"local\" {\n\t\treturn true\n\t}\n\n\tswitch modelProtocol(m.Model) {\n\tcase \"claude-cli\", \"claudecli\", \"codex-cli\", \"codexcli\", \"github-copilot\", \"copilot\":\n\t\treturn true\n\tcase \"ollama\", \"vllm\":\n\t\tapiBase := strings.TrimSpace(m.APIBase)\n\t\treturn apiBase == \"\" || hasLocalAPIBase(apiBase)\n\t}\n\n\tif hasLocalAPIBase(m.APIBase) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc probeLocalModelAvailability(m config.ModelConfig) bool {\n\tapiBase := modelProbeAPIBase(m)\n\tprotocol, modelID := splitModel(m.Model)\n\tswitch protocol {\n\tcase \"ollama\":\n\t\treturn probeOllamaModelFunc(apiBase, modelID)\n\tcase \"vllm\":\n\t\treturn probeOpenAICompatibleModelFunc(apiBase, modelID)\n\tcase \"github-copilot\", \"copilot\":\n\t\treturn probeTCPServiceFunc(apiBase)\n\tcase \"claude-cli\", \"claudecli\", \"codex-cli\", \"codexcli\":\n\t\treturn true\n\tdefault:\n\t\tif hasLocalAPIBase(apiBase) {\n\t\t\treturn probeOpenAICompatibleModelFunc(apiBase, modelID)\n\t\t}\n\t\treturn false\n\t}\n}\n\nfunc modelProbeAPIBase(m config.ModelConfig) string {\n\tif apiBase := strings.TrimSpace(m.APIBase); apiBase != \"\" {\n\t\treturn normalizeModelProbeAPIBase(apiBase)\n\t}\n\n\tswitch modelProtocol(m.Model) {\n\tcase \"ollama\":\n\t\treturn \"http://localhost:11434/v1\"\n\tcase \"vllm\":\n\t\treturn \"http://localhost:8000/v1\"\n\tcase \"github-copilot\", \"copilot\":\n\t\treturn \"localhost:4321\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeModelProbeAPIBase(raw string) string {\n\tu, err := parseAPIBase(raw)\n\tif err != nil {\n\t\treturn strings.TrimSpace(raw)\n\t}\n\n\tswitch strings.ToLower(u.Hostname()) {\n\tcase \"0.0.0.0\":\n\t\tu.Host = net.JoinHostPort(\"127.0.0.1\", u.Port())\n\tcase \"::\":\n\t\tu.Host = net.JoinHostPort(\"::1\", u.Port())\n\tdefault:\n\t\treturn strings.TrimSpace(raw)\n\t}\n\n\tif u.Port() == \"\" {\n\t\tu.Host = u.Hostname()\n\t}\n\n\treturn u.String()\n}\n\nfunc oauthProviderForModel(model string) (string, bool) {\n\tswitch modelProtocol(model) {\n\tcase \"openai\":\n\t\treturn oauthProviderOpenAI, true\n\tcase \"anthropic\":\n\t\treturn oauthProviderAnthropic, true\n\tcase \"antigravity\", \"google-antigravity\":\n\t\treturn oauthProviderGoogleAntigravity, true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\nfunc modelProtocol(model string) string {\n\tprotocol, _ := splitModel(model)\n\treturn protocol\n}\n\nfunc splitModel(model string) (protocol, modelID string) {\n\tmodel = strings.ToLower(strings.TrimSpace(model))\n\tprotocol, _, found := strings.Cut(model, \"/\")\n\tif !found {\n\t\treturn \"openai\", model\n\t}\n\treturn protocol, strings.TrimSpace(model[strings.Index(model, \"/\")+1:])\n}\n\nfunc hasLocalAPIBase(raw string) bool {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn false\n\t}\n\n\tu, err := url.Parse(raw)\n\tif err != nil || u.Hostname() == \"\" {\n\t\tu, err = url.Parse(\"//\" + raw)\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t}\n\n\tswitch strings.ToLower(u.Hostname()) {\n\tcase \"localhost\", \"127.0.0.1\", \"::1\", \"0.0.0.0\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc probeTCPService(raw string) bool {\n\thostPort, err := hostPortFromAPIBase(raw)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tconn, err := net.DialTimeout(\"tcp\", hostPort, modelProbeTimeout)\n\tif err != nil {\n\t\treturn false\n\t}\n\t_ = conn.Close()\n\treturn true\n}\n\nfunc probeOllamaModel(apiBase, modelID string) bool {\n\troot, err := apiRootFromAPIBase(apiBase)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tvar resp struct {\n\t\tModels []struct {\n\t\t\tName  string `json:\"name\"`\n\t\t\tModel string `json:\"model\"`\n\t\t} `json:\"models\"`\n\t}\n\tif err := getJSON(root+\"/api/tags\", &resp); err != nil {\n\t\treturn false\n\t}\n\n\tfor _, model := range resp.Models {\n\t\tif ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc probeOpenAICompatibleModel(apiBase, modelID string) bool {\n\tif strings.TrimSpace(apiBase) == \"\" {\n\t\treturn false\n\t}\n\n\tvar resp struct {\n\t\tData []struct {\n\t\t\tID string `json:\"id\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), \"/\")+\"/models\", &resp); err != nil {\n\t\treturn false\n\t}\n\n\tfor _, model := range resp.Data {\n\t\tif strings.EqualFold(strings.TrimSpace(model.ID), modelID) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc getJSON(rawURL string, out any) error {\n\treq, err := http.NewRequest(http.MethodGet, rawURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tclient := &http.Client{Timeout: modelProbeTimeout}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"unexpected status %d\", resp.StatusCode)\n\t}\n\n\treturn json.NewDecoder(resp.Body).Decode(out)\n}\n\nfunc apiRootFromAPIBase(raw string) (string, error) {\n\tu, err := parseAPIBase(raw)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil\n}\n\nfunc hostPortFromAPIBase(raw string) (string, error) {\n\tu, err := parseAPIBase(raw)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif port := u.Port(); port != \"\" {\n\t\treturn u.Host, nil\n\t}\n\tswitch strings.ToLower(u.Scheme) {\n\tcase \"https\":\n\t\treturn net.JoinHostPort(u.Hostname(), \"443\"), nil\n\tdefault:\n\t\treturn net.JoinHostPort(u.Hostname(), \"80\"), nil\n\t}\n}\n\nfunc parseAPIBase(raw string) (*url.URL, error) {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty api base\")\n\t}\n\n\tu, err := url.Parse(raw)\n\tif err == nil && u.Hostname() != \"\" {\n\t\treturn u, nil\n\t}\n\n\tu, err = url.Parse(\"//\" + raw)\n\tif err != nil || u.Hostname() == \"\" {\n\t\treturn nil, fmt.Errorf(\"invalid api base %q\", raw)\n\t}\n\tif u.Scheme == \"\" {\n\t\tu.Scheme = \"http\"\n\t}\n\treturn u, nil\n}\n\nfunc ollamaModelMatches(candidate, want string) bool {\n\tcandidate = strings.TrimSpace(candidate)\n\twant = strings.TrimSpace(want)\n\tif candidate == \"\" || want == \"\" {\n\t\treturn false\n\t}\n\tif strings.EqualFold(candidate, want) {\n\t\treturn true\n\t}\n\n\tbase, _, _ := strings.Cut(candidate, \":\")\n\treturn strings.EqualFold(base, want)\n}\n"
  },
  {
    "path": "web/backend/api/models.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// registerModelRoutes binds model list management endpoints to the ServeMux.\nfunc (h *Handler) registerModelRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/models\", h.handleListModels)\n\tmux.HandleFunc(\"POST /api/models\", h.handleAddModel)\n\tmux.HandleFunc(\"POST /api/models/default\", h.handleSetDefaultModel)\n\tmux.HandleFunc(\"PUT /api/models/{index}\", h.handleUpdateModel)\n\tmux.HandleFunc(\"DELETE /api/models/{index}\", h.handleDeleteModel)\n}\n\n// modelResponse is the JSON structure returned for each model in the list.\n// All ModelConfig fields are included so the frontend can display and edit them.\ntype modelResponse struct {\n\tIndex      int    `json:\"index\"`\n\tModelName  string `json:\"model_name\"`\n\tModel      string `json:\"model\"`\n\tAPIBase    string `json:\"api_base,omitempty\"`\n\tAPIKey     string `json:\"api_key\"`\n\tProxy      string `json:\"proxy,omitempty\"`\n\tAuthMethod string `json:\"auth_method,omitempty\"`\n\t// Advanced fields\n\tConnectMode    string `json:\"connect_mode,omitempty\"`\n\tWorkspace      string `json:\"workspace,omitempty\"`\n\tRPM            int    `json:\"rpm,omitempty\"`\n\tMaxTokensField string `json:\"max_tokens_field,omitempty\"`\n\tRequestTimeout int    `json:\"request_timeout,omitempty\"`\n\tThinkingLevel  string `json:\"thinking_level,omitempty\"`\n\t// Meta\n\tConfigured bool `json:\"configured\"`\n\tIsDefault  bool `json:\"is_default\"`\n}\n\n// handleListModels returns all model_list entries with masked API keys.\n//\n//\tGET /api/models\nfunc (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tdefaultModel := cfg.Agents.Defaults.GetModelName()\n\tconfigured := make([]bool, len(cfg.ModelList))\n\n\tvar wg sync.WaitGroup\n\twg.Add(len(cfg.ModelList))\n\tfor i, m := range cfg.ModelList {\n\t\tgo func(i int, m config.ModelConfig) {\n\t\t\tdefer wg.Done()\n\t\t\tconfigured[i] = isModelConfigured(m)\n\t\t}(i, m)\n\t}\n\twg.Wait()\n\n\tmodels := make([]modelResponse, 0, len(cfg.ModelList))\n\tfor i, m := range cfg.ModelList {\n\t\tmodels = append(models, modelResponse{\n\t\t\tIndex:          i,\n\t\t\tModelName:      m.ModelName,\n\t\t\tModel:          m.Model,\n\t\t\tAPIBase:        m.APIBase,\n\t\t\tAPIKey:         maskAPIKey(m.APIKey),\n\t\t\tProxy:          m.Proxy,\n\t\t\tAuthMethod:     m.AuthMethod,\n\t\t\tConnectMode:    m.ConnectMode,\n\t\t\tWorkspace:      m.Workspace,\n\t\t\tRPM:            m.RPM,\n\t\t\tMaxTokensField: m.MaxTokensField,\n\t\t\tRequestTimeout: m.RequestTimeout,\n\t\t\tThinkingLevel:  m.ThinkingLevel,\n\t\t\tConfigured:     configured[i],\n\t\t\tIsDefault:      m.ModelName == defaultModel,\n\t\t})\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"models\":        models,\n\t\t\"total\":         len(models),\n\t\t\"default_model\": defaultModel,\n\t})\n}\n\n// handleAddModel appends a new model configuration entry.\n//\n//\tPOST /api/models\nfunc (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar mc config.ModelConfig\n\tif err = json.Unmarshal(body, &mc); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err = mc.Validate(); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Validation error: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tcfg.ModelList = append(cfg.ModelList, mc)\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\": \"ok\",\n\t\t\"index\":  len(cfg.ModelList) - 1,\n\t})\n}\n\n// handleUpdateModel replaces a model configuration entry at the given index.\n// If the request body omits api_key (or sends an empty string), the existing\n// stored key is preserved so callers can update only api_base / proxy without\n// exposing or clearing the secret.\n//\n//\tPUT /api/models/{index}\nfunc (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {\n\tidx, err := strconv.Atoi(r.PathValue(\"index\"))\n\tif err != nil {\n\t\thttp.Error(w, \"Invalid index\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar mc config.ModelConfig\n\tif err = json.Unmarshal(body, &mc); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err = mc.Validate(); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Validation error: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif idx < 0 || idx >= len(cfg.ModelList) {\n\t\thttp.Error(w, fmt.Sprintf(\"Index %d out of range (0-%d)\", idx, len(cfg.ModelList)-1), http.StatusNotFound)\n\t\treturn\n\t}\n\n\t// Preserve the existing API key when the caller omits it (empty string).\n\t// This lets the UI update api_base / proxy without clearing the stored secret.\n\tif mc.APIKey == \"\" {\n\t\tmc.APIKey = cfg.ModelList[idx].APIKey\n\t}\n\n\tcfg.ModelList[idx] = mc\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\n// handleDeleteModel removes a model configuration entry at the given index.\n//\n//\tDELETE /api/models/{index}\nfunc (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {\n\tidx, err := strconv.Atoi(r.PathValue(\"index\"))\n\tif err != nil {\n\t\thttp.Error(w, \"Invalid index\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif idx < 0 || idx >= len(cfg.ModelList) {\n\t\thttp.Error(w, fmt.Sprintf(\"Index %d out of range (0-%d)\", idx, len(cfg.ModelList)-1), http.StatusNotFound)\n\t\treturn\n\t}\n\n\tdeletedModelName := cfg.ModelList[idx].ModelName\n\n\tcfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...)\n\n\t// If the deleted model was the default, clear it.\n\tif cfg.Agents.Defaults.ModelName == deletedModelName {\n\t\tcfg.Agents.Defaults.ModelName = \"\"\n\t}\n\tif cfg.Agents.Defaults.Model == deletedModelName {\n\t\tcfg.Agents.Defaults.Model = \"\"\n\t}\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\n// handleSetDefaultModel sets the default model for all agents.\n//\n//\tPOST /api/models/default\nfunc (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar req struct {\n\t\tModelName string `json:\"model_name\"`\n\t}\n\tif err = json.Unmarshal(body, &req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif req.ModelName == \"\" {\n\t\thttp.Error(w, \"model_name is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Verify the model_name exists in model_list\n\tfound := false\n\tfor _, m := range cfg.ModelList {\n\t\tif m.ModelName == req.ModelName {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\thttp.Error(w, fmt.Sprintf(\"Model %q not found in model_list\", req.ModelName), http.StatusNotFound)\n\t\treturn\n\t}\n\n\tcfg.Agents.Defaults.ModelName = req.ModelName\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\"status\":        \"ok\",\n\t\t\"default_model\": req.ModelName,\n\t})\n}\n\n// maskAPIKey returns a masked version of an API key for safe display.\n// Keys longer than 8 chars show prefix + last 4 chars: \"sk-****abcd\"\n// Shorter keys are fully masked as \"****\".\n// Empty keys return empty string.\nfunc maskAPIKey(key string) string {\n\tif key == \"\" {\n\t\treturn \"\"\n\t}\n\tif len(key) <= 8 {\n\t\treturn \"****\"\n\t}\n\t// Show first 3 chars and last 4 chars\n\treturn key[:3] + \"****\" + key[len(key)-4:]\n}\n"
  },
  {
    "path": "web/backend/api/models_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc resetModelProbeHooks(t *testing.T) {\n\tt.Helper()\n\n\torigTCPProbe := probeTCPServiceFunc\n\torigOllamaProbe := probeOllamaModelFunc\n\torigOpenAIProbe := probeOpenAICompatibleModelFunc\n\tt.Cleanup(func() {\n\t\tprobeTCPServiceFunc = origTCPProbe\n\t\tprobeOllamaModelFunc = origOllamaProbe\n\t\tprobeOpenAICompatibleModelFunc = origOpenAIProbe\n\t})\n}\n\nfunc TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\tresetModelProbeHooks(t)\n\n\tvar mu sync.Mutex\n\tvar openAIProbes []string\n\tvar ollamaProbes []string\n\tvar tcpProbes []string\n\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\tmu.Lock()\n\t\topenAIProbes = append(openAIProbes, apiBase+\"|\"+modelID)\n\t\tmu.Unlock()\n\t\treturn apiBase == \"http://127.0.0.1:8000/v1\" && modelID == \"custom-model\"\n\t}\n\tprobeOllamaModelFunc = func(apiBase, modelID string) bool {\n\t\tmu.Lock()\n\t\tollamaProbes = append(ollamaProbes, apiBase+\"|\"+modelID)\n\t\tmu.Unlock()\n\t\treturn apiBase == \"http://localhost:11434/v1\" && modelID == \"llama3\"\n\t}\n\tprobeTCPServiceFunc = func(apiBase string) bool {\n\t\tmu.Lock()\n\t\ttcpProbes = append(tcpProbes, apiBase)\n\t\tmu.Unlock()\n\t\treturn apiBase == \"http://127.0.0.1:4321\"\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName:  \"openai-oauth\",\n\t\t\tModel:      \"openai/gpt-5.4\",\n\t\t\tAuthMethod: \"oauth\",\n\t\t},\n\t\t{\n\t\t\tModelName: \"vllm-local\",\n\t\t\tModel:     \"vllm/custom-model\",\n\t\t\tAPIBase:   \"http://127.0.0.1:8000/v1\",\n\t\t},\n\t\t{\n\t\t\tModelName: \"ollama-default\",\n\t\t\tModel:     \"ollama/llama3\",\n\t\t},\n\t\t{\n\t\t\tModelName: \"vllm-remote\",\n\t\t\tModel:     \"vllm/custom-model\",\n\t\t\tAPIBase:   \"https://models.example.com/v1\",\n\t\t\tAPIKey:    \"remote-key\",\n\t\t},\n\t\t{\n\t\t\tModelName:  \"copilot-gpt-5.4\",\n\t\t\tModel:      \"github-copilot/gpt-5.4\",\n\t\t\tAPIBase:    \"http://127.0.0.1:4321\",\n\t\t\tAuthMethod: \"oauth\",\n\t\t},\n\t}\n\tcfg.Agents.Defaults.ModelName = \"openai-oauth\"\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/models\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tModels []modelResponse `json:\"models\"`\n\t}\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\n\tgot := make(map[string]bool, len(resp.Models))\n\tfor _, model := range resp.Models {\n\t\tgot[model.ModelName] = model.Configured\n\t}\n\n\tif got[\"openai-oauth\"] {\n\t\tt.Fatalf(\"openai oauth model configured = true, want false without stored credential\")\n\t}\n\tif !got[\"vllm-local\"] {\n\t\tt.Fatalf(\"vllm local model configured = false, want true when local probe succeeds\")\n\t}\n\tif !got[\"ollama-default\"] {\n\t\tt.Fatalf(\"ollama default model configured = false, want true when default local probe succeeds\")\n\t}\n\tif !got[\"vllm-remote\"] {\n\t\tt.Fatalf(\"remote vllm model configured = false, want true with api_key\")\n\t}\n\tif !got[\"copilot-gpt-5.4\"] {\n\t\tt.Fatalf(\"copilot model configured = false, want true when local bridge probe succeeds\")\n\t}\n\tif len(openAIProbes) != 1 || openAIProbes[0] != \"http://127.0.0.1:8000/v1|custom-model\" {\n\t\tt.Fatalf(\"openAI probes = %#v, want only local vllm probe\", openAIProbes)\n\t}\n\tif len(ollamaProbes) != 1 || ollamaProbes[0] != \"http://localhost:11434/v1|llama3\" {\n\t\tt.Fatalf(\"ollama probes = %#v, want default local probe\", ollamaProbes)\n\t}\n\tif len(tcpProbes) != 1 || tcpProbes[0] != \"http://127.0.0.1:4321\" {\n\t\tt.Fatalf(\"tcp probes = %#v, want only local copilot probe\", tcpProbes)\n\t}\n}\n\nfunc TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\tresetModelProbeHooks(t)\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName:  \"claude-oauth\",\n\t\tModel:      \"anthropic/claude-sonnet-4.6\",\n\t\tAuthMethod: \"oauth\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"claude-oauth\"\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tif err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{\n\t\tAccessToken: \"anthropic-token\",\n\t\tProvider:    oauthProviderAnthropic,\n\t\tAuthMethod:  \"oauth\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SetCredential() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/models\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tModels []modelResponse `json:\"models\"`\n\t}\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif len(resp.Models) != 1 {\n\t\tt.Fatalf(\"len(models) = %d, want 1\", len(resp.Models))\n\t}\n\tif !resp.Models[0].Configured {\n\t\tt.Fatalf(\"oauth model configured = false, want true with stored credential\")\n\t}\n}\n\nfunc TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\tresetModelProbeHooks(t)\n\n\tstarted := make(chan string, 2)\n\trelease := make(chan struct{})\n\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\tstarted <- apiBase + \"|\" + modelID\n\t\t<-release\n\t\treturn true\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{\n\t\t{\n\t\t\tModelName: \"local-vllm-a\",\n\t\t\tModel:     \"vllm/custom-a\",\n\t\t\tAPIBase:   \"http://127.0.0.1:8000/v1\",\n\t\t},\n\t\t{\n\t\t\tModelName: \"local-vllm-b\",\n\t\t\tModel:     \"vllm/custom-b\",\n\t\t\tAPIBase:   \"http://127.0.0.1:8001/v1\",\n\t\t},\n\t}\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trecCh := make(chan *httptest.ResponseRecorder, 1)\n\tgo func() {\n\t\trec := httptest.NewRecorder()\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/models\", nil)\n\t\tmux.ServeHTTP(rec, req)\n\t\trecCh <- rec\n\t}()\n\n\tfor i := 0; i < 2; i++ {\n\t\tselect {\n\t\tcase <-started:\n\t\tcase <-time.After(200 * time.Millisecond):\n\t\t\tt.Fatal(\"expected both local probes to start before the first one completed\")\n\t\t}\n\t}\n\tclose(release)\n\n\trec := <-recCh\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n}\n\nfunc TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\tresetModelProbeHooks(t)\n\n\tvar gotProbe string\n\tprobeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {\n\t\tgotProbe = apiBase + \"|\" + modelID\n\t\treturn apiBase == \"http://127.0.0.1:8000/v1\" && modelID == \"custom-model\"\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"vllm-local\",\n\t\tModel:     \"vllm/custom-model\",\n\t\tAPIBase:   \"http://0.0.0.0:8000/v1\",\n\t}}\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/models\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tModels []modelResponse `json:\"models\"`\n\t}\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif len(resp.Models) != 1 {\n\t\tt.Fatalf(\"len(models) = %d, want 1\", len(resp.Models))\n\t}\n\tif !resp.Models[0].Configured {\n\t\tt.Fatal(\"wildcard-bound local model configured = false, want true after probe host normalization\")\n\t}\n\tif gotProbe != \"http://127.0.0.1:8000/v1|custom-model\" {\n\t\tt.Fatalf(\"probe api base = %q, want %q\", gotProbe, \"http://127.0.0.1:8000/v1|custom-model\")\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/oauth.go",
    "content": "package api\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\nconst (\n\toauthProviderOpenAI            = \"openai\"\n\toauthProviderAnthropic         = \"anthropic\"\n\toauthProviderGoogleAntigravity = \"google-antigravity\"\n\n\toauthMethodBrowser    = \"browser\"\n\toauthMethodDeviceCode = \"device_code\"\n\toauthMethodToken      = \"token\"\n\n\toauthFlowPending = \"pending\"\n\toauthFlowSuccess = \"success\"\n\toauthFlowError   = \"error\"\n\toauthFlowExpired = \"expired\"\n)\n\nconst (\n\toauthBrowserFlowTTL    = 10 * time.Minute\n\toauthDeviceCodeFlowTTL = 15 * time.Minute\n\toauthTerminalFlowGC    = 30 * time.Minute\n)\n\nvar oauthProviderOrder = []string{\n\toauthProviderOpenAI,\n\toauthProviderAnthropic,\n\toauthProviderGoogleAntigravity,\n}\n\nvar oauthProviderMethods = map[string][]string{\n\toauthProviderOpenAI:            {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken},\n\toauthProviderAnthropic:         {oauthMethodToken},\n\toauthProviderGoogleAntigravity: {oauthMethodBrowser},\n}\n\nvar oauthProviderLabels = map[string]string{\n\toauthProviderOpenAI:            \"OpenAI\",\n\toauthProviderAnthropic:         \"Anthropic\",\n\toauthProviderGoogleAntigravity: \"Google Antigravity\",\n}\n\nvar (\n\toauthNow                      = time.Now\n\toauthGeneratePKCE             = auth.GeneratePKCE\n\toauthGenerateState            = auth.GenerateState\n\toauthBuildAuthorizeURL        = auth.BuildAuthorizeURL\n\toauthRequestDeviceCode        = auth.RequestDeviceCode\n\toauthPollDeviceCodeOnce       = auth.PollDeviceCodeOnce\n\toauthExchangeCodeForTokens    = auth.ExchangeCodeForTokens\n\toauthGetCredential            = auth.GetCredential\n\toauthSetCredential            = auth.SetCredential\n\toauthDeleteCredential         = auth.DeleteCredential\n\toauthLoadConfig               = config.LoadConfig\n\toauthSaveConfig               = config.SaveConfig\n\toauthFetchAntigravityProject  = providers.FetchAntigravityProjectID\n\toauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail\n)\n\ntype oauthFlow struct {\n\tID           string\n\tProvider     string\n\tMethod       string\n\tStatus       string\n\tCreatedAt    time.Time\n\tUpdatedAt    time.Time\n\tExpiresAt    time.Time\n\tError        string\n\tCodeVerifier string\n\tOAuthState   string\n\tRedirectURI  string\n\tDeviceAuthID string\n\tUserCode     string\n\tVerifyURL    string\n\tInterval     int\n}\n\ntype oauthProviderStatus struct {\n\tProvider    string   `json:\"provider\"`\n\tDisplayName string   `json:\"display_name\"`\n\tMethods     []string `json:\"methods\"`\n\tLoggedIn    bool     `json:\"logged_in\"`\n\tStatus      string   `json:\"status\"`\n\tAuthMethod  string   `json:\"auth_method,omitempty\"`\n\tExpiresAt   string   `json:\"expires_at,omitempty\"`\n\tAccountID   string   `json:\"account_id,omitempty\"`\n\tEmail       string   `json:\"email,omitempty\"`\n\tProjectID   string   `json:\"project_id,omitempty\"`\n}\n\ntype oauthFlowResponse struct {\n\tFlowID    string `json:\"flow_id\"`\n\tProvider  string `json:\"provider\"`\n\tMethod    string `json:\"method\"`\n\tStatus    string `json:\"status\"`\n\tExpiresAt string `json:\"expires_at,omitempty\"`\n\tError     string `json:\"error,omitempty\"`\n\tUserCode  string `json:\"user_code,omitempty\"`\n\tVerifyURL string `json:\"verify_url,omitempty\"`\n\tInterval  int    `json:\"interval,omitempty\"`\n}\n\n// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux.\nfunc (h *Handler) registerOAuthRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/oauth/providers\", h.handleListOAuthProviders)\n\tmux.HandleFunc(\"POST /api/oauth/login\", h.handleOAuthLogin)\n\tmux.HandleFunc(\"GET /api/oauth/flows/{id}\", h.handleGetOAuthFlow)\n\tmux.HandleFunc(\"POST /api/oauth/flows/{id}/poll\", h.handlePollOAuthFlow)\n\tmux.HandleFunc(\"POST /api/oauth/logout\", h.handleOAuthLogout)\n\tmux.HandleFunc(\"GET /oauth/callback\", h.handleOAuthCallback)\n}\n\nfunc (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) {\n\tprovidersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder))\n\n\tfor _, provider := range oauthProviderOrder {\n\t\tcred, err := oauthGetCredential(provider)\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to load credentials: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\titem := oauthProviderStatus{\n\t\t\tProvider:    provider,\n\t\t\tDisplayName: oauthProviderLabels[provider],\n\t\t\tMethods:     oauthProviderMethods[provider],\n\t\t\tStatus:      \"not_logged_in\",\n\t\t}\n\t\tif cred != nil {\n\t\t\titem.LoggedIn = true\n\t\t\titem.AuthMethod = cred.AuthMethod\n\t\t\titem.AccountID = cred.AccountID\n\t\t\titem.Email = cred.Email\n\t\t\titem.ProjectID = cred.ProjectID\n\t\t\tif !cred.ExpiresAt.IsZero() {\n\t\t\t\titem.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)\n\t\t\t}\n\t\t\tswitch {\n\t\t\tcase cred.IsExpired():\n\t\t\t\titem.Status = \"expired\"\n\t\t\tcase cred.NeedsRefresh():\n\t\t\t\titem.Status = \"needs_refresh\"\n\t\t\tdefault:\n\t\t\t\titem.Status = \"connected\"\n\t\t\t}\n\t\t}\n\n\t\tprovidersResp = append(providersResp, item)\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\"providers\": providersResp,\n\t})\n}\n\nfunc (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar req struct {\n\t\tProvider string `json:\"provider\"`\n\t\tMethod   string `json:\"method\"`\n\t\tToken    string `json:\"token\"`\n\t}\n\tif err = json.Unmarshal(body, &req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tprovider, err := normalizeOAuthProvider(req.Provider)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tmethod := strings.ToLower(strings.TrimSpace(req.Method))\n\tif !isOAuthMethodSupported(provider, method) {\n\t\thttp.Error(\n\t\t\tw,\n\t\t\tfmt.Sprintf(\"unsupported login method %q for provider %q\", method, provider),\n\t\t\thttp.StatusBadRequest,\n\t\t)\n\t\treturn\n\t}\n\n\tswitch method {\n\tcase oauthMethodToken:\n\t\ttoken := strings.TrimSpace(req.Token)\n\t\tif token == \"\" {\n\t\t\thttp.Error(w, \"token is required\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tcred := &auth.AuthCredential{\n\t\t\tAccessToken: token,\n\t\t\tProvider:    provider,\n\t\t\tAuthMethod:  oauthMethodToken,\n\t\t}\n\t\tif err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"token login failed: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\":   \"ok\",\n\t\t\t\"provider\": provider,\n\t\t\t\"method\":   method,\n\t\t})\n\t\treturn\n\n\tcase oauthMethodDeviceCode:\n\t\tcfg := auth.OpenAIOAuthConfig()\n\t\tinfo, err := oauthRequestDeviceCode(cfg)\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to request device code: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tnow := oauthNow()\n\t\tflow := &oauthFlow{\n\t\t\tID:           newOAuthFlowID(),\n\t\t\tProvider:     provider,\n\t\t\tMethod:       method,\n\t\t\tStatus:       oauthFlowPending,\n\t\t\tCreatedAt:    now,\n\t\t\tUpdatedAt:    now,\n\t\t\tExpiresAt:    now.Add(oauthDeviceCodeFlowTTL),\n\t\t\tDeviceAuthID: info.DeviceAuthID,\n\t\t\tUserCode:     info.UserCode,\n\t\t\tVerifyURL:    info.VerifyURL,\n\t\t\tInterval:     info.Interval,\n\t\t}\n\t\th.storeOAuthFlow(flow)\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\":     \"ok\",\n\t\t\t\"provider\":   provider,\n\t\t\t\"method\":     method,\n\t\t\t\"flow_id\":    flow.ID,\n\t\t\t\"user_code\":  flow.UserCode,\n\t\t\t\"verify_url\": flow.VerifyURL,\n\t\t\t\"interval\":   flow.Interval,\n\t\t\t\"expires_at\": flow.ExpiresAt.Format(time.RFC3339),\n\t\t})\n\t\treturn\n\n\tcase oauthMethodBrowser:\n\t\tcfg, err := oauthConfigForProvider(provider)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tpkce, err := oauthGeneratePKCE()\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to generate PKCE: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tstate, err := oauthGenerateState()\n\t\tif err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to generate state: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tredirectURI := buildOAuthRedirectURI(r)\n\t\tauthURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI)\n\n\t\tnow := oauthNow()\n\t\tflow := &oauthFlow{\n\t\t\tID:           newOAuthFlowID(),\n\t\t\tProvider:     provider,\n\t\t\tMethod:       method,\n\t\t\tStatus:       oauthFlowPending,\n\t\t\tCreatedAt:    now,\n\t\t\tUpdatedAt:    now,\n\t\t\tExpiresAt:    now.Add(oauthBrowserFlowTTL),\n\t\t\tCodeVerifier: pkce.CodeVerifier,\n\t\t\tOAuthState:   state,\n\t\t\tRedirectURI:  redirectURI,\n\t\t}\n\t\th.storeOAuthFlow(flow)\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"status\":     \"ok\",\n\t\t\t\"provider\":   provider,\n\t\t\t\"method\":     method,\n\t\t\t\"flow_id\":    flow.ID,\n\t\t\t\"auth_url\":   authURL,\n\t\t\t\"expires_at\": flow.ExpiresAt.Format(time.RFC3339),\n\t\t})\n\t\treturn\n\tdefault:\n\t\thttp.Error(w, \"unsupported login method\", http.StatusBadRequest)\n\t}\n}\n\nfunc (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) {\n\tflowID := strings.TrimSpace(r.PathValue(\"id\"))\n\tif flowID == \"\" {\n\t\thttp.Error(w, \"missing flow id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tflow, ok := h.getOAuthFlow(flowID)\n\tif !ok {\n\t\thttp.Error(w, \"flow not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(flowToResponse(flow))\n}\n\nfunc (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) {\n\tflowID := strings.TrimSpace(r.PathValue(\"id\"))\n\tif flowID == \"\" {\n\t\thttp.Error(w, \"missing flow id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tflow, ok := h.getOAuthFlow(flowID)\n\tif !ok {\n\t\thttp.Error(w, \"flow not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\tif flow.Method != oauthMethodDeviceCode {\n\t\thttp.Error(w, \"flow does not support polling\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif flow.Status != oauthFlowPending {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(flowToResponse(flow))\n\t\treturn\n\t}\n\n\tcfg := auth.OpenAIOAuthConfig()\n\tcred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode)\n\tif err != nil {\n\t\tif strings.Contains(strings.ToLower(err.Error()), \"pending\") {\n\t\t\tupdated, _ := h.getOAuthFlow(flowID)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_ = json.NewEncoder(w).Encode(flowToResponse(updated))\n\t\t\treturn\n\t\t}\n\t\th.setOAuthFlowError(flowID, fmt.Sprintf(\"device code poll failed: %v\", err))\n\t\tupdated, _ := h.getOAuthFlow(flowID)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(flowToResponse(updated))\n\t\treturn\n\t}\n\tif cred == nil {\n\t\tupdated, _ := h.getOAuthFlow(flowID)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(flowToResponse(updated))\n\t\treturn\n\t}\n\n\tif err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {\n\t\th.setOAuthFlowError(flowID, fmt.Sprintf(\"failed to save credential: %v\", err))\n\t\tupdated, _ := h.getOAuthFlow(flowID)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(flowToResponse(updated))\n\t\treturn\n\t}\n\n\th.setOAuthFlowSuccess(flowID)\n\tupdated, _ := h.getOAuthFlow(flowID)\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(flowToResponse(updated))\n}\n\nfunc (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {\n\tstate := strings.TrimSpace(r.URL.Query().Get(\"state\"))\n\tif state == \"\" {\n\t\trenderOAuthCallbackPage(w, \"\", oauthFlowError, \"Missing state\", \"missing_state\")\n\t\treturn\n\t}\n\n\tflow, ok := h.getOAuthFlowByState(state)\n\tif !ok {\n\t\trenderOAuthCallbackPage(w, \"\", oauthFlowError, \"OAuth flow not found\", \"flow_not_found\")\n\t\treturn\n\t}\n\n\tif flow.Status != oauthFlowPending {\n\t\trenderOAuthCallbackPage(w, flow.ID, flow.Status, \"Flow already completed\", flow.Error)\n\t\treturn\n\t}\n\n\tif errMsg := strings.TrimSpace(r.URL.Query().Get(\"error\")); errMsg != \"\" {\n\t\tif desc := strings.TrimSpace(r.URL.Query().Get(\"error_description\")); desc != \"\" {\n\t\t\terrMsg += \": \" + desc\n\t\t}\n\t\th.setOAuthFlowError(flow.ID, errMsg)\n\t\trenderOAuthCallbackPage(w, flow.ID, oauthFlowError, \"Authorization failed\", errMsg)\n\t\treturn\n\t}\n\n\tcode := strings.TrimSpace(r.URL.Query().Get(\"code\"))\n\tif code == \"\" {\n\t\th.setOAuthFlowError(flow.ID, \"missing authorization code\")\n\t\trenderOAuthCallbackPage(w, flow.ID, oauthFlowError, \"Missing authorization code\", \"missing_code\")\n\t\treturn\n\t}\n\n\tcfg, err := oauthConfigForProvider(flow.Provider)\n\tif err != nil {\n\t\th.setOAuthFlowError(flow.ID, err.Error())\n\t\trenderOAuthCallbackPage(w, flow.ID, oauthFlowError, \"Unsupported provider\", err.Error())\n\t\treturn\n\t}\n\n\tcred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI)\n\tif err != nil {\n\t\th.setOAuthFlowError(flow.ID, fmt.Sprintf(\"token exchange failed: %v\", err))\n\t\trenderOAuthCallbackPage(w, flow.ID, oauthFlowError, \"Token exchange failed\", err.Error())\n\t\treturn\n\t}\n\n\tif err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil {\n\t\th.setOAuthFlowError(flow.ID, fmt.Sprintf(\"failed to save credential: %v\", err))\n\t\trenderOAuthCallbackPage(w, flow.ID, oauthFlowError, \"Failed to save credential\", err.Error())\n\t\treturn\n\t}\n\n\th.setOAuthFlowSuccess(flow.ID)\n\trenderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, \"Authentication successful\", \"\")\n}\n\nfunc (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\thttp.Error(w, \"failed to read request body\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer r.Body.Close()\n\n\tvar req struct {\n\t\tProvider string `json:\"provider\"`\n\t}\n\tif err = json.Unmarshal(body, &req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tprovider, err := normalizeOAuthProvider(req.Provider)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := oauthDeleteCredential(provider); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to delete credential: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tif err := h.syncProviderAuthMethod(provider, \"\"); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to update config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\"status\":   \"ok\",\n\t\t\"provider\": provider,\n\t})\n}\n\nfunc renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) {\n\tpayload := map[string]string{\n\t\t\"type\":   \"picoclaw-oauth-result\",\n\t\t\"flowId\": flowID,\n\t\t\"status\": status,\n\t}\n\tif errMsg != \"\" {\n\t\tpayload[\"error\"] = errMsg\n\t}\n\tpayloadJSON, _ := json.Marshal(payload)\n\n\tmessage := title\n\tif errMsg != \"\" {\n\t\tmessage = fmt.Sprintf(\"%s: %s\", title, errMsg)\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tif status == oauthFlowSuccess {\n\t\tw.WriteHeader(http.StatusOK)\n\t} else {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t}\n\n\t_, _ = fmt.Fprintf(\n\t\tw,\n\t\t\"<!doctype html><html><head><meta charset=\\\"utf-8\\\"><title>PicoClaw OAuth</title></head><body><script>(function(){var payload=%s;var hasOpener=false;try{if(window.opener&&!window.opener.closed){window.opener.postMessage(payload,window.location.origin);hasOpener=true}}catch(e){}var target='/credentials?oauth_flow_id='+encodeURIComponent(payload.flowId||'')+'&oauth_status='+encodeURIComponent(payload.status||'');setTimeout(function(){if(hasOpener){window.close();return}window.location.replace(target)},800)})();</script><div style=\\\"font-family:Inter,system-ui,sans-serif;padding:24px\\\"><h2>%s</h2><p>%s</p><p>You can close this window.</p></div></body></html>\",\n\t\tstring(payloadJSON),\n\t\thtml.EscapeString(title),\n\t\thtml.EscapeString(message),\n\t)\n}\n\nfunc normalizeOAuthProvider(raw string) (string, error) {\n\tprovider := strings.ToLower(strings.TrimSpace(raw))\n\tswitch provider {\n\tcase \"antigravity\":\n\t\treturn oauthProviderGoogleAntigravity, nil\n\tcase oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity:\n\t\treturn provider, nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported provider %q\", raw)\n\t}\n}\n\nfunc isOAuthMethodSupported(provider, method string) bool {\n\tmethods := oauthProviderMethods[provider]\n\tfor _, m := range methods {\n\t\tif m == method {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) {\n\tswitch provider {\n\tcase oauthProviderOpenAI:\n\t\treturn auth.OpenAIOAuthConfig(), nil\n\tcase oauthProviderGoogleAntigravity:\n\t\treturn auth.GoogleAntigravityOAuthConfig(), nil\n\tdefault:\n\t\treturn auth.OAuthProviderConfig{}, fmt.Errorf(\"provider %q does not support browser oauth\", provider)\n\t}\n}\n\nfunc oauthMethodTokenOrOAuth(method string) string {\n\tif method == oauthMethodToken {\n\t\treturn oauthMethodToken\n\t}\n\treturn \"oauth\"\n}\n\nfunc buildOAuthRedirectURI(r *http.Request) string {\n\tscheme := \"http\"\n\tif r.TLS != nil {\n\t\tscheme = \"https\"\n\t}\n\tif forwarded := strings.TrimSpace(r.Header.Get(\"X-Forwarded-Proto\")); forwarded != \"\" {\n\t\tscheme = strings.Split(forwarded, \",\")[0]\n\t}\n\treturn fmt.Sprintf(\"%s://%s/oauth/callback\", scheme, r.Host)\n}\n\nfunc flowToResponse(flow *oauthFlow) oauthFlowResponse {\n\tresp := oauthFlowResponse{\n\t\tFlowID:   flow.ID,\n\t\tProvider: flow.Provider,\n\t\tMethod:   flow.Method,\n\t\tStatus:   flow.Status,\n\t\tError:    flow.Error,\n\t}\n\tif !flow.ExpiresAt.IsZero() {\n\t\tresp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339)\n\t}\n\tif flow.Method == oauthMethodDeviceCode {\n\t\tresp.UserCode = flow.UserCode\n\t\tresp.VerifyURL = flow.VerifyURL\n\t\tresp.Interval = flow.Interval\n\t}\n\treturn resp\n}\n\nfunc newOAuthFlowID() string {\n\tbuf := make([]byte, 16)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn fmt.Sprintf(\"oauth_%d\", time.Now().UnixNano())\n\t}\n\treturn hex.EncodeToString(buf)\n}\n\nfunc (h *Handler) storeOAuthFlow(flow *oauthFlow) {\n\tnow := oauthNow()\n\th.oauthMu.Lock()\n\tdefer h.oauthMu.Unlock()\n\n\th.gcOAuthFlowsLocked(now)\n\th.oauthFlows[flow.ID] = flow\n\tif flow.OAuthState != \"\" {\n\t\th.oauthState[flow.OAuthState] = flow.ID\n\t}\n}\n\nfunc (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) {\n\tnow := oauthNow()\n\th.oauthMu.Lock()\n\tdefer h.oauthMu.Unlock()\n\n\th.gcOAuthFlowsLocked(now)\n\tflow, ok := h.oauthFlows[flowID]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\tcp := *flow\n\treturn &cp, true\n}\n\nfunc (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) {\n\tnow := oauthNow()\n\th.oauthMu.Lock()\n\tdefer h.oauthMu.Unlock()\n\n\th.gcOAuthFlowsLocked(now)\n\tflowID, ok := h.oauthState[state]\n\tif !ok {\n\t\treturn nil, false\n\t}\n\tflow, ok := h.oauthFlows[flowID]\n\tif !ok {\n\t\tdelete(h.oauthState, state)\n\t\treturn nil, false\n\t}\n\tcp := *flow\n\treturn &cp, true\n}\n\nfunc (h *Handler) setOAuthFlowSuccess(flowID string) {\n\tnow := oauthNow()\n\th.oauthMu.Lock()\n\tdefer h.oauthMu.Unlock()\n\n\tflow, ok := h.oauthFlows[flowID]\n\tif !ok {\n\t\treturn\n\t}\n\tflow.Status = oauthFlowSuccess\n\tflow.Error = \"\"\n\tflow.UpdatedAt = now\n\tif flow.OAuthState != \"\" {\n\t\tdelete(h.oauthState, flow.OAuthState)\n\t}\n}\n\nfunc (h *Handler) setOAuthFlowError(flowID, errMsg string) {\n\tnow := oauthNow()\n\th.oauthMu.Lock()\n\tdefer h.oauthMu.Unlock()\n\n\tflow, ok := h.oauthFlows[flowID]\n\tif !ok {\n\t\treturn\n\t}\n\tflow.Status = oauthFlowError\n\tflow.Error = errMsg\n\tflow.UpdatedAt = now\n\tif flow.OAuthState != \"\" {\n\t\tdelete(h.oauthState, flow.OAuthState)\n\t}\n}\n\nfunc (h *Handler) gcOAuthFlowsLocked(now time.Time) {\n\tfor id, flow := range h.oauthFlows {\n\t\tif flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {\n\t\t\tflow.Status = oauthFlowExpired\n\t\t\tflow.Error = \"flow expired\"\n\t\t\tflow.UpdatedAt = now\n\t\t\tif flow.OAuthState != \"\" {\n\t\t\t\tdelete(h.oauthState, flow.OAuthState)\n\t\t\t}\n\t\t}\n\n\t\tif flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC {\n\t\t\tif flow.OAuthState != \"\" {\n\t\t\t\tdelete(h.oauthState, flow.OAuthState)\n\t\t\t}\n\t\t\tdelete(h.oauthFlows, id)\n\t\t}\n\t}\n}\n\nfunc (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error {\n\tif cred == nil {\n\t\treturn fmt.Errorf(\"empty credential\")\n\t}\n\n\tcp := *cred\n\tcp.Provider = provider\n\tif cp.AuthMethod == \"\" {\n\t\tcp.AuthMethod = authMethod\n\t}\n\n\tif provider == oauthProviderGoogleAntigravity {\n\t\tif cp.Email == \"\" {\n\t\t\temail, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorC(\"oauth\", fmt.Sprintf(\"oauth warning: could not fetch google email: %v\", err))\n\t\t\t} else {\n\t\t\t\tcp.Email = email\n\t\t\t}\n\t\t}\n\t\tif cp.ProjectID == \"\" {\n\t\t\tprojectID, err := oauthFetchAntigravityProject(cp.AccessToken)\n\t\t\tif err != nil {\n\t\t\t\tlogger.ErrorC(\"oauth\", fmt.Sprintf(\"oauth warning: could not fetch antigravity project id: %v\", err))\n\t\t\t} else {\n\t\t\t\tcp.ProjectID = projectID\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := oauthSetCredential(provider, &cp); err != nil {\n\t\treturn fmt.Errorf(\"saving credential: %w\", err)\n\t}\n\tif err := h.syncProviderAuthMethod(provider, authMethod); err != nil {\n\t\treturn fmt.Errorf(\"syncing provider auth config: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {\n\tcfg, err := oauthLoadConfig(h.configPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tswitch provider {\n\tcase oauthProviderOpenAI:\n\t\tcfg.Providers.OpenAI.AuthMethod = authMethod\n\tcase oauthProviderAnthropic:\n\t\tcfg.Providers.Anthropic.AuthMethod = authMethod\n\tcase oauthProviderGoogleAntigravity:\n\t\tcfg.Providers.Antigravity.AuthMethod = authMethod\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported provider %q\", provider)\n\t}\n\n\tfound := false\n\tfor i := range cfg.ModelList {\n\t\tif modelBelongsToProvider(provider, cfg.ModelList[i].Model) {\n\t\t\tcfg.ModelList[i].AuthMethod = authMethod\n\t\t\tfound = true\n\t\t}\n\t}\n\n\tif !found && authMethod != \"\" {\n\t\tcfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod))\n\t}\n\n\treturn oauthSaveConfig(h.configPath, cfg)\n}\n\nfunc modelBelongsToProvider(provider, model string) bool {\n\tlower := strings.ToLower(strings.TrimSpace(model))\n\tswitch provider {\n\tcase oauthProviderOpenAI:\n\t\treturn lower == \"openai\" || strings.HasPrefix(lower, \"openai/\")\n\tcase oauthProviderAnthropic:\n\t\treturn lower == \"anthropic\" || strings.HasPrefix(lower, \"anthropic/\")\n\tcase oauthProviderGoogleAntigravity:\n\t\treturn lower == \"antigravity\" ||\n\t\t\tlower == \"google-antigravity\" ||\n\t\t\tstrings.HasPrefix(lower, \"antigravity/\") ||\n\t\t\tstrings.HasPrefix(lower, \"google-antigravity/\")\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {\n\tswitch provider {\n\tcase oauthProviderOpenAI:\n\t\treturn config.ModelConfig{\n\t\t\tModelName:  \"gpt-5.4\",\n\t\t\tModel:      \"openai/gpt-5.4\",\n\t\t\tAuthMethod: authMethod,\n\t\t}\n\tcase oauthProviderAnthropic:\n\t\treturn config.ModelConfig{\n\t\t\tModelName:  \"claude-sonnet-4.6\",\n\t\t\tModel:      \"anthropic/claude-sonnet-4.6\",\n\t\t\tAuthMethod: authMethod,\n\t\t}\n\tcase oauthProviderGoogleAntigravity:\n\t\treturn config.ModelConfig{\n\t\t\tModelName:  \"gemini-flash\",\n\t\t\tModel:      \"antigravity/gemini-3-flash\",\n\t\t\tAuthMethod: authMethod,\n\t\t}\n\tdefault:\n\t\treturn config.ModelConfig{}\n\t}\n}\n\nfunc fetchGoogleUserEmail(accessToken string) (string, error) {\n\treq, err := http.NewRequest(http.MethodGet, \"https://www.googleapis.com/oauth2/v2/userinfo\", nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+accessToken)\n\n\tclient := &http.Client{Timeout: 10 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ := io.ReadAll(resp.Body)\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", fmt.Errorf(\"userinfo request failed: %s\", string(body))\n\t}\n\n\tvar userInfo struct {\n\t\tEmail string `json:\"email\"`\n\t}\n\tif err := json.Unmarshal(body, &userInfo); err != nil {\n\t\treturn \"\", err\n\t}\n\tif userInfo.Email == \"\" {\n\t\treturn \"\", fmt.Errorf(\"empty email in userinfo response\")\n\t}\n\treturn userInfo.Email, nil\n}\n"
  },
  {
    "path": "web/backend/api/oauth_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/auth\"\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/oauth/login\",\n\t\tstrings.NewReader(`{\"provider\":\"anthropic\",\"method\":\"browser\"}`),\n\t)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusBadRequest, rec.Body.String())\n\t}\n}\n\nfunc TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\n\toauthGeneratePKCE = func() (auth.PKCECodes, error) {\n\t\treturn auth.PKCECodes{CodeVerifier: \"verifier-1\", CodeChallenge: \"challenge-1\"}, nil\n\t}\n\toauthGenerateState = func() (string, error) { return \"state-1\", nil }\n\toauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string {\n\t\treturn \"https://example.com/authorize?state=\" + state\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/oauth/login\",\n\t\tstrings.NewReader(`{\"provider\":\"openai\",\"method\":\"browser\"}`),\n\t)\n\treq.Host = \"localhost:18800\"\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar loginResp map[string]any\n\tif err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil {\n\t\tt.Fatalf(\"unmarshal login response: %v\", err)\n\t}\n\tflowID, _ := loginResp[\"flow_id\"].(string)\n\tif flowID == \"\" {\n\t\tt.Fatalf(\"flow_id is empty: %v\", loginResp)\n\t}\n\tif loginResp[\"auth_url\"] != \"https://example.com/authorize?state=state-1\" {\n\t\tt.Fatalf(\"unexpected auth_url: %v\", loginResp[\"auth_url\"])\n\t}\n\n\trec2 := httptest.NewRecorder()\n\treq2 := httptest.NewRequest(http.MethodGet, \"/api/oauth/flows/\"+flowID, nil)\n\tmux.ServeHTTP(rec2, req2)\n\tif rec2.Code != http.StatusOK {\n\t\tt.Fatalf(\"flow status code = %d, want %d, body=%s\", rec2.Code, http.StatusOK, rec2.Body.String())\n\t}\n\tvar flowResp oauthFlowResponse\n\tif err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil {\n\t\tt.Fatalf(\"unmarshal flow response: %v\", err)\n\t}\n\tif flowResp.Status != oauthFlowPending {\n\t\tt.Fatalf(\"flow status = %q, want %q\", flowResp.Status, oauthFlowPending)\n\t}\n\tif flowResp.Method != oauthMethodBrowser {\n\t\tt.Fatalf(\"flow method = %q, want %q\", flowResp.Method, oauthMethodBrowser)\n\t}\n}\n\nfunc TestOAuthFlowExpiresWhenQueried(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\n\tnow := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)\n\toauthNow = func() time.Time { return now }\n\n\th := NewHandler(configPath)\n\th.storeOAuthFlow(&oauthFlow{\n\t\tID:        \"expired-flow\",\n\t\tProvider:  oauthProviderOpenAI,\n\t\tMethod:    oauthMethodBrowser,\n\t\tStatus:    oauthFlowPending,\n\t\tCreatedAt: now.Add(-20 * time.Minute),\n\t\tUpdatedAt: now.Add(-20 * time.Minute),\n\t\tExpiresAt: now.Add(-1 * time.Minute),\n\t})\n\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/oauth/flows/expired-flow\", nil)\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\tvar flowResp oauthFlowResponse\n\tif err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil {\n\t\tt.Fatalf(\"unmarshal flow response: %v\", err)\n\t}\n\tif flowResp.Status != oauthFlowExpired {\n\t\tt.Fatalf(\"flow status = %q, want %q\", flowResp.Status, oauthFlowExpired)\n\t}\n}\n\nfunc TestOAuthCallbackUnknownState(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/oauth/callback?state=unknown&code=abc\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusBadRequest)\n\t}\n\tif !strings.Contains(rec.Body.String(), \"OAuth flow not found\") {\n\t\tt.Fatalf(\"unexpected body: %s\", rec.Body.String())\n\t}\n}\n\nfunc TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\tresetOAuthHooks(t)\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig error: %v\", err)\n\t}\n\tcfg.Providers.OpenAI.AuthMethod = \"oauth\"\n\tcfg.ModelList = append(cfg.ModelList, config.ModelConfig{\n\t\tModelName:  \"gpt-5.4\",\n\t\tModel:      \"openai/gpt-5.4\",\n\t\tAuthMethod: \"oauth\",\n\t})\n\tif err = config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig error: %v\", err)\n\t}\n\tif err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{\n\t\tAccessToken: \"token-before-logout\",\n\t\tProvider:    oauthProviderOpenAI,\n\t\tAuthMethod:  \"oauth\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SetCredential error: %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodPost, \"/api/oauth/logout\", bytes.NewBufferString(`{\"provider\":\"openai\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tcred, err := auth.GetCredential(oauthProviderOpenAI)\n\tif err != nil {\n\t\tt.Fatalf(\"GetCredential error: %v\", err)\n\t}\n\tif cred != nil {\n\t\tt.Fatalf(\"expected credential deleted, got %#v\", cred)\n\t}\n\n\tupdated, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig error: %v\", err)\n\t}\n\tif updated.Providers.OpenAI.AuthMethod != \"\" {\n\t\tt.Fatalf(\"providers.openai.auth_method = %q, want empty\", updated.Providers.OpenAI.AuthMethod)\n\t}\n\tfor _, m := range updated.ModelList {\n\t\tif strings.HasPrefix(m.Model, \"openai/\") && m.AuthMethod != \"\" {\n\t\t\tt.Fatalf(\"openai model auth_method = %q, want empty\", m.AuthMethod)\n\t\t}\n\t}\n}\n\nfunc setupOAuthTestEnv(t *testing.T) (string, func()) {\n\tt.Helper()\n\n\ttmp := t.TempDir()\n\toldHome := os.Getenv(\"HOME\")\n\toldPicoHome := os.Getenv(\"PICOCLAW_HOME\")\n\n\tif err := os.Setenv(\"HOME\", tmp); err != nil {\n\t\tt.Fatalf(\"set HOME: %v\", err)\n\t}\n\tif err := os.Setenv(\"PICOCLAW_HOME\", filepath.Join(tmp, \".picoclaw\")); err != nil {\n\t\tt.Fatalf(\"set PICOCLAW_HOME: %v\", err)\n\t}\n\n\tcfg := config.DefaultConfig()\n\tcfg.ModelList = []config.ModelConfig{{\n\t\tModelName: \"custom-default\",\n\t\tModel:     \"openai/gpt-4o\",\n\t\tAPIKey:    \"sk-default\",\n\t}}\n\tcfg.Agents.Defaults.ModelName = \"custom-default\"\n\n\tconfigPath := filepath.Join(tmp, \"config.json\")\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig error: %v\", err)\n\t}\n\n\tcleanup := func() {\n\t\t_ = os.Setenv(\"HOME\", oldHome)\n\t\tif oldPicoHome == \"\" {\n\t\t\t_ = os.Unsetenv(\"PICOCLAW_HOME\")\n\t\t} else {\n\t\t\t_ = os.Setenv(\"PICOCLAW_HOME\", oldPicoHome)\n\t\t}\n\t}\n\treturn configPath, cleanup\n}\n\nfunc resetOAuthHooks(t *testing.T) {\n\tt.Helper()\n\n\torigNow := oauthNow\n\torigGeneratePKCE := oauthGeneratePKCE\n\torigGenerateState := oauthGenerateState\n\torigBuildAuthorizeURL := oauthBuildAuthorizeURL\n\torigRequestDeviceCode := oauthRequestDeviceCode\n\torigPollDeviceCodeOnce := oauthPollDeviceCodeOnce\n\torigExchangeCodeForTokens := oauthExchangeCodeForTokens\n\torigGetCredential := oauthGetCredential\n\torigSetCredential := oauthSetCredential\n\torigDeleteCredential := oauthDeleteCredential\n\torigLoadConfig := oauthLoadConfig\n\torigSaveConfig := oauthSaveConfig\n\torigFetchProject := oauthFetchAntigravityProject\n\torigFetchGoogleEmail := oauthFetchGoogleUserEmailFunc\n\n\tt.Cleanup(func() {\n\t\toauthNow = origNow\n\t\toauthGeneratePKCE = origGeneratePKCE\n\t\toauthGenerateState = origGenerateState\n\t\toauthBuildAuthorizeURL = origBuildAuthorizeURL\n\t\toauthRequestDeviceCode = origRequestDeviceCode\n\t\toauthPollDeviceCodeOnce = origPollDeviceCodeOnce\n\t\toauthExchangeCodeForTokens = origExchangeCodeForTokens\n\t\toauthGetCredential = origGetCredential\n\t\toauthSetCredential = origSetCredential\n\t\toauthDeleteCredential = origDeleteCredential\n\t\toauthLoadConfig = origLoadConfig\n\t\toauthSaveConfig = origSaveConfig\n\t\toauthFetchAntigravityProject = origFetchProject\n\t\toauthFetchGoogleUserEmailFunc = origFetchGoogleEmail\n\t})\n}\n"
  },
  {
    "path": "web/backend/api/pico.go",
    "content": "package api\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.\nfunc (h *Handler) registerPicoRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/pico/token\", h.handleGetPicoToken)\n\tmux.HandleFunc(\"POST /api/pico/token\", h.handleRegenPicoToken)\n\tmux.HandleFunc(\"POST /api/pico/setup\", h.handlePicoSetup)\n\n\t// WebSocket proxy: forward /pico/ws to gateway\n\t// This allows the frontend to connect via the same port as the web UI,\n\t// avoiding the need to expose extra ports for WebSocket communication.\n\tmux.HandleFunc(\"GET /pico/ws\", h.handleWebSocketProxy())\n}\n\n// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.\n// The gateway bind host and port are resolved from the latest configuration.\nfunc (h *Handler) createWsProxy() *httputil.ReverseProxy {\n\twsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL())\n\twsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {\n\t\thttp.Error(w, \"Gateway unavailable: \"+err.Error(), http.StatusBadGateway)\n\t}\n\treturn wsProxy\n}\n\n// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.\n// The reverse proxy forwards the incoming upgrade handshake as-is.\nfunc (h *Handler) handleWebSocketProxy() http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tproxy := h.createWsProxy()\n\t\tproxy.ServeHTTP(w, r)\n\t}\n}\n\n// handleGetPicoToken returns the current WS token and URL for the frontend.\n//\n//\tGET /api/pico/token\nfunc (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\twsURL := h.buildWsURL(r, cfg)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"token\":   cfg.Channels.Pico.Token,\n\t\t\"ws_url\":  wsURL,\n\t\t\"enabled\": cfg.Channels.Pico.Enabled,\n\t})\n}\n\n// handleRegenPicoToken generates a new Pico WebSocket token and saves it.\n//\n//\tPOST /api/pico/token\nfunc (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\ttoken := generateSecureToken()\n\tcfg.Channels.Pico.Token = token\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\twsURL := h.buildWsURL(r, cfg)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"token\":  token,\n\t\t\"ws_url\": wsURL,\n\t})\n}\n\n// ensurePicoChannel enables the Pico channel with sane defaults if it isn't\n// already configured. Returns true when the config was modified.\n//\n// callerOrigin is the Origin header from the setup request. If non-empty and\n// no origins are configured yet, it's written as the allowed origin so the\n// WebSocket handshake works for whatever host the caller is on (LAN, custom\n// port, etc.). Pass \"\" when there's no request context.\nfunc (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to load config: %w\", err)\n\t}\n\n\tchanged := false\n\n\tif !cfg.Channels.Pico.Enabled {\n\t\tcfg.Channels.Pico.Enabled = true\n\t\tchanged = true\n\t}\n\n\tif cfg.Channels.Pico.Token == \"\" {\n\t\tcfg.Channels.Pico.Token = generateSecureToken()\n\t\tchanged = true\n\t}\n\n\t// Seed origins from the request instead of hardcoding ports.\n\tif len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != \"\" {\n\t\tcfg.Channels.Pico.AllowOrigins = []string{callerOrigin}\n\t\tchanged = true\n\t}\n\n\tif changed {\n\t\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to save config: %w\", err)\n\t\t}\n\t}\n\n\treturn changed, nil\n}\n\n// handlePicoSetup automatically configures everything needed for the Pico Channel to work.\n//\n//\tPOST /api/pico/setup\nfunc (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {\n\tchanged, err := h.ensurePicoChannel(r.Header.Get(\"Origin\"))\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\twsURL := h.buildWsURL(r, cfg)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"token\":   cfg.Channels.Pico.Token,\n\t\t\"ws_url\":  wsURL,\n\t\t\"enabled\": true,\n\t\t\"changed\": changed,\n\t})\n}\n\n// generateSecureToken creates a random 32-character hex string.\nfunc generateSecureToken() string {\n\tb := make([]byte, 16)\n\tif _, err := rand.Read(b); err != nil {\n\t\t// Fallback to something pseudo-random if crypto/rand fails\n\t\treturn fmt.Sprintf(\"pico_%x\", time.Now().UnixNano())\n\t}\n\treturn hex.EncodeToString(b)\n}\n"
  },
  {
    "path": "web/backend/api/pico_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestEnsurePicoChannel_FreshConfig(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tchanged, err := h.ensurePicoChannel(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\tif !changed {\n\t\tt.Fatal(\"ensurePicoChannel() should report changed on a fresh config\")\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tif !cfg.Channels.Pico.Enabled {\n\t\tt.Error(\"expected Pico to be enabled after setup\")\n\t}\n\tif cfg.Channels.Pico.Token == \"\" {\n\t\tt.Error(\"expected a non-empty token after setup\")\n\t}\n}\n\nfunc TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tif _, err := h.ensurePicoChannel(\"\"); err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tif cfg.Channels.Pico.AllowTokenQuery {\n\t\tt.Error(\"setup must not enable allow_token_query by default\")\n\t}\n}\n\nfunc TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tif _, err := h.ensurePicoChannel(\"http://localhost:18800\"); err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tfor _, origin := range cfg.Channels.Pico.AllowOrigins {\n\t\tif origin == \"*\" {\n\t\t\tt.Error(\"setup must not set wildcard origin '*'\")\n\t\t}\n\t}\n}\n\nfunc TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tif _, err := h.ensurePicoChannel(\"\"); err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\t// Without a caller origin, allow_origins stays empty (CheckOrigin\n\t// allows all when the list is empty, so the channel still works).\n\tif len(cfg.Channels.Pico.AllowOrigins) != 0 {\n\t\tt.Errorf(\"allow_origins = %v, want empty when no caller origin\", cfg.Channels.Pico.AllowOrigins)\n\t}\n}\n\nfunc TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\tlanOrigin := \"http://192.168.1.9:18800\"\n\tif _, err := h.ensurePicoChannel(lanOrigin); err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tif len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin {\n\t\tt.Errorf(\"allow_origins = %v, want [%s]\", cfg.Channels.Pico.AllowOrigins, lanOrigin)\n\t}\n}\n\nfunc TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\n\t// Pre-configure with custom user settings\n\tcfg := config.DefaultConfig()\n\tcfg.Channels.Pico.Enabled = true\n\tcfg.Channels.Pico.Token = \"user-custom-token\"\n\tcfg.Channels.Pico.AllowTokenQuery = true\n\tcfg.Channels.Pico.AllowOrigins = []string{\"https://myapp.example.com\"}\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\n\tchanged, err := h.ensurePicoChannel(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"ensurePicoChannel() error = %v\", err)\n\t}\n\tif changed {\n\t\tt.Error(\"ensurePicoChannel() should not change a fully configured config\")\n\t}\n\n\tcfg, err = config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tif cfg.Channels.Pico.Token != \"user-custom-token\" {\n\t\tt.Errorf(\"token = %q, want %q\", cfg.Channels.Pico.Token, \"user-custom-token\")\n\t}\n\tif !cfg.Channels.Pico.AllowTokenQuery {\n\t\tt.Error(\"user's allow_token_query=true must be preserved\")\n\t}\n\tif len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != \"https://myapp.example.com\" {\n\t\tt.Errorf(\"allow_origins = %v, want [https://myapp.example.com]\", cfg.Channels.Pico.AllowOrigins)\n\t}\n}\n\nfunc TestEnsurePicoChannel_Idempotent(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\torigin := \"http://localhost:18800\"\n\n\t// First call sets things up\n\tif _, err := h.ensurePicoChannel(origin); err != nil {\n\t\tt.Fatalf(\"first ensurePicoChannel() error = %v\", err)\n\t}\n\n\tcfg1, _ := config.LoadConfig(configPath)\n\ttoken1 := cfg1.Channels.Pico.Token\n\n\t// Second call should be a no-op\n\tchanged, err := h.ensurePicoChannel(origin)\n\tif err != nil {\n\t\tt.Fatalf(\"second ensurePicoChannel() error = %v\", err)\n\t}\n\tif changed {\n\t\tt.Error(\"second ensurePicoChannel() should not report changed\")\n\t}\n\n\tcfg2, _ := config.LoadConfig(configPath)\n\tif cfg2.Channels.Pico.Token != token1 {\n\t\tt.Error(\"token should not change on subsequent calls\")\n\t}\n}\n\nfunc TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\treq := httptest.NewRequest(\"POST\", \"/api/pico/setup\", nil)\n\treq.Header.Set(\"Origin\", \"http://10.0.0.5:3000\")\n\trec := httptest.NewRecorder()\n\n\th.handlePicoSetup(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tif len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != \"http://10.0.0.5:3000\" {\n\t\tt.Errorf(\"allow_origins = %v, want [http://10.0.0.5:3000]\", cfg.Channels.Pico.AllowOrigins)\n\t}\n}\n\nfunc TestHandlePicoSetup_Response(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\treq := httptest.NewRequest(\"POST\", \"/api/pico/setup\", nil)\n\trec := httptest.NewRecorder()\n\n\th.handlePicoSetup(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n\n\tvar resp map[string]any\n\tif err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {\n\t\tt.Fatalf(\"failed to decode response: %v\", err)\n\t}\n\n\tif resp[\"token\"] == nil || resp[\"token\"] == \"\" {\n\t\tt.Error(\"response should contain a non-empty token\")\n\t}\n\tif resp[\"ws_url\"] == nil || resp[\"ws_url\"] == \"\" {\n\t\tt.Error(\"response should contain ws_url\")\n\t}\n\tif resp[\"enabled\"] != true {\n\t\tt.Error(\"response should have enabled=true\")\n\t}\n\tif resp[\"changed\"] != true {\n\t\tt.Error(\"response should have changed=true on first setup\")\n\t}\n}\n\nfunc TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\thandler := h.handleWebSocketProxy()\n\n\tserver1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/pico/ws\" {\n\t\t\tt.Fatalf(\"server1 path = %q, want %q\", r.URL.Path, \"/pico/ws\")\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"server1\")\n\t}))\n\tdefer server1.Close()\n\n\tserver2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/pico/ws\" {\n\t\t\tt.Fatalf(\"server2 path = %q, want %q\", r.URL.Path, \"/pico/ws\")\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"server2\")\n\t}))\n\tdefer server2.Close()\n\n\tcfg := config.DefaultConfig()\n\tcfg.Gateway.Host = \"127.0.0.1\"\n\tcfg.Gateway.Port = mustGatewayTestPort(t, server1.URL)\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\treq1 := httptest.NewRequest(http.MethodGet, \"/pico/ws\", nil)\n\trec1 := httptest.NewRecorder()\n\thandler(rec1, req1)\n\n\tif rec1.Code != http.StatusOK {\n\t\tt.Fatalf(\"first status = %d, want %d\", rec1.Code, http.StatusOK)\n\t}\n\tif body := rec1.Body.String(); body != \"server1\" {\n\t\tt.Fatalf(\"first body = %q, want %q\", body, \"server1\")\n\t}\n\n\tcfg.Gateway.Port = mustGatewayTestPort(t, server2.URL)\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\treq2 := httptest.NewRequest(http.MethodGet, \"/pico/ws\", nil)\n\trec2 := httptest.NewRecorder()\n\thandler(rec2, req2)\n\n\tif rec2.Code != http.StatusOK {\n\t\tt.Fatalf(\"second status = %d, want %d\", rec2.Code, http.StatusOK)\n\t}\n\tif body := rec2.Body.String(); body != \"server2\" {\n\t\tt.Fatalf(\"second body = %q, want %q\", body, \"server2\")\n\t}\n}\n\nfunc mustGatewayTestPort(t *testing.T, rawURL string) int {\n\tt.Helper()\n\n\tparsed, err := url.Parse(rawURL)\n\tif err != nil {\n\t\tt.Fatalf(\"url.Parse() error = %v\", err)\n\t}\n\n\tport, err := strconv.Atoi(parsed.Port())\n\tif err != nil {\n\t\tt.Fatalf(\"Atoi(%q) error = %v\", parsed.Port(), err)\n\t}\n\n\treturn port\n}\n"
  },
  {
    "path": "web/backend/api/router.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"sync\"\n\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n)\n\n// Handler serves HTTP API requests.\ntype Handler struct {\n\tconfigPath           string\n\tserverPort           int\n\tserverPublic         bool\n\tserverPublicExplicit bool\n\tserverCIDRs          []string\n\toauthMu              sync.Mutex\n\toauthFlows           map[string]*oauthFlow\n\toauthState           map[string]string\n}\n\n// NewHandler creates an instance of the API handler.\nfunc NewHandler(configPath string) *Handler {\n\treturn &Handler{\n\t\tconfigPath: configPath,\n\t\tserverPort: launcherconfig.DefaultPort,\n\t\toauthFlows: make(map[string]*oauthFlow),\n\t\toauthState: make(map[string]string),\n\t}\n}\n\n// SetServerOptions stores current backend listen options for fallback behavior.\nfunc (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) {\n\th.serverPort = port\n\th.serverPublic = public\n\th.serverPublicExplicit = publicExplicit\n\th.serverCIDRs = append([]string(nil), allowedCIDRs...)\n}\n\n// RegisterRoutes binds all API endpoint handlers to the ServeMux.\nfunc (h *Handler) RegisterRoutes(mux *http.ServeMux) {\n\t// Config CRUD\n\th.registerConfigRoutes(mux)\n\n\t// Pico Channel (WebSocket chat)\n\th.registerPicoRoutes(mux)\n\n\t// Gateway process lifecycle\n\th.registerGatewayRoutes(mux)\n\n\t// Session history\n\th.registerSessionRoutes(mux)\n\n\t// OAuth login and credential management\n\th.registerOAuthRoutes(mux)\n\n\t// Model list management\n\th.registerModelRoutes(mux)\n\n\t// Channel catalog (for frontend navigation/config pages)\n\th.registerChannelRoutes(mux)\n\n\t// Skills and tools support/actions\n\th.registerSkillRoutes(mux)\n\th.registerToolRoutes(mux)\n\n\t// OS startup / launch-at-login\n\th.registerStartupRoutes(mux)\n\n\t// Launcher service parameters (port/public)\n\th.registerLauncherConfigRoutes(mux)\n}\n\n// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.\nfunc (h *Handler) Shutdown() {\n\th.StopGateway()\n}\n"
  },
  {
    "path": "web/backend/api/session.go",
    "content": "package api\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n)\n\n// registerSessionRoutes binds session list and detail endpoints to the ServeMux.\nfunc (h *Handler) registerSessionRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/sessions\", h.handleListSessions)\n\tmux.HandleFunc(\"GET /api/sessions/{id}\", h.handleGetSession)\n\tmux.HandleFunc(\"DELETE /api/sessions/{id}\", h.handleDeleteSession)\n}\n\n// sessionFile mirrors the on-disk session JSON structure from pkg/session.\ntype sessionFile struct {\n\tKey      string              `json:\"key\"`\n\tMessages []providers.Message `json:\"messages\"`\n\tSummary  string              `json:\"summary,omitempty\"`\n\tCreated  time.Time           `json:\"created\"`\n\tUpdated  time.Time           `json:\"updated\"`\n}\n\n// sessionListItem is a lightweight summary returned by GET /api/sessions.\ntype sessionListItem struct {\n\tID           string `json:\"id\"`\n\tTitle        string `json:\"title\"`\n\tPreview      string `json:\"preview\"`\n\tMessageCount int    `json:\"message_count\"`\n\tCreated      string `json:\"created\"`\n\tUpdated      string `json:\"updated\"`\n}\n\ntype sessionMetaFile struct {\n\tKey       string    `json:\"key\"`\n\tSummary   string    `json:\"summary\"`\n\tSkip      int       `json:\"skip\"`\n\tCount     int       `json:\"count\"`\n\tCreatedAt time.Time `json:\"created_at\"`\n\tUpdatedAt time.Time `json:\"updated_at\"`\n}\n\n// picoSessionPrefix is the key prefix used by the gateway's routing for Pico\n// channel sessions. The full key format is:\n//\n//\tagent:main:pico:direct:pico:<session-uuid>\n//\n// The sanitized filename replaces ':' with '_', so on disk it becomes:\n//\n//\tagent_main_pico_direct_pico_<session-uuid>.json\nconst (\n\tpicoSessionPrefix          = \"agent:main:pico:direct:pico:\"\n\tsanitizedPicoSessionPrefix = \"agent_main_pico_direct_pico_\"\n\tmaxSessionJSONLLineSize    = 10 * 1024 * 1024 // 10 MB\n\tmaxSessionTitleRunes       = 60\n)\n\n// extractPicoSessionID extracts the session UUID from a full session key.\n// Returns the UUID and true if the key matches the Pico session pattern.\nfunc extractPicoSessionID(key string) (string, bool) {\n\tif strings.HasPrefix(key, picoSessionPrefix) {\n\t\treturn strings.TrimPrefix(key, picoSessionPrefix), true\n\t}\n\treturn \"\", false\n}\n\nfunc extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {\n\tif strings.HasPrefix(key, sanitizedPicoSessionPrefix) {\n\t\treturn strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true\n\t}\n\treturn \"\", false\n}\n\nfunc sanitizeSessionKey(key string) string {\n\treturn strings.ReplaceAll(key, \":\", \"_\")\n}\n\nfunc (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {\n\tpath := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+\".json\")\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn sessionFile{}, err\n\t}\n\n\tvar sess sessionFile\n\tif err := json.Unmarshal(data, &sess); err != nil {\n\t\treturn sessionFile{}, err\n\t}\n\treturn sess, nil\n}\n\nfunc (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {\n\tdata, err := os.ReadFile(path)\n\tif os.IsNotExist(err) {\n\t\treturn sessionMetaFile{Key: sessionKey}, nil\n\t}\n\tif err != nil {\n\t\treturn sessionMetaFile{}, err\n\t}\n\n\tvar meta sessionMetaFile\n\tif err := json.Unmarshal(data, &meta); err != nil {\n\t\treturn sessionMetaFile{}, err\n\t}\n\tif meta.Key == \"\" {\n\t\tmeta.Key = sessionKey\n\t}\n\treturn meta, nil\n}\n\nfunc (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer f.Close()\n\n\tmsgs := make([]providers.Message, 0)\n\tscanner := bufio.NewScanner(f)\n\tscanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)\n\n\tseen := 0\n\tfor scanner.Scan() {\n\t\tline := scanner.Bytes()\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tseen++\n\t\tif seen <= skip {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar msg providers.Message\n\t\tif err := json.Unmarshal(line, &msg); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tmsgs = append(msgs, msg)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn msgs, nil\n}\n\nfunc (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {\n\tsessionKey := picoSessionPrefix + sessionID\n\tbase := filepath.Join(dir, sanitizeSessionKey(sessionKey))\n\tjsonlPath := base + \".jsonl\"\n\tmetaPath := base + \".meta.json\"\n\n\tmeta, err := h.readSessionMeta(metaPath, sessionKey)\n\tif err != nil {\n\t\treturn sessionFile{}, err\n\t}\n\n\tmessages, err := h.readSessionMessages(jsonlPath, meta.Skip)\n\tif err != nil {\n\t\treturn sessionFile{}, err\n\t}\n\n\tupdated := meta.UpdatedAt\n\tcreated := meta.CreatedAt\n\tif created.IsZero() || updated.IsZero() {\n\t\tif info, statErr := os.Stat(jsonlPath); statErr == nil {\n\t\t\tif created.IsZero() {\n\t\t\t\tcreated = info.ModTime()\n\t\t\t}\n\t\t\tif updated.IsZero() {\n\t\t\t\tupdated = info.ModTime()\n\t\t\t}\n\t\t}\n\t}\n\n\treturn sessionFile{\n\t\tKey:      meta.Key,\n\t\tMessages: messages,\n\t\tSummary:  meta.Summary,\n\t\tCreated:  created,\n\t\tUpdated:  updated,\n\t}, nil\n}\n\nfunc buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {\n\tpreview := \"\"\n\tfor _, msg := range sess.Messages {\n\t\tif msg.Role == \"user\" && strings.TrimSpace(msg.Content) != \"\" {\n\t\t\tpreview = msg.Content\n\t\t\tbreak\n\t\t}\n\t}\n\ttitle := strings.TrimSpace(sess.Summary)\n\tif title == \"\" {\n\t\ttitle = preview\n\t}\n\n\ttitle = truncateRunes(title, maxSessionTitleRunes)\n\tpreview = truncateRunes(preview, maxSessionTitleRunes)\n\n\tif preview == \"\" {\n\t\tpreview = \"(empty)\"\n\t}\n\tif title == \"\" {\n\t\ttitle = preview\n\t}\n\n\tvalidMessageCount := 0\n\tfor _, msg := range sess.Messages {\n\t\tif (msg.Role == \"user\" || msg.Role == \"assistant\") && strings.TrimSpace(msg.Content) != \"\" {\n\t\t\tvalidMessageCount++\n\t\t}\n\t}\n\n\treturn sessionListItem{\n\t\tID:           sessionID,\n\t\tTitle:        title,\n\t\tPreview:      preview,\n\t\tMessageCount: validMessageCount,\n\t\tCreated:      sess.Created.Format(time.RFC3339),\n\t\tUpdated:      sess.Updated.Format(time.RFC3339),\n\t}\n}\n\nfunc isEmptySession(sess sessionFile) bool {\n\treturn len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == \"\"\n}\n\nfunc truncateRunes(s string, maxLen int) string {\n\tif maxLen <= 0 {\n\t\treturn \"\"\n\t}\n\trunes := []rune(strings.TrimSpace(s))\n\tif len(runes) <= maxLen {\n\t\treturn string(runes)\n\t}\n\treturn string(runes[:maxLen]) + \"...\"\n}\n\n// sessionsDir resolves the path to the gateway's session storage directory.\n// It reads the workspace from config, falling back to ~/.picoclaw/workspace.\nfunc (h *Handler) sessionsDir() (string, error) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tworkspace := cfg.Agents.Defaults.Workspace\n\tif workspace == \"\" {\n\t\thome, _ := os.UserHomeDir()\n\t\tworkspace = filepath.Join(home, \".picoclaw\", \"workspace\")\n\t}\n\n\t// Expand ~ prefix\n\tif len(workspace) > 0 && workspace[0] == '~' {\n\t\thome, _ := os.UserHomeDir()\n\t\tif len(workspace) > 1 && workspace[1] == '/' {\n\t\t\tworkspace = home + workspace[1:]\n\t\t} else {\n\t\t\tworkspace = home\n\t\t}\n\t}\n\n\treturn filepath.Join(workspace, \"sessions\"), nil\n}\n\n// handleListSessions returns a list of Pico session summaries.\n//\n//\tGET /api/sessions\nfunc (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {\n\tdir, err := h.sessionsDir()\n\tif err != nil {\n\t\thttp.Error(w, \"failed to resolve sessions directory\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\t// Directory doesn't exist yet = no sessions\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode([]sessionListItem{})\n\t\treturn\n\t}\n\n\titems := []sessionListItem{}\n\tseen := make(map[string]struct{})\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tname := entry.Name()\n\t\tvar (\n\t\t\tsessionID string\n\t\t\tsess      sessionFile\n\t\t\tloadErr   error\n\t\t\tok        bool\n\t\t)\n\n\t\tswitch {\n\t\tcase strings.HasSuffix(name, \".jsonl\"):\n\t\t\tsessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, \".jsonl\"))\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsess, loadErr = h.readJSONLSession(dir, sessionID)\n\t\t\tif loadErr == nil && isEmptySession(sess) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\tcase strings.HasSuffix(name, \".meta.json\"):\n\t\t\tcontinue\n\t\tcase filepath.Ext(name) == \".json\":\n\t\t\tbase := strings.TrimSuffix(name, \".json\")\n\t\t\tif _, statErr := os.Stat(filepath.Join(dir, base+\".jsonl\")); statErr == nil {\n\t\t\t\tif jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {\n\t\t\t\t\tif jsonlSess, jsonlErr := h.readJSONLSession(\n\t\t\t\t\t\tdir,\n\t\t\t\t\t\tjsonlSessionID,\n\t\t\t\t\t); jsonlErr == nil &&\n\t\t\t\t\t\t!isEmptySession(jsonlSess) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tdata, err := os.ReadFile(filepath.Join(dir, name))\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := json.Unmarshal(data, &sess); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif isEmptySession(sess) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionID, ok = extractPicoSessionID(sess.Key)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, exists := seen[sessionID]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif loadErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[sessionID]; exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tseen[sessionID] = struct{}{}\n\t\titems = append(items, buildSessionListItem(sessionID, sess))\n\t}\n\n\t// Sort by updated descending (most recent first)\n\tsort.Slice(items, func(i, j int) bool {\n\t\treturn items[i].Updated > items[j].Updated\n\t})\n\n\t// Pagination parameters\n\toffsetStr := r.URL.Query().Get(\"offset\")\n\tlimitStr := r.URL.Query().Get(\"limit\")\n\n\toffset := 0\n\tlimit := 20 // Default limit\n\n\tif val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {\n\t\toffset = val\n\t}\n\tif val, err := strconv.Atoi(limitStr); err == nil && val > 0 {\n\t\tlimit = val\n\t}\n\n\ttotalItems := len(items)\n\n\tend := offset + limit\n\tif offset >= totalItems {\n\t\titems = []sessionListItem{} // Out of bounds, return empty\n\t} else {\n\t\tif end > totalItems {\n\t\t\tend = totalItems\n\t\t}\n\t\titems = items[offset:end]\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(items)\n}\n\n// handleGetSession returns the full message history for a specific session.\n//\n//\tGET /api/sessions/{id}\nfunc (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {\n\tsessionID := r.PathValue(\"id\")\n\tif sessionID == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tdir, err := h.sessionsDir()\n\tif err != nil {\n\t\thttp.Error(w, \"failed to resolve sessions directory\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tsess, err := h.readJSONLSession(dir, sessionID)\n\tif err == nil && isEmptySession(sess) {\n\t\terr = os.ErrNotExist\n\t}\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\tsess, err = h.readLegacySession(dir, sessionID)\n\t\t\tif err == nil && isEmptySession(sess) {\n\t\t\t\terr = os.ErrNotExist\n\t\t\t}\n\t\t}\n\t\tif err != nil {\n\t\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t\thttp.Error(w, \"session not found\", http.StatusNotFound)\n\t\t\t} else {\n\t\t\t\thttp.Error(w, \"failed to parse session\", http.StatusInternalServerError)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Convert to a simpler format for the frontend\n\ttype chatMessage struct {\n\t\tRole    string `json:\"role\"`\n\t\tContent string `json:\"content\"`\n\t}\n\n\tmessages := make([]chatMessage, 0, len(sess.Messages))\n\tfor _, msg := range sess.Messages {\n\t\t// Only include user and assistant messages that have actual content\n\t\tif (msg.Role == \"user\" || msg.Role == \"assistant\") && strings.TrimSpace(msg.Content) != \"\" {\n\t\t\tmessages = append(messages, chatMessage{\n\t\t\t\tRole:    msg.Role,\n\t\t\t\tContent: msg.Content,\n\t\t\t})\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]any{\n\t\t\"id\":       sessionID,\n\t\t\"messages\": messages,\n\t\t\"summary\":  sess.Summary,\n\t\t\"created\":  sess.Created.Format(time.RFC3339),\n\t\t\"updated\":  sess.Updated.Format(time.RFC3339),\n\t})\n}\n\n// handleDeleteSession deletes a specific session.\n//\n//\tDELETE /api/sessions/{id}\nfunc (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {\n\tsessionID := r.PathValue(\"id\")\n\tif sessionID == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tdir, err := h.sessionsDir()\n\tif err != nil {\n\t\thttp.Error(w, \"failed to resolve sessions directory\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tbase := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))\n\tjsonlPath := base + \".jsonl\"\n\tmetaPath := base + \".meta.json\"\n\tlegacyPath := base + \".json\"\n\n\tremoved := false\n\tfor _, path := range []string{jsonlPath, metaPath, legacyPath} {\n\t\tif err := os.Remove(path); err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thttp.Error(w, \"failed to delete session\", http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tremoved = true\n\t}\n\n\tif !removed {\n\t\thttp.Error(w, \"session not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\tw.WriteHeader(http.StatusNoContent)\n}\n"
  },
  {
    "path": "web/backend/api/session_test.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/memory\"\n\t\"github.com/sipeed/picoclaw/pkg/providers\"\n\t\"github.com/sipeed/picoclaw/pkg/session\"\n)\n\nfunc sessionsTestDir(t *testing.T, configPath string) string {\n\tt.Helper()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tdir := filepath.Join(cfg.Agents.Defaults.Workspace, \"sessions\")\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll() error = %v\", err)\n\t}\n\treturn dir\n}\n\nfunc TestHandleListSessions_JSONLStorage(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tstore, err := memory.NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore() error = %v\", err)\n\t}\n\n\tsessionKey := picoSessionPrefix + \"history-jsonl\"\n\tif err := store.AddFullMessage(nil, sessionKey, providers.Message{\n\t\tRole:    \"user\",\n\t\tContent: \"Explain why the history API is empty after migration.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"AddFullMessage(user) error = %v\", err)\n\t}\n\tif err := store.AddFullMessage(nil, sessionKey, providers.Message{\n\t\tRole:    \"assistant\",\n\t\tContent: \"Because the API still reads only legacy JSON session files.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"AddFullMessage(assistant) error = %v\", err)\n\t}\n\tif err := store.AddFullMessage(nil, sessionKey, providers.Message{\n\t\tRole:    \"tool\",\n\t\tContent: \"ignored\",\n\t}); err != nil {\n\t\tt.Fatalf(\"AddFullMessage(tool) error = %v\", err)\n\t}\n\tif err := store.SetSummary(nil, sessionKey, \"JSONL-backed session\"); err != nil {\n\t\tt.Fatalf(\"SetSummary() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/sessions\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar items []sessionListItem\n\tif err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"len(items) = %d, want 1\", len(items))\n\t}\n\tif items[0].ID != \"history-jsonl\" {\n\t\tt.Fatalf(\"items[0].ID = %q, want %q\", items[0].ID, \"history-jsonl\")\n\t}\n\tif items[0].MessageCount != 2 {\n\t\tt.Fatalf(\"items[0].MessageCount = %d, want 2\", items[0].MessageCount)\n\t}\n\tif items[0].Title != \"JSONL-backed session\" {\n\t\tt.Fatalf(\"items[0].Title = %q, want %q\", items[0].Title, \"JSONL-backed session\")\n\t}\n\tif items[0].Preview != \"Explain why the history API is empty after migration.\" {\n\t\tt.Fatalf(\"items[0].Preview = %q\", items[0].Preview)\n\t}\n}\n\nfunc TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tstore, err := memory.NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore() error = %v\", err)\n\t}\n\n\tsessionKey := picoSessionPrefix + \"summary-title\"\n\tif err := store.AddFullMessage(nil, sessionKey, providers.Message{\n\t\tRole:    \"user\",\n\t\tContent: \"fallback preview\",\n\t}); err != nil {\n\t\tt.Fatalf(\"AddFullMessage() error = %v\", err)\n\t}\n\tif err := store.SetSummary(\n\t\tnil,\n\t\tsessionKey,\n\t\t\"  This summary is intentionally longer than sixty characters so it must be truncated in the history menu.  \",\n\t); err != nil {\n\t\tt.Fatalf(\"SetSummary() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/sessions\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar items []sessionListItem\n\tif err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"len(items) = %d, want 1\", len(items))\n\t}\n\texpectedTitle := truncateRunes(\n\t\t\"This summary is intentionally longer than sixty characters so it must be truncated in the history menu.\",\n\t\tmaxSessionTitleRunes,\n\t)\n\tif items[0].Title != expectedTitle {\n\t\tt.Fatalf(\"items[0].Title = %q\", items[0].Title)\n\t}\n\tif items[0].Preview != \"fallback preview\" {\n\t\tt.Fatalf(\"items[0].Preview = %q, want %q\", items[0].Preview, \"fallback preview\")\n\t}\n}\n\nfunc TestHandleGetSession_JSONLStorage(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tstore, err := memory.NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore() error = %v\", err)\n\t}\n\n\tsessionKey := picoSessionPrefix + \"detail-jsonl\"\n\tfor _, msg := range []providers.Message{\n\t\t{Role: \"user\", Content: \"first\"},\n\t\t{Role: \"assistant\", Content: \"second\"},\n\t\t{Role: \"tool\", Content: \"ignored\"},\n\t} {\n\t\tif err := store.AddFullMessage(nil, sessionKey, msg); err != nil {\n\t\t\tt.Fatalf(\"AddFullMessage() error = %v\", err)\n\t\t}\n\t}\n\tif err := store.SetSummary(nil, sessionKey, \"detail summary\"); err != nil {\n\t\tt.Fatalf(\"SetSummary() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/sessions/detail-jsonl\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tID       string `json:\"id\"`\n\t\tSummary  string `json:\"summary\"`\n\t\tMessages []struct {\n\t\t\tRole    string `json:\"role\"`\n\t\t\tContent string `json:\"content\"`\n\t\t} `json:\"messages\"`\n\t}\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif resp.ID != \"detail-jsonl\" {\n\t\tt.Fatalf(\"resp.ID = %q, want %q\", resp.ID, \"detail-jsonl\")\n\t}\n\tif resp.Summary != \"detail summary\" {\n\t\tt.Fatalf(\"resp.Summary = %q, want %q\", resp.Summary, \"detail summary\")\n\t}\n\tif len(resp.Messages) != 2 {\n\t\tt.Fatalf(\"len(resp.Messages) = %d, want 2\", len(resp.Messages))\n\t}\n\tif resp.Messages[0].Role != \"user\" || resp.Messages[0].Content != \"first\" {\n\t\tt.Fatalf(\"first message = %#v, want user/first\", resp.Messages[0])\n\t}\n\tif resp.Messages[1].Role != \"assistant\" || resp.Messages[1].Content != \"second\" {\n\t\tt.Fatalf(\"second message = %#v, want assistant/second\", resp.Messages[1])\n\t}\n}\n\nfunc TestHandleDeleteSession_JSONLStorage(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tstore, err := memory.NewJSONLStore(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewJSONLStore() error = %v\", err)\n\t}\n\n\tsessionKey := picoSessionPrefix + \"delete-jsonl\"\n\tif err := store.AddFullMessage(nil, sessionKey, providers.Message{\n\t\tRole:    \"user\",\n\t\tContent: \"delete me\",\n\t}); err != nil {\n\t\tt.Fatalf(\"AddFullMessage() error = %v\", err)\n\t}\n\tif err := store.SetSummary(nil, sessionKey, \"delete summary\"); err != nil {\n\t\tt.Fatalf(\"SetSummary() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodDelete, \"/api/sessions/delete-jsonl\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusNoContent, rec.Body.String())\n\t}\n\n\tbase := filepath.Join(dir, sanitizeSessionKey(sessionKey))\n\tfor _, path := range []string{base + \".jsonl\", base + \".meta.json\"} {\n\t\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\t\tt.Fatalf(\"expected %s to be removed, stat err = %v\", path, err)\n\t\t}\n\t}\n}\n\nfunc TestHandleGetSession_LegacyJSONFallback(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tmanager := session.NewSessionManager(dir)\n\tsessionKey := picoSessionPrefix + \"legacy-json\"\n\tmanager.AddMessage(sessionKey, \"user\", \"legacy user\")\n\tmanager.AddMessage(sessionKey, \"assistant\", \"legacy assistant\")\n\tif err := manager.Save(sessionKey); err != nil {\n\t\tt.Fatalf(\"Save() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/sessions/legacy-json\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n}\n\nfunc TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tdir := sessionsTestDir(t, configPath)\n\tbase := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+\"empty-jsonl\"))\n\tif err := os.WriteFile(base+\".jsonl\", []byte{}, 0o644); err != nil {\n\t\tt.Fatalf(\"WriteFile(jsonl) error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\tlistRec := httptest.NewRecorder()\n\tlistReq := httptest.NewRequest(http.MethodGet, \"/api/sessions\", nil)\n\tmux.ServeHTTP(listRec, listReq)\n\n\tif listRec.Code != http.StatusOK {\n\t\tt.Fatalf(\"list status = %d, want %d, body=%s\", listRec.Code, http.StatusOK, listRec.Body.String())\n\t}\n\n\tvar items []sessionListItem\n\tif err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {\n\t\tt.Fatalf(\"Unmarshal(list) error = %v\", err)\n\t}\n\tif len(items) != 0 {\n\t\tt.Fatalf(\"len(items) = %d, want 0\", len(items))\n\t}\n\n\tdetailRec := httptest.NewRecorder()\n\tdetailReq := httptest.NewRequest(http.MethodGet, \"/api/sessions/empty-jsonl\", nil)\n\tmux.ServeHTTP(detailRec, detailReq)\n\n\tif detailRec.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"detail status = %d, want %d, body=%s\", detailRec.Code, http.StatusNotFound, detailRec.Body.String())\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/skills.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/skills\"\n)\n\ntype skillSupportResponse struct {\n\tSkills []skills.SkillInfo `json:\"skills\"`\n}\n\ntype skillDetailResponse struct {\n\tName        string `json:\"name\"`\n\tPath        string `json:\"path\"`\n\tSource      string `json:\"source\"`\n\tDescription string `json:\"description\"`\n\tContent     string `json:\"content\"`\n}\n\nvar (\n\tskillNameSanitizer       = regexp.MustCompile(`[^a-z0-9-]+`)\n\timportedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\\r\\n|\\n|\\r)(.*?)(?:\\r\\n|\\n|\\r)---(?:\\r\\n|\\n|\\r)*`)\n\tskillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\\r\\n|\\n|\\r)(.*?)(?:\\r\\n|\\n|\\r)---(?:\\r\\n|\\n|\\r)*`)\n)\n\nfunc (h *Handler) registerSkillRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/skills\", h.handleListSkills)\n\tmux.HandleFunc(\"GET /api/skills/{name}\", h.handleGetSkill)\n\tmux.HandleFunc(\"POST /api/skills/import\", h.handleImportSkill)\n\tmux.HandleFunc(\"DELETE /api/skills/{name}\", h.handleDeleteSkill)\n}\n\nfunc (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tloader := newSkillsLoader(cfg.WorkspacePath())\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(skillSupportResponse{\n\t\tSkills: loader.ListSkills(),\n\t})\n}\n\nfunc (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tloader := newSkillsLoader(cfg.WorkspacePath())\n\tname := r.PathValue(\"name\")\n\tallSkills := loader.ListSkills()\n\n\tfor _, skill := range allSkills {\n\t\tif skill.Name != name {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontent, err := loadSkillContent(skill.Path)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Skill content not found\", http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(skillDetailResponse{\n\t\t\tName:        skill.Name,\n\t\t\tPath:        skill.Path,\n\t\t\tSource:      skill.Source,\n\t\t\tDescription: skill.Description,\n\t\t\tContent:     content,\n\t\t})\n\t\treturn\n\t}\n\n\thttp.Error(w, \"Skill not found\", http.StatusNotFound)\n}\n\nfunc (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\terr = r.ParseMultipartForm(2 << 20)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid multipart form: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tuploadedFile, fileHeader, err := r.FormFile(\"file\")\n\tif err != nil {\n\t\thttp.Error(w, \"file is required\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tdefer uploadedFile.Close()\n\n\tcontent, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to read file: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tif len(content) > 1<<20 {\n\t\thttp.Error(w, \"file exceeds 1MB limit\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tskillName, err := normalizeImportedSkillName(fileHeader.Filename, content)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\tcontent = normalizeImportedSkillContent(content, skillName)\n\n\tworkspace := cfg.WorkspacePath()\n\tskillDir := filepath.Join(workspace, \"skills\", skillName)\n\tskillFile := filepath.Join(skillDir, \"SKILL.md\")\n\tif _, err := os.Stat(skillDir); err == nil {\n\t\thttp.Error(w, \"skill already exists\", http.StatusConflict)\n\t\treturn\n\t}\n\n\tif err := os.MkdirAll(skillDir, 0o755); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to create skill directory: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\tif err := os.WriteFile(skillFile, content, 0o644); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save skill: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tloader := newSkillsLoader(workspace)\n\tfor _, skill := range loader.ListSkills() {\n\t\tif skill.Path == skillFile || (skill.Name == skillName && skill.Source == \"workspace\") {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tjson.NewEncoder(w).Encode(skill)\n\t\t\treturn\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\n\t\t\"name\": skillName,\n\t\t\"path\": skillFile,\n\t})\n}\n\nfunc (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tloader := newSkillsLoader(cfg.WorkspacePath())\n\tname := r.PathValue(\"name\")\n\tfor _, skill := range loader.ListSkills() {\n\t\tif skill.Name != name {\n\t\t\tcontinue\n\t\t}\n\t\tif skill.Source != \"workspace\" {\n\t\t\thttp.Error(w, \"only workspace skills can be deleted\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {\n\t\t\thttp.Error(w, fmt.Sprintf(\"Failed to delete skill: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n\t\treturn\n\t}\n\n\thttp.Error(w, \"Skill not found\", http.StatusNotFound)\n}\n\nfunc newSkillsLoader(workspace string) *skills.SkillsLoader {\n\treturn skills.NewSkillsLoader(\n\t\tworkspace,\n\t\tfilepath.Join(globalConfigDir(), \"skills\"),\n\t\tbuiltinSkillsDir(),\n\t)\n}\n\nfunc normalizeImportedSkillName(filename string, content []byte) (string, error) {\n\trawContent := strings.ReplaceAll(string(content), \"\\r\\n\", \"\\n\")\n\trawContent = strings.ReplaceAll(rawContent, \"\\r\", \"\\n\")\n\tmetadata, _ := extractImportedSkillMetadata(rawContent)\n\n\traw := strings.TrimSpace(metadata[\"name\"])\n\tif raw == \"\" {\n\t\traw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))\n\t}\n\traw = strings.ToLower(raw)\n\traw = strings.ReplaceAll(raw, \"_\", \"-\")\n\traw = strings.ReplaceAll(raw, \" \", \"-\")\n\traw = skillNameSanitizer.ReplaceAllString(raw, \"-\")\n\traw = strings.Trim(raw, \"-\")\n\traw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), \"-\")\n\n\tif raw == \"\" {\n\t\treturn \"\", fmt.Errorf(\"skill name is required in frontmatter or filename\")\n\t}\n\tif len(raw) > 64 {\n\t\treturn \"\", fmt.Errorf(\"skill name exceeds 64 characters\")\n\t}\n\tmatched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw)\n\tif err != nil || !matched {\n\t\treturn \"\", fmt.Errorf(\"skill name must be alphanumeric with hyphens\")\n\t}\n\treturn raw, nil\n}\n\nfunc normalizeImportedSkillContent(content []byte, skillName string) []byte {\n\traw := strings.ReplaceAll(string(content), \"\\r\\n\", \"\\n\")\n\traw = strings.ReplaceAll(raw, \"\\r\", \"\\n\")\n\n\tmetadata, body := extractImportedSkillMetadata(raw)\n\tdescription := strings.TrimSpace(metadata[\"description\"])\n\tif description == \"\" {\n\t\tdescription = inferImportedSkillDescription(body)\n\t}\n\tif description == \"\" {\n\t\tdescription = \"Imported skill\"\n\t}\n\tif len(description) > 1024 {\n\t\tdescription = strings.TrimSpace(description[:1024])\n\t}\n\n\tbody = strings.TrimLeft(body, \"\\n\")\n\tvar builder strings.Builder\n\tbuilder.WriteString(\"---\\n\")\n\tbuilder.WriteString(\"name: \")\n\tbuilder.WriteString(skillName)\n\tbuilder.WriteString(\"\\n\")\n\tbuilder.WriteString(\"description: \")\n\tbuilder.WriteString(description)\n\tbuilder.WriteString(\"\\n\")\n\tbuilder.WriteString(\"---\\n\\n\")\n\tbuilder.WriteString(body)\n\tif !strings.HasSuffix(builder.String(), \"\\n\") {\n\t\tbuilder.WriteString(\"\\n\")\n\t}\n\treturn []byte(builder.String())\n}\n\nfunc extractImportedSkillMetadata(raw string) (map[string]string, string) {\n\tmatches := importedSkillFrontmatter.FindStringSubmatch(raw)\n\tif len(matches) != 2 {\n\t\treturn map[string]string{}, raw\n\t}\n\tmeta := parseImportedSkillYAML(matches[1])\n\tbody := importedSkillFrontmatter.ReplaceAllString(raw, \"\")\n\treturn meta, body\n}\n\nfunc parseImportedSkillYAML(frontmatter string) map[string]string {\n\tresult := make(map[string]string)\n\tfor _, line := range strings.Split(frontmatter, \"\\n\") {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\t\tkey, value, ok := strings.Cut(line, \":\")\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tresult[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `\"'`)\n\t}\n\treturn result\n}\n\nfunc inferImportedSkillDescription(body string) string {\n\tfor _, line := range strings.Split(body, \"\\n\") {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tline = strings.TrimLeft(line, \"#-*0123456789. \")\n\t\tline = strings.TrimSpace(line)\n\t\tif line != \"\" {\n\t\t\treturn line\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc loadSkillContent(path string) (string, error) {\n\tcontent, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn skillFrontmatterStripper.ReplaceAllString(string(content), \"\"), nil\n}\n\nfunc globalConfigDir() string {\n\tif home := os.Getenv(config.EnvHome); home != \"\" {\n\t\treturn home\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(home, \".picoclaw\")\n}\n\nfunc builtinSkillsDir() string {\n\tif path := os.Getenv(config.EnvBuiltinSkills); path != \"\" {\n\t\treturn path\n\t}\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(wd, \"skills\")\n}\n"
  },
  {
    "path": "web/backend/api/skills_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestHandleListSkills(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tworkspace := filepath.Join(t.TempDir(), \"workspace\")\n\tcfg.Agents.Defaults.Workspace = workspace\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tif err := os.MkdirAll(filepath.Join(workspace, \"skills\", \"workspace-skill\"), 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll(workspace skill) error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(workspace, \"skills\", \"workspace-skill\", \"SKILL.md\"),\n\t\t[]byte(\"---\\nname: workspace-skill\\ndescription: Workspace skill\\n---\\n\"),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile(workspace skill) error = %v\", err)\n\t}\n\n\tglobalSkillDir := filepath.Join(globalConfigDir(), \"skills\", \"global-skill\")\n\tif err := os.MkdirAll(globalSkillDir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll(global skill) error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(globalSkillDir, \"SKILL.md\"),\n\t\t[]byte(\"---\\nname: global-skill\\ndescription: Global skill\\n---\\n\"),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile(global skill) error = %v\", err)\n\t}\n\n\tbuiltinRoot := filepath.Join(t.TempDir(), \"builtin-skills\")\n\toldBuiltin := os.Getenv(\"PICOCLAW_BUILTIN_SKILLS\")\n\tif err := os.Setenv(\"PICOCLAW_BUILTIN_SKILLS\", builtinRoot); err != nil {\n\t\tt.Fatalf(\"Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v\", err)\n\t}\n\tdefer func() {\n\t\tif oldBuiltin == \"\" {\n\t\t\t_ = os.Unsetenv(\"PICOCLAW_BUILTIN_SKILLS\")\n\t\t} else {\n\t\t\t_ = os.Setenv(\"PICOCLAW_BUILTIN_SKILLS\", oldBuiltin)\n\t\t}\n\t}()\n\n\tbuiltinSkillDir := filepath.Join(builtinRoot, \"builtin-skill\")\n\tif err := os.MkdirAll(builtinSkillDir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll(builtin skill) error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(builtinSkillDir, \"SKILL.md\"),\n\t\t[]byte(\"---\\nname: builtin-skill\\ndescription: Builtin skill\\n---\\n\"),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile(builtin skill) error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/skills\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp skillSupportResponse\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif len(resp.Skills) != 3 {\n\t\tt.Fatalf(\"skills count = %d, want 3\", len(resp.Skills))\n\t}\n\n\tgotSkills := make(map[string]string, len(resp.Skills))\n\tfor _, skill := range resp.Skills {\n\t\tgotSkills[skill.Name] = skill.Source\n\t}\n\tif gotSkills[\"workspace-skill\"] != \"workspace\" {\n\t\tt.Fatalf(\"workspace-skill source = %q, want workspace\", gotSkills[\"workspace-skill\"])\n\t}\n\tif gotSkills[\"global-skill\"] != \"global\" {\n\t\tt.Fatalf(\"global-skill source = %q, want global\", gotSkills[\"global-skill\"])\n\t}\n\tif gotSkills[\"builtin-skill\"] != \"builtin\" {\n\t\tt.Fatalf(\"builtin-skill source = %q, want builtin\", gotSkills[\"builtin-skill\"])\n\t}\n}\n\nfunc TestHandleGetSkill(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tworkspace := filepath.Join(t.TempDir(), \"workspace\")\n\tcfg.Agents.Defaults.Workspace = workspace\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tskillDir := filepath.Join(workspace, \"skills\", \"viewer-skill\")\n\tif err := os.MkdirAll(skillDir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll() error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(skillDir, \"SKILL.md\"),\n\t\t[]byte(\n\t\t\t\"---\\nname: viewer-skill\\ndescription: Viewable skill\\n---\\n# Viewer Skill\\n\\nThis is visible content.\\n\",\n\t\t),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/skills/viewer-skill\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp skillDetailResponse\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif resp.Name != \"viewer-skill\" || resp.Source != \"workspace\" || resp.Description != \"Viewable skill\" {\n\t\tt.Fatalf(\"unexpected response: %#v\", resp)\n\t}\n\tif resp.Content != \"# Viewer Skill\\n\\nThis is visible content.\\n\" {\n\t\tt.Fatalf(\"content = %q\", resp.Content)\n\t}\n}\n\nfunc TestHandleGetSkillUsesResolvedPath(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\n\tworkspace := filepath.Join(t.TempDir(), \"workspace\")\n\tcfg.Agents.Defaults.Workspace = workspace\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tskillDir := filepath.Join(workspace, \"skills\", \"folder-name\")\n\tif err := os.MkdirAll(skillDir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll() error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(skillDir, \"SKILL.md\"),\n\t\t[]byte(\"---\\nname: display-name\\ndescription: Mismatched path skill\\n---\\n# Display Name\\n\"),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/skills/display-name\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp skillDetailResponse\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tif resp.Name != \"display-name\" {\n\t\tt.Fatalf(\"resp.Name = %q, want display-name\", resp.Name)\n\t}\n\tif resp.Content != \"# Display Name\\n\" {\n\t\tt.Fatalf(\"content = %q\", resp.Content)\n\t}\n}\n\nfunc TestHandleImportSkill(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tworkspace := filepath.Join(t.TempDir(), \"workspace\")\n\tcfg.Agents.Defaults.Workspace = workspace\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tvar body bytes.Buffer\n\twriter := multipart.NewWriter(&body)\n\tpart, err := writer.CreateFormFile(\"file\", \"Plain Skill.md\")\n\tif err != nil {\n\t\tt.Fatalf(\"CreateFormFile() error = %v\", err)\n\t}\n\t_, err = io.WriteString(part, \"# Plain Skill\\n\\nUse this skill to test imports.\\n\")\n\tif err != nil {\n\t\tt.Fatalf(\"WriteString() error = %v\", err)\n\t}\n\terr = writer.Close()\n\tif err != nil {\n\t\tt.Fatalf(\"Close() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodPost, \"/api/skills/import\", &body)\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tskillFile := filepath.Join(workspace, \"skills\", \"plain-skill\", \"SKILL.md\")\n\tcontent, err := os.ReadFile(skillFile)\n\tif err != nil {\n\t\tt.Fatalf(\"ReadFile() error = %v\", err)\n\t}\n\texpected := \"---\\nname: plain-skill\\ndescription: Plain Skill\\n---\\n\\n# Plain Skill\\n\\nUse this skill to test imports.\\n\"\n\tif string(content) != expected {\n\t\tt.Fatalf(\"saved skill content mismatch:\\n%s\", string(content))\n\t}\n\n\trec2 := httptest.NewRecorder()\n\treq2 := httptest.NewRequest(http.MethodGet, \"/api/skills\", nil)\n\tmux.ServeHTTP(rec2, req2)\n\tif rec2.Code != http.StatusOK {\n\t\tt.Fatalf(\"list status = %d, want %d, body=%s\", rec2.Code, http.StatusOK, rec2.Body.String())\n\t}\n\tvar listResp skillSupportResponse\n\tif err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil {\n\t\tt.Fatalf(\"Unmarshal list response error = %v\", err)\n\t}\n\tfound := false\n\tfor _, skill := range listResp.Skills {\n\t\tif skill.Name == \"plain-skill\" && skill.Source == \"workspace\" && skill.Description == \"Plain Skill\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"plain-skill should be listed after import, got %#v\", listResp.Skills)\n\t}\n}\n\nfunc TestHandleDeleteSkill(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tworkspace := filepath.Join(t.TempDir(), \"workspace\")\n\tcfg.Agents.Defaults.Workspace = workspace\n\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\tskillDir := filepath.Join(workspace, \"skills\", \"delete-me\")\n\tif err := os.MkdirAll(skillDir, 0o755); err != nil {\n\t\tt.Fatalf(\"MkdirAll() error = %v\", err)\n\t}\n\tif err := os.WriteFile(\n\t\tfilepath.Join(skillDir, \"SKILL.md\"),\n\t\t[]byte(\"---\\nname: delete-me\\ndescription: delete me\\n---\\n\"),\n\t\t0o644,\n\t); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodDelete, \"/api/skills/delete-me\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\tif _, err := os.Stat(skillDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"skill directory should be removed, stat err=%v\", err)\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/startup.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n)\n\nconst (\n\tautoStartEntryName = \"PicoClawLauncher\"\n\tlaunchAgentLabel   = \"io.picoclaw.launcher\"\n)\n\ntype autoStartRequest struct {\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype autoStartResponse struct {\n\tEnabled   bool   `json:\"enabled\"`\n\tSupported bool   `json:\"supported\"`\n\tPlatform  string `json:\"platform\"`\n\tMessage   string `json:\"message,omitempty\"`\n}\n\nvar errAutoStartUnsupported = errors.New(\"autostart is not supported on this platform\")\n\nfunc (h *Handler) registerStartupRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/system/autostart\", h.handleGetAutoStart)\n\tmux.HandleFunc(\"PUT /api/system/autostart\", h.handleSetAutoStart)\n}\n\nfunc (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) {\n\tenabled, supported, message, err := h.getAutoStartStatus()\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to read startup setting: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(autoStartResponse{\n\t\tEnabled:   enabled,\n\t\tSupported: supported,\n\t\tPlatform:  runtime.GOOS,\n\t\tMessage:   message,\n\t})\n}\n\nfunc (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) {\n\tvar req autoStartRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := h.setAutoStart(req.Enabled); err != nil {\n\t\tif errors.Is(err, errAutoStartUnsupported) {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to update startup setting: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tenabled, supported, message, err := h.getAutoStartStatus()\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to verify startup setting: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(autoStartResponse{\n\t\tEnabled:   enabled,\n\t\tSupported: supported,\n\t\tPlatform:  runtime.GOOS,\n\t\tMessage:   message,\n\t})\n}\n\nfunc (h *Handler) resolveLaunchCommand() (string, []string, error) {\n\texePath, err := os.Executable()\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\targs := []string{\"-no-browser\"}\n\tif h.configPath != \"\" {\n\t\targs = append(args, h.configPath)\n\t}\n\n\treturn exePath, args, nil\n}\n\nfunc (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) {\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\texists, err := fileExists(macLaunchAgentPath())\n\t\treturn exists, true, \"Changes apply on next login.\", err\n\tcase \"linux\":\n\t\texists, err := fileExists(linuxAutoStartPath())\n\t\treturn exists, true, \"Changes apply on next login.\", err\n\tcase \"windows\":\n\t\texists, err := windowsRunKeyExists()\n\t\treturn exists, true, \"Changes apply on next login.\", err\n\tdefault:\n\t\treturn false, false, \"Current platform does not support launch at login.\", nil\n\t}\n}\n\nfunc (h *Handler) setAutoStart(enabled bool) error {\n\texePath, args, err := h.resolveLaunchCommand()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\treturn setDarwinAutoStart(enabled, exePath, args)\n\tcase \"linux\":\n\t\treturn setLinuxAutoStart(enabled, exePath, args)\n\tcase \"windows\":\n\t\treturn setWindowsAutoStart(enabled, exePath, args)\n\tdefault:\n\t\treturn errAutoStartUnsupported\n\t}\n}\n\nfunc fileExists(path string) (bool, error) {\n\t_, err := os.Stat(path)\n\tif err == nil {\n\t\treturn true, nil\n\t}\n\tif os.IsNotExist(err) {\n\t\treturn false, nil\n\t}\n\treturn false, err\n}\n\nfunc macLaunchAgentPath() string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \"Library\", \"LaunchAgents\", launchAgentLabel+\".plist\")\n}\n\nfunc setDarwinAutoStart(enabled bool, exePath string, args []string) error {\n\tplistPath := macLaunchAgentPath()\n\tif enabled {\n\t\tif err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcontent := buildDarwinPlist(exePath, args)\n\t\treturn os.WriteFile(plistPath, []byte(content), 0o644)\n\t}\n\n\tif err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc xmlEscape(s string) string {\n\tvar b bytes.Buffer\n\tfor _, r := range s {\n\t\tswitch r {\n\t\tcase '&':\n\t\t\tb.WriteString(\"&amp;\")\n\t\tcase '<':\n\t\t\tb.WriteString(\"&lt;\")\n\t\tcase '>':\n\t\t\tb.WriteString(\"&gt;\")\n\t\tcase '\"':\n\t\t\tb.WriteString(\"&quot;\")\n\t\tcase '\\'':\n\t\t\tb.WriteString(\"&apos;\")\n\t\tdefault:\n\t\t\tb.WriteRune(r)\n\t\t}\n\t}\n\treturn b.String()\n}\n\nfunc buildDarwinPlist(exePath string, args []string) string {\n\tprogramArgs := make([]string, 0, len(args)+1)\n\tprogramArgs = append(programArgs, exePath)\n\tprogramArgs = append(programArgs, args...)\n\n\tvar b strings.Builder\n\tb.WriteString(`<?xml version=\"1.0\" encoding=\"UTF-8\"?>` + \"\\n\")\n\tb.WriteString(\n\t\t`<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">` + \"\\n\",\n\t)\n\tb.WriteString(`<plist version=\"1.0\">` + \"\\n\")\n\tb.WriteString(`<dict>` + \"\\n\")\n\tb.WriteString(`  <key>Label</key>` + \"\\n\")\n\tb.WriteString(`  <string>` + launchAgentLabel + `</string>` + \"\\n\")\n\tb.WriteString(`  <key>ProgramArguments</key>` + \"\\n\")\n\tb.WriteString(`  <array>` + \"\\n\")\n\tfor _, arg := range programArgs {\n\t\tb.WriteString(`    <string>` + xmlEscape(arg) + `</string>` + \"\\n\")\n\t}\n\tb.WriteString(`  </array>` + \"\\n\")\n\tb.WriteString(`  <key>RunAtLoad</key>` + \"\\n\")\n\tb.WriteString(`  <true/>` + \"\\n\")\n\tb.WriteString(`  <key>ProcessType</key>` + \"\\n\")\n\tb.WriteString(`  <string>Background</string>` + \"\\n\")\n\tb.WriteString(`</dict>` + \"\\n\")\n\tb.WriteString(`</plist>` + \"\\n\")\n\treturn b.String()\n}\n\nfunc linuxAutoStartPath() string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".config\", \"autostart\", \"picoclaw-web.desktop\")\n}\n\nfunc shellQuote(s string) string {\n\tif s == \"\" {\n\t\treturn \"''\"\n\t}\n\tif !strings.ContainsAny(s, \" \\t\\n'\\\"\\\\$`\") {\n\t\treturn s\n\t}\n\treturn \"'\" + strings.ReplaceAll(s, \"'\", \"'\\\"'\\\"'\") + \"'\"\n}\n\nfunc buildLinuxExecLine(exePath string, args []string) string {\n\tparts := make([]string, 0, len(args)+1)\n\tparts = append(parts, shellQuote(exePath))\n\tfor _, arg := range args {\n\t\tparts = append(parts, shellQuote(arg))\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\nfunc setLinuxAutoStart(enabled bool, exePath string, args []string) error {\n\tdesktopPath := linuxAutoStartPath()\n\tif enabled {\n\t\tif err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcontent := strings.Join([]string{\n\t\t\t\"[Desktop Entry]\",\n\t\t\t\"Type=Application\",\n\t\t\t\"Version=1.0\",\n\t\t\t\"Name=PicoClaw Web\",\n\t\t\t\"Comment=Start PicoClaw Web on login\",\n\t\t\t\"Exec=\" + buildLinuxExecLine(exePath, args),\n\t\t\t\"Terminal=false\",\n\t\t\t\"X-GNOME-Autostart-enabled=true\",\n\t\t\t\"NoDisplay=true\",\n\t\t\t\"\",\n\t\t}, \"\\n\")\n\t\treturn os.WriteFile(desktopPath, []byte(content), 0o644)\n\t}\n\n\tif err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc windowsCommandLine(exePath string, args []string) string {\n\tparts := make([]string, 0, len(args)+1)\n\tparts = append(parts, fmt.Sprintf(\"%q\", exePath))\n\tfor _, arg := range args {\n\t\tparts = append(parts, fmt.Sprintf(\"%q\", arg))\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\nfunc windowsRunKeyExists() (bool, error) {\n\tcmd := exec.Command(\"reg\", \"query\", `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`, \"/v\", autoStartEntryName)\n\tif err := cmd.Run(); err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc setWindowsAutoStart(enabled bool, exePath string, args []string) error {\n\tkey := `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`\n\tif enabled {\n\t\tcommandLine := windowsCommandLine(exePath, args)\n\t\tcmd := exec.Command(\"reg\", \"add\", key, \"/v\", autoStartEntryName, \"/t\", \"REG_SZ\", \"/d\", commandLine, \"/f\")\n\t\treturn cmd.Run()\n\t}\n\n\tcmd := exec.Command(\"reg\", \"delete\", key, \"/v\", autoStartEntryName, \"/f\")\n\tif err := cmd.Run(); err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "web/backend/api/startup_test.go",
    "content": "package api\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n)\n\nfunc TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\th := NewHandler(configPath)\n\n\t// Persist non-default launcher options to ensure resolveLaunchCommand does not\n\t// pin them into autostart args.\n\tlauncherPath := launcherconfig.PathForAppConfig(configPath)\n\tif err := launcherconfig.Save(launcherPath, launcherconfig.Config{\n\t\tPort:   19999,\n\t\tPublic: true,\n\t}); err != nil {\n\t\tt.Fatalf(\"launcherconfig.Save() error = %v\", err)\n\t}\n\n\texePath, args, err := h.resolveLaunchCommand()\n\tif err != nil {\n\t\tt.Fatalf(\"resolveLaunchCommand() error = %v\", err)\n\t}\n\tif exePath == \"\" {\n\t\tt.Fatal(\"resolveLaunchCommand() returned empty executable path\")\n\t}\n\tif len(args) != 2 {\n\t\tt.Fatalf(\"args len = %d, want 2 (got %v)\", len(args), args)\n\t}\n\tif args[0] != \"-no-browser\" {\n\t\tt.Fatalf(\"args[0] = %q, want %q\", args[0], \"-no-browser\")\n\t}\n\tif args[1] != configPath {\n\t\tt.Fatalf(\"args[1] = %q, want %q\", args[1], configPath)\n\t}\n\tfor _, arg := range args {\n\t\tif arg == \"-port\" || arg == \"-public\" {\n\t\t\tt.Fatalf(\"autostart args should not pin network flags, got %v\", args)\n\t\t}\n\t}\n}\n\nfunc TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {\n\tplist := buildDarwinPlist(\"/tmp/picoclaw-web\", []string{\"-no-browser\", \"/tmp/config.json\"})\n\tif !strings.Contains(plist, \"<key>RunAtLoad</key>\") {\n\t\tt.Fatalf(\"plist missing RunAtLoad key:\\n%s\", plist)\n\t}\n\tif !strings.Contains(plist, \"<true/>\") {\n\t\tt.Fatalf(\"plist missing RunAtLoad true value:\\n%s\", plist)\n\t}\n}\n"
  },
  {
    "path": "web/backend/api/tools.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\ntype toolCatalogEntry struct {\n\tName        string\n\tDescription string\n\tCategory    string\n\tConfigKey   string\n}\n\ntype toolSupportItem struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tCategory    string `json:\"category\"`\n\tConfigKey   string `json:\"config_key\"`\n\tStatus      string `json:\"status\"`\n\tReasonCode  string `json:\"reason_code,omitempty\"`\n}\n\ntype toolSupportResponse struct {\n\tTools []toolSupportItem `json:\"tools\"`\n}\n\ntype toolStateRequest struct {\n\tEnabled bool `json:\"enabled\"`\n}\n\nvar toolCatalog = []toolCatalogEntry{\n\t{\n\t\tName:        \"read_file\",\n\t\tDescription: \"Read file content from the workspace or explicitly allowed paths.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"read_file\",\n\t},\n\t{\n\t\tName:        \"write_file\",\n\t\tDescription: \"Create or overwrite files within the writable workspace scope.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"write_file\",\n\t},\n\t{\n\t\tName:        \"list_dir\",\n\t\tDescription: \"Inspect directories and enumerate files available to the agent.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"list_dir\",\n\t},\n\t{\n\t\tName:        \"edit_file\",\n\t\tDescription: \"Apply targeted edits to existing files without rewriting everything.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"edit_file\",\n\t},\n\t{\n\t\tName:        \"append_file\",\n\t\tDescription: \"Append content to the end of an existing file.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"append_file\",\n\t},\n\t{\n\t\tName:        \"exec\",\n\t\tDescription: \"Run shell commands inside the configured workspace sandbox.\",\n\t\tCategory:    \"filesystem\",\n\t\tConfigKey:   \"exec\",\n\t},\n\t{\n\t\tName:        \"cron\",\n\t\tDescription: \"Schedule one-time or recurring reminders, jobs, and shell commands.\",\n\t\tCategory:    \"automation\",\n\t\tConfigKey:   \"cron\",\n\t},\n\t{\n\t\tName:        \"web_search\",\n\t\tDescription: \"Search the web using the configured providers.\",\n\t\tCategory:    \"web\",\n\t\tConfigKey:   \"web\",\n\t},\n\t{\n\t\tName:        \"web_fetch\",\n\t\tDescription: \"Fetch and summarize the contents of a webpage.\",\n\t\tCategory:    \"web\",\n\t\tConfigKey:   \"web_fetch\",\n\t},\n\t{\n\t\tName:        \"message\",\n\t\tDescription: \"Send a follow-up message back to the active user or chat.\",\n\t\tCategory:    \"communication\",\n\t\tConfigKey:   \"message\",\n\t},\n\t{\n\t\tName:        \"send_file\",\n\t\tDescription: \"Send an outbound file or media attachment to the active chat.\",\n\t\tCategory:    \"communication\",\n\t\tConfigKey:   \"send_file\",\n\t},\n\t{\n\t\tName:        \"find_skills\",\n\t\tDescription: \"Search external skill registries for installable skills.\",\n\t\tCategory:    \"skills\",\n\t\tConfigKey:   \"find_skills\",\n\t},\n\t{\n\t\tName:        \"install_skill\",\n\t\tDescription: \"Install a skill into the current workspace from a registry.\",\n\t\tCategory:    \"skills\",\n\t\tConfigKey:   \"install_skill\",\n\t},\n\t{\n\t\tName:        \"spawn\",\n\t\tDescription: \"Launch a background subagent for long-running or delegated work.\",\n\t\tCategory:    \"agents\",\n\t\tConfigKey:   \"spawn\",\n\t},\n\t{\n\t\tName:        \"spawn_status\",\n\t\tDescription: \"Query the status of spawned subagents.\",\n\t\tCategory:    \"agents\",\n\t\tConfigKey:   \"spawn_status\",\n\t},\n\t{\n\t\tName:        \"i2c\",\n\t\tDescription: \"Interact with I2C hardware devices exposed on the host.\",\n\t\tCategory:    \"hardware\",\n\t\tConfigKey:   \"i2c\",\n\t},\n\t{\n\t\tName:        \"spi\",\n\t\tDescription: \"Interact with SPI hardware devices exposed on the host.\",\n\t\tCategory:    \"hardware\",\n\t\tConfigKey:   \"spi\",\n\t},\n\t{\n\t\tName:        \"tool_search_tool_regex\",\n\t\tDescription: \"Discover hidden MCP tools by regex search when tool discovery is enabled.\",\n\t\tCategory:    \"discovery\",\n\t\tConfigKey:   \"mcp.discovery.use_regex\",\n\t},\n\t{\n\t\tName:        \"tool_search_tool_bm25\",\n\t\tDescription: \"Discover hidden MCP tools by semantic ranking when tool discovery is enabled.\",\n\t\tCategory:    \"discovery\",\n\t\tConfigKey:   \"mcp.discovery.use_bm25\",\n\t},\n}\n\nfunc (h *Handler) registerToolRoutes(mux *http.ServeMux) {\n\tmux.HandleFunc(\"GET /api/tools\", h.handleListTools)\n\tmux.HandleFunc(\"PUT /api/tools/{name}/state\", h.handleUpdateToolState)\n}\n\nfunc (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(toolSupportResponse{\n\t\tTools: buildToolSupport(cfg),\n\t})\n}\n\nfunc (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) {\n\tcfg, err := config.LoadConfig(h.configPath)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to load config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tvar req toolStateRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Invalid JSON: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := applyToolState(cfg, r.PathValue(\"name\"), req.Enabled); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := config.SaveConfig(h.configPath, cfg); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"Failed to save config: %v\", err), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"status\": \"ok\"})\n}\n\nfunc buildToolSupport(cfg *config.Config) []toolSupportItem {\n\titems := make([]toolSupportItem, 0, len(toolCatalog))\n\tfor _, entry := range toolCatalog {\n\t\tstatus := \"disabled\"\n\t\treasonCode := \"\"\n\n\t\tswitch entry.Name {\n\t\tcase \"find_skills\", \"install_skill\":\n\t\t\tif cfg.Tools.IsToolEnabled(entry.ConfigKey) {\n\t\t\t\tif cfg.Tools.IsToolEnabled(\"skills\") {\n\t\t\t\t\tstatus = \"enabled\"\n\t\t\t\t} else {\n\t\t\t\t\tstatus = \"blocked\"\n\t\t\t\t\treasonCode = \"requires_skills\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"spawn\", \"spawn_status\":\n\t\t\tif cfg.Tools.IsToolEnabled(entry.ConfigKey) {\n\t\t\t\tif cfg.Tools.IsToolEnabled(\"subagent\") {\n\t\t\t\t\tstatus = \"enabled\"\n\t\t\t\t} else {\n\t\t\t\t\tstatus = \"blocked\"\n\t\t\t\t\treasonCode = \"requires_subagent\"\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"tool_search_tool_regex\":\n\t\t\tstatus, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)\n\t\tcase \"tool_search_tool_bm25\":\n\t\t\tstatus, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)\n\t\tcase \"i2c\", \"spi\":\n\t\t\tstatus, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))\n\t\tdefault:\n\t\t\tif cfg.Tools.IsToolEnabled(entry.ConfigKey) {\n\t\t\t\tstatus = \"enabled\"\n\t\t\t}\n\t\t}\n\n\t\titems = append(items, toolSupportItem{\n\t\t\tName:        entry.Name,\n\t\t\tDescription: entry.Description,\n\t\t\tCategory:    entry.Category,\n\t\t\tConfigKey:   entry.ConfigKey,\n\t\t\tStatus:      status,\n\t\t\tReasonCode:  reasonCode,\n\t\t})\n\t}\n\treturn items\n}\n\nfunc resolveHardwareToolSupport(enabled bool) (string, string) {\n\tif !enabled {\n\t\treturn \"disabled\", \"\"\n\t}\n\tif runtime.GOOS != \"linux\" {\n\t\treturn \"blocked\", \"requires_linux\"\n\t}\n\treturn \"enabled\", \"\"\n}\n\nfunc resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {\n\tif !cfg.Tools.IsToolEnabled(\"mcp\") {\n\t\treturn \"disabled\", \"\"\n\t}\n\tif !cfg.Tools.MCP.Discovery.Enabled {\n\t\treturn \"blocked\", \"requires_mcp_discovery\"\n\t}\n\tif !methodEnabled {\n\t\treturn \"disabled\", \"\"\n\t}\n\treturn \"enabled\", \"\"\n}\n\nfunc applyToolState(cfg *config.Config, toolName string, enabled bool) error {\n\tswitch toolName {\n\tcase \"read_file\":\n\t\tcfg.Tools.ReadFile.Enabled = enabled\n\tcase \"write_file\":\n\t\tcfg.Tools.WriteFile.Enabled = enabled\n\tcase \"list_dir\":\n\t\tcfg.Tools.ListDir.Enabled = enabled\n\tcase \"edit_file\":\n\t\tcfg.Tools.EditFile.Enabled = enabled\n\tcase \"append_file\":\n\t\tcfg.Tools.AppendFile.Enabled = enabled\n\tcase \"exec\":\n\t\tcfg.Tools.Exec.Enabled = enabled\n\tcase \"cron\":\n\t\tcfg.Tools.Cron.Enabled = enabled\n\tcase \"web_search\":\n\t\tcfg.Tools.Web.Enabled = enabled\n\tcase \"web_fetch\":\n\t\tcfg.Tools.WebFetch.Enabled = enabled\n\tcase \"message\":\n\t\tcfg.Tools.Message.Enabled = enabled\n\tcase \"send_file\":\n\t\tcfg.Tools.SendFile.Enabled = enabled\n\tcase \"find_skills\":\n\t\tcfg.Tools.FindSkills.Enabled = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.Skills.Enabled = true\n\t\t}\n\tcase \"install_skill\":\n\t\tcfg.Tools.InstallSkill.Enabled = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.Skills.Enabled = true\n\t\t}\n\tcase \"spawn\":\n\t\tcfg.Tools.Spawn.Enabled = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.Subagent.Enabled = true\n\t\t}\n\tcase \"spawn_status\":\n\t\tcfg.Tools.SpawnStatus.Enabled = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.Spawn.Enabled = true\n\t\t\tcfg.Tools.Subagent.Enabled = true\n\t\t}\n\tcase \"i2c\":\n\t\tcfg.Tools.I2C.Enabled = enabled\n\tcase \"spi\":\n\t\tcfg.Tools.SPI.Enabled = enabled\n\tcase \"tool_search_tool_regex\":\n\t\tcfg.Tools.MCP.Discovery.UseRegex = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.MCP.Enabled = true\n\t\t\tcfg.Tools.MCP.Discovery.Enabled = true\n\t\t}\n\tcase \"tool_search_tool_bm25\":\n\t\tcfg.Tools.MCP.Discovery.UseBM25 = enabled\n\t\tif enabled {\n\t\t\tcfg.Tools.MCP.Enabled = true\n\t\t\tcfg.Tools.MCP.Discovery.Enabled = true\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"tool %q cannot be updated\", toolName)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "web/backend/api/tools_test.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"runtime\"\n\t\"testing\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nfunc TestHandleListTools(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.Tools.ReadFile.Enabled = true\n\tcfg.Tools.WriteFile.Enabled = false\n\tcfg.Tools.Cron.Enabled = true\n\tcfg.Tools.FindSkills.Enabled = true\n\tcfg.Tools.Skills.Enabled = true\n\tcfg.Tools.Spawn.Enabled = true\n\tcfg.Tools.Subagent.Enabled = false\n\tcfg.Tools.MCP.Enabled = true\n\tcfg.Tools.MCP.Discovery.Enabled = true\n\tcfg.Tools.MCP.Discovery.UseRegex = true\n\tcfg.Tools.MCP.Discovery.UseBM25 = false\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/tools\", nil)\n\tmux.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\tvar resp toolSupportResponse\n\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t}\n\tgotTools := make(map[string]toolSupportItem, len(resp.Tools))\n\tfor _, tool := range resp.Tools {\n\t\tgotTools[tool.Name] = tool\n\t}\n\tif gotTools[\"read_file\"].Status != \"enabled\" {\n\t\tt.Fatalf(\"read_file status = %q, want enabled\", gotTools[\"read_file\"].Status)\n\t}\n\tif gotTools[\"write_file\"].Status != \"disabled\" {\n\t\tt.Fatalf(\"write_file status = %q, want disabled\", gotTools[\"write_file\"].Status)\n\t}\n\tif gotTools[\"cron\"].Status != \"enabled\" {\n\t\tt.Fatalf(\"cron status = %q, want enabled\", gotTools[\"cron\"].Status)\n\t}\n\tif gotTools[\"spawn\"].Status != \"blocked\" || gotTools[\"spawn\"].ReasonCode != \"requires_subagent\" {\n\t\tt.Fatalf(\"spawn = %#v, want blocked/requires_subagent\", gotTools[\"spawn\"])\n\t}\n\tif gotTools[\"find_skills\"].Status != \"enabled\" {\n\t\tt.Fatalf(\"find_skills status = %q, want enabled\", gotTools[\"find_skills\"].Status)\n\t}\n\tif gotTools[\"tool_search_tool_regex\"].Status != \"enabled\" {\n\t\tt.Fatalf(\"tool_search_tool_regex status = %q, want enabled\", gotTools[\"tool_search_tool_regex\"].Status)\n\t}\n\tif gotTools[\"tool_search_tool_regex\"].ConfigKey != \"mcp.discovery.use_regex\" {\n\t\tt.Fatalf(\n\t\t\t\"tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex\",\n\t\t\tgotTools[\"tool_search_tool_regex\"].ConfigKey,\n\t\t)\n\t}\n\tif gotTools[\"tool_search_tool_bm25\"].Status != \"disabled\" {\n\t\tt.Fatalf(\"tool_search_tool_bm25 status = %q, want disabled\", gotTools[\"tool_search_tool_bm25\"].Status)\n\t}\n\tif gotTools[\"tool_search_tool_bm25\"].ConfigKey != \"mcp.discovery.use_bm25\" {\n\t\tt.Fatalf(\n\t\t\t\"tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25\",\n\t\t\tgotTools[\"tool_search_tool_bm25\"].ConfigKey,\n\t\t)\n\t}\n\tif runtime.GOOS == \"linux\" {\n\t\tif gotTools[\"i2c\"].Status != \"disabled\" {\n\t\t\tt.Fatalf(\"i2c status = %q, want disabled on linux when config is off\", gotTools[\"i2c\"].Status)\n\t\t}\n\t} else {\n\t\tcfg.Tools.I2C.Enabled = true\n\t\tcfg.Tools.SPI.Enabled = true\n\t\tif err := config.SaveConfig(configPath, cfg); err != nil {\n\t\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t\t}\n\n\t\trec = httptest.NewRecorder()\n\t\treq = httptest.NewRequest(http.MethodGet, \"/api/tools\", nil)\n\t\tmux.ServeHTTP(rec, req)\n\t\tif rec.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t\t}\n\n\t\tif err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {\n\t\t\tt.Fatalf(\"Unmarshal() error = %v\", err)\n\t\t}\n\t\tgotTools = make(map[string]toolSupportItem, len(resp.Tools))\n\t\tfor _, tool := range resp.Tools {\n\t\t\tgotTools[tool.Name] = tool\n\t\t}\n\n\t\tif gotTools[\"i2c\"].Status != \"blocked\" || gotTools[\"i2c\"].ReasonCode != \"requires_linux\" {\n\t\t\tt.Fatalf(\"i2c = %#v, want blocked/requires_linux\", gotTools[\"i2c\"])\n\t\t}\n\t\tif gotTools[\"spi\"].Status != \"blocked\" || gotTools[\"spi\"].ReasonCode != \"requires_linux\" {\n\t\t\tt.Fatalf(\"spi = %#v, want blocked/requires_linux\", gotTools[\"spi\"])\n\t\t}\n\t}\n}\n\nfunc TestHandleUpdateToolState(t *testing.T) {\n\tconfigPath, cleanup := setupOAuthTestEnv(t)\n\tdefer cleanup()\n\n\tcfg, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig() error = %v\", err)\n\t}\n\tcfg.Tools.Spawn.Enabled = false\n\tcfg.Tools.Subagent.Enabled = false\n\tcfg.Tools.Cron.Enabled = false\n\tcfg.Tools.MCP.Enabled = false\n\tcfg.Tools.MCP.Discovery.Enabled = false\n\tcfg.Tools.MCP.Discovery.UseRegex = false\n\terr = config.SaveConfig(configPath, cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"SaveConfig() error = %v\", err)\n\t}\n\n\th := NewHandler(configPath)\n\tmux := http.NewServeMux()\n\th.RegisterRoutes(mux)\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/tools/spawn/state\",\n\t\tbytes.NewBufferString(`{\"enabled\":true}`),\n\t)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec, req)\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"spawn status = %d, want %d, body=%s\", rec.Code, http.StatusOK, rec.Body.String())\n\t}\n\n\trec2 := httptest.NewRecorder()\n\treq2 := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/tools/tool_search_tool_regex/state\",\n\t\tbytes.NewBufferString(`{\"enabled\":true}`),\n\t)\n\treq2.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec2, req2)\n\tif rec2.Code != http.StatusOK {\n\t\tt.Fatalf(\"regex status = %d, want %d, body=%s\", rec2.Code, http.StatusOK, rec2.Body.String())\n\t}\n\n\trec3 := httptest.NewRecorder()\n\treq3 := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/tools/cron/state\",\n\t\tbytes.NewBufferString(`{\"enabled\":true}`),\n\t)\n\treq3.Header.Set(\"Content-Type\", \"application/json\")\n\tmux.ServeHTTP(rec3, req3)\n\tif rec3.Code != http.StatusOK {\n\t\tt.Fatalf(\"cron status = %d, want %d, body=%s\", rec3.Code, http.StatusOK, rec3.Body.String())\n\t}\n\n\tupdated, err := config.LoadConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadConfig(updated) error = %v\", err)\n\t}\n\tif !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled {\n\t\tt.Fatalf(\"spawn/subagent should both be enabled: %#v\", updated.Tools)\n\t}\n\tif !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex {\n\t\tt.Fatalf(\"mcp regex discovery should be enabled: %#v\", updated.Tools.MCP)\n\t}\n\tif !updated.Tools.Cron.Enabled {\n\t\tt.Fatalf(\"cron should be enabled: %#v\", updated.Tools.Cron)\n\t}\n}\n"
  },
  {
    "path": "web/backend/app_runtime.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/web/backend/utils\"\n)\n\nconst (\n\tbrowserDelay    = 500 * time.Millisecond\n\tshutdownTimeout = 15 * time.Second\n)\n\n// shutdownApp gracefully shuts down all server components and resources.\n// It performs the following shutdown sequence:\n//   - Shuts down the API handler to close all active SSE (Server-Sent Events) connections\n//   - Disables HTTP keep-alive to prevent new connections during shutdown\n//   - Attempts graceful HTTP server shutdown with timeout\n//   - Logs shutdown status at appropriate log levels\n//\n// The function handles timeout errors gracefully by logging them at info level\n// since context.DeadlineExceeded is expected when there are active long-running\n// connections (such as SSE streams).\n//\n// This function should be called during application termination to ensure\n// clean resource cleanup and proper connection closure.\nfunc shutdownApp() {\n\t// First, shutdown API handler to close all SSE connections\n\tif apiHandler != nil {\n\t\tapiHandler.Shutdown()\n\t}\n\n\tif server != nil {\n\t\t// Disable keep-alive to allow graceful shutdown\n\t\tserver.SetKeepAlivesEnabled(false)\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)\n\t\tdefer cancel()\n\t\tif err := server.Shutdown(ctx); err != nil {\n\t\t\t// Context deadline exceeded is expected if there are active connections\n\t\t\t// This is not necessarily an error, so log it at info level\n\t\t\tif errors.Is(err, context.DeadlineExceeded) {\n\t\t\t\tlogger.Infof(\"Server shutdown timeout after %v, forcing close\", shutdownTimeout)\n\t\t\t} else {\n\t\t\t\tlogger.Errorf(\"Server shutdown error: %v\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tlogger.Infof(\"Server shutdown completed successfully\")\n\t\t}\n\t}\n}\n\nfunc openBrowser() error {\n\tif serverAddr == \"\" {\n\t\treturn fmt.Errorf(\"server address not set\")\n\t}\n\treturn utils.OpenBrowser(serverAddr)\n}\n"
  },
  {
    "path": "web/backend/dist/.gitkeep",
    "content": "# Keep the embedded web backend dist directory in version control.\n"
  },
  {
    "path": "web/backend/embed.go",
    "content": "package main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"mime\"\n\t\"net/http\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n//go:embed all:dist\nvar frontendFS embed.FS\n\n// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files\nfunc registerEmbedRoutes(mux *http.ServeMux) {\n\t// Register correct MIME type for SVG files\n\t// Go's built-in mime.TypeByExtension returns \"image/svg\" which is incorrect\n\t// The correct MIME type per RFC 6838 is \"image/svg+xml\"\n\tif err := mime.AddExtensionType(\".svg\", \"image/svg+xml\"); err != nil {\n\t\tlogger.ErrorC(\"web\", fmt.Sprintf(\"Warning: failed to register SVG MIME type: %v\", err))\n\t}\n\n\t// Attempt to get the subdirectory 'dist' where Vite usually builds\n\tsubFS, err := fs.Sub(frontendFS, \"dist\")\n\tif err != nil {\n\t\t// Log a warning if dist doesn't exist yet (e.g., during development before a frontend build)\n\t\tlogger.WarnC(\"web\",\n\t\t\t\"Warning: no 'dist' folder found in embedded frontend. \"+\n\t\t\t\t\"Ensure you run `pnpm build:backend` in the frontend directory \"+\n\t\t\t\t\"before building the Go backend.\",\n\t\t)\n\t\treturn\n\t}\n\n\tfileServer := http.FileServer(http.FS(subFS))\n\n\t// Serve static assets and fallback to index.html for SPA routes.\n\tmux.Handle(\n\t\t\"/\",\n\t\thttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Keep unknown API paths as 404 instead of falling back to SPA entry.\n\t\t\tif r.URL.Path == \"/api\" || strings.HasPrefix(r.URL.Path, \"/api/\") {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, \"/\"))\n\t\t\tif cleanPath == \".\" {\n\t\t\t\tcleanPath = \"\"\n\t\t\t}\n\n\t\t\t// Existing static files/directories should be served directly.\n\t\t\tif cleanPath != \"\" {\n\t\t\t\tif _, statErr := fs.Stat(subFS, cleanPath); statErr == nil {\n\t\t\t\t\tfileServer.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Missing asset-like paths should remain 404.\n\t\t\t\tif strings.Contains(path.Base(cleanPath), \".\") {\n\t\t\t\t\tfileServer.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tindexReq := r.Clone(r.Context())\n\t\t\tindexReq.URL.Path = \"/\"\n\t\t\tfileServer.ServeHTTP(w, indexReq)\n\t\t}),\n\t)\n}\n"
  },
  {
    "path": "web/backend/embed_test.go",
    "content": "package main\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestUnknownAPIPathStays404(t *testing.T) {\n\tmux := http.NewServeMux()\n\tregisterEmbedRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/not-found\", nil)\n\trr := httptest.NewRecorder()\n\tmux.ServeHTTP(rr, req)\n\n\tif rr.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"status = %d, want %d\", rr.Code, http.StatusNotFound)\n\t}\n}\n\nfunc TestMissingAssetStays404(t *testing.T) {\n\tmux := http.NewServeMux()\n\tregisterEmbedRoutes(mux)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/assets/not-found.js\", nil)\n\trr := httptest.NewRecorder()\n\tmux.ServeHTTP(rr, req)\n\n\tif rr.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"status = %d, want %d\", rr.Code, http.StatusNotFound)\n\t}\n}\n"
  },
  {
    "path": "web/backend/i18n.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n)\n\n// Language represents the supported languages\ntype Language string\n\nconst (\n\tLanguageEnglish Language = \"en\"\n\tLanguageChinese Language = \"zh\"\n)\n\n// current language (default: English)\nvar currentLang Language = LanguageEnglish\n\n// TranslationKey represents a translation key used for i18n\ntype TranslationKey string\n\nconst (\n\tAppTooltip         TranslationKey = \"AppTooltip\"\n\tMenuOpen           TranslationKey = \"MenuOpen\"\n\tMenuOpenTooltip    TranslationKey = \"MenuOpenTooltip\"\n\tMenuAbout          TranslationKey = \"MenuAbout\"\n\tMenuAboutTooltip   TranslationKey = \"MenuAboutTooltip\"\n\tMenuVersion        TranslationKey = \"MenuVersion\"\n\tMenuVersionTooltip TranslationKey = \"MenuVersionTooltip\"\n\tMenuGitHub         TranslationKey = \"MenuGitHub\"\n\tMenuDocs           TranslationKey = \"MenuDocs\"\n\tMenuRestart        TranslationKey = \"MenuRestart\"\n\tMenuRestartTooltip TranslationKey = \"MenuRestartTooltip\"\n\tMenuQuit           TranslationKey = \"MenuQuit\"\n\tMenuQuitTooltip    TranslationKey = \"MenuQuitTooltip\"\n\tExiting            TranslationKey = \"Exiting\"\n\tDocUrl             TranslationKey = \"DocUrl\"\n)\n\n// Translation tables\n// Chinese translations intentionally contain Han script\n//\n//nolint:gosmopolitan\nvar translations = map[Language]map[TranslationKey]string{\n\tLanguageEnglish: {\n\t\tAppTooltip:         \"%s - Web Console\",\n\t\tMenuOpen:           \"Open Console\",\n\t\tMenuOpenTooltip:    \"Open PicoClaw console in browser\",\n\t\tMenuAbout:          \"About\",\n\t\tMenuAboutTooltip:   \"About PicoClaw\",\n\t\tMenuVersion:        \"Version: %s\",\n\t\tMenuVersionTooltip: \"Current version number\",\n\t\tMenuGitHub:         \"GitHub\",\n\t\tMenuDocs:           \"Documentation\",\n\t\tMenuRestart:        \"Restart Service\",\n\t\tMenuRestartTooltip: \"Restart Gateway service\",\n\t\tMenuQuit:           \"Quit\",\n\t\tMenuQuitTooltip:    \"Exit PicoClaw\",\n\t\tExiting:            \"Exiting PicoClaw...\",\n\t\tDocUrl:             \"https://docs.picoclaw.io/docs/\",\n\t},\n\tLanguageChinese: {\n\t\tAppTooltip:         \"%s - Web Console\",\n\t\tMenuOpen:           \"打开控制台\",\n\t\tMenuOpenTooltip:    \"在浏览器中打开 PicoClaw 控制台\",\n\t\tMenuAbout:          \"关于\",\n\t\tMenuAboutTooltip:   \"关于 PicoClaw\",\n\t\tMenuVersion:        \"版本: %s\",\n\t\tMenuVersionTooltip: \"当前版本号\",\n\t\tMenuGitHub:         \"GitHub\",\n\t\tMenuDocs:           \"文档\",\n\t\tMenuRestart:        \"重启服务\",\n\t\tMenuRestartTooltip: \"重启核心服务\",\n\t\tMenuQuit:           \"退出\",\n\t\tMenuQuitTooltip:    \"退出 PicoClaw\",\n\t\tExiting:            \"正在退出 PicoClaw...\",\n\t\tDocUrl:             \"https://docs.picoclaw.io/zh-Hans/docs/\",\n\t},\n}\n\n// SetLanguage sets the current language\nfunc SetLanguage(lang string) {\n\tlang = strings.ToLower(strings.TrimSpace(lang))\n\n\t// Extract language code before first underscore or dot\n\t// e.g., \"en_US.UTF-8\" -> \"en\", \"zh_CN\" -> \"zh\"\n\tif idx := strings.IndexAny(lang, \"_.\"); idx > 0 {\n\t\tlang = lang[:idx]\n\t}\n\n\tif lang == \"zh\" || lang == \"zh-cn\" || lang == \"chinese\" {\n\t\tcurrentLang = LanguageChinese\n\t} else {\n\t\tcurrentLang = LanguageEnglish\n\t}\n}\n\n// GetLanguage returns the current language\nfunc GetLanguage() Language {\n\treturn currentLang\n}\n\n// T translates a key to the current language\nfunc T(key TranslationKey, args ...any) string {\n\tif trans, ok := translations[currentLang][key]; ok {\n\t\tif len(args) > 0 {\n\t\t\treturn fmt.Sprintf(trans, args...)\n\t\t}\n\t\treturn trans\n\t}\n\treturn string(key)\n}\n\n// Initialize i18n from environment variable\nfunc init() {\n\tif lang := os.Getenv(\"LANG\"); lang != \"\" {\n\t\tSetLanguage(lang)\n\t}\n}\n"
  },
  {
    "path": "web/backend/launcherconfig/config.go",
    "content": "package launcherconfig\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nconst (\n\t// FileName is the launcher-specific settings file name.\n\tFileName = \"launcher-config.json\"\n\t// DefaultPort is the default port for the web launcher.\n\tDefaultPort = 18800\n)\n\n// Config stores launch parameters for the web backend service.\ntype Config struct {\n\tPort         int      `json:\"port\"`\n\tPublic       bool     `json:\"public\"`\n\tAllowedCIDRs []string `json:\"allowed_cidrs,omitempty\"`\n}\n\n// Default returns default launcher settings.\nfunc Default() Config {\n\treturn Config{Port: DefaultPort, Public: false}\n}\n\n// Validate checks if launcher settings are valid.\nfunc Validate(cfg Config) error {\n\tif cfg.Port < 1 || cfg.Port > 65535 {\n\t\treturn fmt.Errorf(\"port %d is out of range (1-65535)\", cfg.Port)\n\t}\n\tfor _, cidr := range cfg.AllowedCIDRs {\n\t\tif _, _, err := net.ParseCIDR(cidr); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid CIDR %q\", cidr)\n\t\t}\n\t}\n\treturn nil\n}\n\n// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.\nfunc NormalizeCIDRs(cidrs []string) []string {\n\tif len(cidrs) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(cidrs))\n\tseen := make(map[string]struct{}, len(cidrs))\n\tfor _, raw := range cidrs {\n\t\ttrimmed := strings.TrimSpace(raw)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[trimmed]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmed] = struct{}{}\n\t\tout = append(out, trimmed)\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// PathForAppConfig returns launcher-config path near the app config file.\nfunc PathForAppConfig(appConfigPath string) string {\n\tdir := filepath.Dir(appConfigPath)\n\tif dir == \"\" || dir == \".\" {\n\t\tdir = \".\"\n\t}\n\treturn filepath.Join(dir, FileName)\n}\n\n// Load reads launcher settings; fallback is returned when file does not exist.\nfunc Load(path string, fallback Config) (Config, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fallback, nil\n\t\t}\n\t\treturn Config{}, err\n\t}\n\n\tcfg := fallback\n\tif err := json.Unmarshal(data, &cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\tcfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)\n\tif err := Validate(cfg); err != nil {\n\t\treturn Config{}, err\n\t}\n\treturn cfg, nil\n}\n\n// Save writes launcher settings to disk.\nfunc Save(path string, cfg Config) error {\n\tcfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)\n\tif err := Validate(cfg); err != nil {\n\t\treturn err\n\t}\n\tif err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {\n\t\treturn err\n\t}\n\tdata, err := json.MarshalIndent(cfg, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdata = append(data, '\\n')\n\treturn os.WriteFile(path, data, 0o600)\n}\n"
  },
  {
    "path": "web/backend/launcherconfig/config_test.go",
    "content": "package launcherconfig\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestLoadReturnsFallbackWhenMissing(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"launcher-config.json\")\n\tfallback := Config{Port: 19999, Public: true}\n\n\tgot, err := Load(path, fallback)\n\tif err != nil {\n\t\tt.Fatalf(\"Load() error = %v\", err)\n\t}\n\tif got.Port != fallback.Port || got.Public != fallback.Public {\n\t\tt.Fatalf(\"Load() = %+v, want %+v\", got, fallback)\n\t}\n}\n\nfunc TestSaveAndLoadRoundTrip(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"launcher-config.json\")\n\twant := Config{\n\t\tPort:         18080,\n\t\tPublic:       true,\n\t\tAllowedCIDRs: []string{\"192.168.1.0/24\", \"10.0.0.0/8\"},\n\t}\n\n\tif err := Save(path, want); err != nil {\n\t\tt.Fatalf(\"Save() error = %v\", err)\n\t}\n\tgot, err := Load(path, Default())\n\tif err != nil {\n\t\tt.Fatalf(\"Load() error = %v\", err)\n\t}\n\tif got.Port != want.Port || got.Public != want.Public {\n\t\tt.Fatalf(\"Load() = %+v, want %+v\", got, want)\n\t}\n\tif len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {\n\t\tt.Fatalf(\"allowed_cidrs len = %d, want %d\", len(got.AllowedCIDRs), len(want.AllowedCIDRs))\n\t}\n\tfor i := range want.AllowedCIDRs {\n\t\tif got.AllowedCIDRs[i] != want.AllowedCIDRs[i] {\n\t\t\tt.Fatalf(\"allowed_cidrs[%d] = %q, want %q\", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])\n\t\t}\n\t}\n\n\tstat, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatalf(\"Stat() error = %v\", err)\n\t}\n\tif perm := stat.Mode().Perm(); perm != 0o600 {\n\t\tt.Fatalf(\"file perm = %o, want 600\", perm)\n\t}\n}\n\nfunc TestValidateRejectsInvalidPort(t *testing.T) {\n\tif err := Validate(Config{Port: 0, Public: false}); err == nil {\n\t\tt.Fatal(\"Validate() expected error for port 0\")\n\t}\n\tif err := Validate(Config{Port: 65536, Public: false}); err == nil {\n\t\tt.Fatal(\"Validate() expected error for port 65536\")\n\t}\n}\n\nfunc TestValidateRejectsInvalidCIDR(t *testing.T) {\n\terr := Validate(Config{\n\t\tPort:         18800,\n\t\tAllowedCIDRs: []string{\"192.168.1.0/24\", \"not-a-cidr\"},\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"Validate() expected error for invalid CIDR\")\n\t}\n}\n\nfunc TestNormalizeCIDRs(t *testing.T) {\n\tgot := NormalizeCIDRs([]string{\" 192.168.1.0/24 \", \"\", \"10.0.0.0/8\", \"192.168.1.0/24\"})\n\twant := []string{\"192.168.1.0/24\", \"10.0.0.0/8\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"len(got) = %d, want %d\", len(got), len(want))\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"got[%d] = %q, want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "web/backend/main.go",
    "content": "// PicoClaw Web Console - Web-based chat and management interface\n//\n// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,\n// with configuration management and gateway process control.\n//\n// Usage:\n//\n//\tgo build -o picoclaw-web ./web/backend/\n//\t./picoclaw-web [config.json]\n//\t./picoclaw-web -public config.json\n\npackage main\n\nimport (\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/web/backend/api\"\n\t\"github.com/sipeed/picoclaw/web/backend/launcherconfig\"\n\t\"github.com/sipeed/picoclaw/web/backend/middleware\"\n\t\"github.com/sipeed/picoclaw/web/backend/utils\"\n)\n\nconst (\n\tappName = \"PicoClaw\"\n)\n\nvar (\n\tappVersion = config.Version\n\n\tserver     *http.Server\n\tserverAddr string\n\tapiHandler *api.Handler\n\n\tnoBrowser *bool\n)\n\nfunc main() {\n\tport := flag.String(\"port\", \"18800\", \"Port to listen on\")\n\tpublic := flag.Bool(\"public\", false, \"Listen on all interfaces (0.0.0.0) instead of localhost only\")\n\tnoBrowser = flag.Bool(\"no-browser\", false, \"Do not auto-open browser on startup\")\n\tlang := flag.String(\"lang\", \"\", \"Language: en (English) or zh (Chinese). Default: auto-detect from system locale\")\n\tconsole := flag.Bool(\"console\", false, \"Console mode, no GUI\")\n\n\tflag.Usage = func() {\n\t\tfmt.Fprintf(os.Stderr, \"PicoClaw Launcher - A web-based configuration editor\\n\\n\")\n\t\tfmt.Fprintf(os.Stderr, \"Usage: %s [options] [config.json]\\n\\n\", os.Args[0])\n\t\tfmt.Fprintf(os.Stderr, \"Arguments:\\n\")\n\t\tfmt.Fprintf(os.Stderr, \"  config.json    Path to the configuration file (default: ~/.picoclaw/config.json)\\n\\n\")\n\t\tfmt.Fprintf(os.Stderr, \"Options:\\n\")\n\t\tflag.PrintDefaults()\n\t\tfmt.Fprintf(os.Stderr, \"\\nExamples:\\n\")\n\t\tfmt.Fprintf(os.Stderr, \"  %s                          Use default config path\\n\", os.Args[0])\n\t\tfmt.Fprintf(os.Stderr, \"  %s ./config.json             Specify a config file\\n\", os.Args[0])\n\t\tfmt.Fprintf(\n\t\t\tos.Stderr,\n\t\t\t\"  %s -public ./config.json     Allow access from other devices on the network\\n\",\n\t\t\tos.Args[0],\n\t\t)\n\t}\n\tflag.Parse()\n\n\t// Initialize logger\n\tpicoHome := utils.GetPicoclawHome()\n\t// By default, detect terminal to decide console log behavior\n\t// If -console-logs flag is explicitly set, it overrides the detection\n\tenableConsole := *console\n\tif !enableConsole {\n\t\t// Disable console logging by setting level to Fatal (no output)\n\t\tlogger.SetConsoleLevel(logger.FATAL)\n\n\t\tlogPath := filepath.Join(picoHome, \"logs\", \"web.log\")\n\t\tif err := logger.EnableFileLogging(logPath); err != nil {\n\t\t\t// FIXME: https://github.com/sipeed/picoclaw/issues/1734\n\t\t\tfmt.Fprintf(os.Stderr, \"Failed to initialize logger: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tdefer logger.DisableFileLogging()\n\t}\n\n\tlogger.InfoC(\"web\", \"PicoClaw Launcher starting...\")\n\tlogger.InfoC(\"web\", fmt.Sprintf(\"PicoClaw Home: %s\", picoHome))\n\n\t// Set language from command line or auto-detect\n\tif *lang != \"\" {\n\t\tSetLanguage(*lang)\n\t}\n\n\t// Resolve config path\n\tconfigPath := utils.GetDefaultConfigPath()\n\tif flag.NArg() > 0 {\n\t\tconfigPath = flag.Arg(0)\n\t}\n\n\tabsPath, err := filepath.Abs(configPath)\n\tif err != nil {\n\t\tlogger.Fatalf(\"Failed to resolve config path: %v\", err)\n\t}\n\terr = utils.EnsureOnboarded(absPath)\n\tif err != nil {\n\t\tlogger.Errorf(\"Warning: Failed to initialize PicoClaw config automatically: %v\", err)\n\t}\n\n\tvar explicitPort bool\n\tvar explicitPublic bool\n\tflag.Visit(func(f *flag.Flag) {\n\t\tswitch f.Name {\n\t\tcase \"port\":\n\t\t\texplicitPort = true\n\t\tcase \"public\":\n\t\t\texplicitPublic = true\n\t\t}\n\t})\n\n\tlauncherPath := launcherconfig.PathForAppConfig(absPath)\n\tlauncherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())\n\tif err != nil {\n\t\tlogger.ErrorC(\"web\", fmt.Sprintf(\"Warning: Failed to load %s: %v\", launcherPath, err))\n\t\tlauncherCfg = launcherconfig.Default()\n\t}\n\n\teffectivePort := *port\n\teffectivePublic := *public\n\tif !explicitPort {\n\t\teffectivePort = strconv.Itoa(launcherCfg.Port)\n\t}\n\tif !explicitPublic {\n\t\teffectivePublic = launcherCfg.Public\n\t}\n\n\tportNum, err := strconv.Atoi(effectivePort)\n\tif err != nil || portNum < 1 || portNum > 65535 {\n\t\tif err == nil {\n\t\t\terr = errors.New(\"must be in range 1-65535\")\n\t\t}\n\t\tlogger.Fatalf(\"Invalid port %q: %v\", effectivePort, err)\n\t}\n\n\t// Determine listen address\n\tvar addr string\n\tif effectivePublic {\n\t\taddr = \"0.0.0.0:\" + effectivePort\n\t} else {\n\t\taddr = \"127.0.0.1:\" + effectivePort\n\t}\n\n\t// Initialize Server components\n\tmux := http.NewServeMux()\n\n\t// API Routes (e.g. /api/status)\n\tapiHandler = api.NewHandler(absPath)\n\tapiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)\n\tapiHandler.RegisterRoutes(mux)\n\n\t// Frontend Embedded Assets\n\tregisterEmbedRoutes(mux)\n\n\taccessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)\n\tif err != nil {\n\t\tlogger.Fatalf(\"Invalid allowed CIDR configuration: %v\", err)\n\t}\n\n\t// Apply middleware stack\n\thandler := middleware.Recoverer(\n\t\tmiddleware.Logger(\n\t\t\tmiddleware.JSONContentType(accessControlledMux),\n\t\t),\n\t)\n\n\t// Print startup banner (only in console mode)\n\tif enableConsole {\n\t\tfmt.Print(utils.Banner)\n\t\tfmt.Println()\n\t\tfmt.Println(\"  Open the following URL in your browser:\")\n\t\tfmt.Println()\n\t\tfmt.Printf(\"    >> http://localhost:%s <<\\n\", effectivePort)\n\t\tif effectivePublic {\n\t\t\tif ip := utils.GetLocalIP(); ip != \"\" {\n\t\t\t\tfmt.Printf(\"    >> http://%s:%s <<\\n\", ip, effectivePort)\n\t\t\t}\n\t\t}\n\t\tfmt.Println()\n\t}\n\n\t// Log startup info to file\n\tlogger.InfoC(\"web\", fmt.Sprintf(\"Server will listen on http://localhost:%s\", effectivePort))\n\tif effectivePublic {\n\t\tif ip := utils.GetLocalIP(); ip != \"\" {\n\t\t\tlogger.InfoC(\"web\", fmt.Sprintf(\"Public access enabled at http://%s:%s\", ip, effectivePort))\n\t\t}\n\t}\n\n\t// Share the local URL with the launcher runtime.\n\tserverAddr = fmt.Sprintf(\"http://localhost:%s\", effectivePort)\n\n\t// Auto-open browser will be handled by the launcher runtime.\n\n\t// Auto-start gateway after backend starts listening.\n\tgo func() {\n\t\ttime.Sleep(1 * time.Second)\n\t\tapiHandler.TryAutoStartGateway()\n\t}()\n\n\t// Start the Server in a goroutine\n\tserver = &http.Server{Addr: addr, Handler: handler}\n\tgo func() {\n\t\tlogger.InfoC(\"web\", fmt.Sprintf(\"Server listening on %s\", addr))\n\t\tif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\tlogger.Fatalf(\"Server failed to start: %v\", err)\n\t\t}\n\t}()\n\n\tdefer shutdownApp()\n\n\t// Start system tray or run in console mode\n\tif enableConsole {\n\t\tif !*noBrowser {\n\t\t\t// Auto-open browser after systray is ready (if not disabled)\n\t\t\t// Check no-browser flag via environment or pass as parameter if needed\n\t\t\tif err := openBrowser(); err != nil {\n\t\t\t\tlogger.Errorf(\"Warning: Failed to auto-open browser: %v\", err)\n\t\t\t}\n\t\t}\n\n\t\tsigChan := make(chan os.Signal, 1)\n\t\tsignal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)\n\n\t\t// Main event loop - wait for signals or config changes\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-sigChan:\n\t\t\t\tlogger.Info(\"Shutting down...\")\n\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// GUI mode: start system tray\n\t\trunTray()\n\t}\n}\n"
  },
  {
    "path": "web/backend/middleware/access_control.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// IPAllowlist restricts access to requests from configured CIDR ranges.\n// Loopback addresses are always allowed for local administration.\n// Empty CIDR list means no restriction.\nfunc IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {\n\tif len(allowedCIDRs) == 0 {\n\t\treturn next, nil\n\t}\n\n\tnets := make([]*net.IPNet, 0, len(allowedCIDRs))\n\tfor _, cidr := range allowedCIDRs {\n\t\t_, ipNet, err := net.ParseCIDR(cidr)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid CIDR %q: %w\", cidr, err)\n\t\t}\n\t\tnets = append(nets, ipNet)\n\t}\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tip := clientIPFromRemoteAddr(r.RemoteAddr)\n\t\tif ip == nil {\n\t\t\trejectByPolicy(w, r)\n\t\t\treturn\n\t\t}\n\t\tif ip.IsLoopback() {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\t\tfor _, ipNet := range nets {\n\t\t\tif ipNet.Contains(ip) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\trejectByPolicy(w, r)\n\t}), nil\n}\n\nfunc clientIPFromRemoteAddr(remoteAddr string) net.IP {\n\thost := remoteAddr\n\tif h, _, err := net.SplitHostPort(remoteAddr); err == nil {\n\t\thost = h\n\t}\n\treturn net.ParseIP(host)\n}\n\nfunc rejectByPolicy(w http.ResponseWriter, r *http.Request) {\n\tif strings.HasPrefix(r.URL.Path, \"/api/\") {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusForbidden)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"access denied by network policy\"}`))\n\t\treturn\n\t}\n\thttp.Error(w, \"Forbidden\", http.StatusForbidden)\n}\n"
  },
  {
    "path": "web/backend/middleware/access_control_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {\n\th, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tif err != nil {\n\t\tt.Fatalf(\"IPAllowlist() error = %v\", err)\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"203.0.113.5:1234\"\n\th.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n}\n\nfunc TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {\n\th, err := IPAllowlist([]string{\"192.168.1.0/24\"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tif err != nil {\n\t\tt.Fatalf(\"IPAllowlist() error = %v\", err)\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/api/config\", nil)\n\treq.RemoteAddr = \"10.0.0.8:1234\"\n\th.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusForbidden {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusForbidden)\n\t}\n}\n\nfunc TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {\n\th, err := IPAllowlist([]string{\"192.168.1.0/24\"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tif err != nil {\n\t\tt.Fatalf(\"IPAllowlist() error = %v\", err)\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"192.168.1.88:1234\"\n\th.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n}\n\nfunc TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {\n\th, err := IPAllowlist([]string{\"192.168.1.0/24\"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tif err != nil {\n\t\tt.Fatalf(\"IPAllowlist() error = %v\", err)\n\t}\n\n\trec := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"127.0.0.1:1234\"\n\th.ServeHTTP(rec, req)\n\n\tif rec.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", rec.Code, http.StatusOK)\n\t}\n}\n\nfunc TestIPAllowlist_InvalidCIDR(t *testing.T) {\n\t_, err := IPAllowlist([]string{\"bad-cidr\"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))\n\tif err == nil {\n\t\tt.Fatal(\"IPAllowlist() expected error for invalid CIDR\")\n\t}\n}\n"
  },
  {
    "path": "web/backend/middleware/middleware.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime/debug\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\n// JSONContentType sets the Content-Type header to application/json for\n// API requests handled by the wrapped handler.\nfunc JSONContentType(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif len(r.URL.Path) >= 5 && r.URL.Path[:5] == \"/api/\" {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t}\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// responseRecorder wraps http.ResponseWriter to capture the status code.\ntype responseRecorder struct {\n\thttp.ResponseWriter\n\tstatusCode int\n}\n\nfunc (rr *responseRecorder) WriteHeader(code int) {\n\trr.statusCode = code\n\trr.ResponseWriter.WriteHeader(code)\n}\n\n// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.\nfunc (rr *responseRecorder) Flush() {\n\tif f, ok := rr.ResponseWriter.(http.Flusher); ok {\n\t\tf.Flush()\n\t}\n}\n\n// Unwrap returns the underlying ResponseWriter so that http.ResponseController\n// and interface checks (like http.Flusher) can see through the wrapper.\nfunc (rr *responseRecorder) Unwrap() http.ResponseWriter {\n\treturn rr.ResponseWriter\n}\n\n// Logger logs each HTTP request with method, path, status code, and duration.\nfunc Logger(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tstart := time.Now()\n\t\trec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}\n\t\tnext.ServeHTTP(rec, r)\n\t\tlogger.DebugC(\"http\", fmt.Sprintf(\"%s %s %d %s\", r.Method, r.URL.Path, rec.statusCode, time.Since(start)))\n\t})\n}\n\n// Recoverer recovers from panics in downstream handlers and returns a 500\n// Internal Server Error response.\nfunc Recoverer(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tdefer func() {\n\t\t\tif err := recover(); err != nil {\n\t\t\t\tlogger.ErrorC(\"http\", fmt.Sprintf(\"panic recovered: %v\\n%s\", err, debug.Stack()))\n\t\t\t\thttp.Error(w, `{\"error\":\"internal server error\"}`, http.StatusInternalServerError)\n\t\t\t}\n\t\t}()\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n"
  },
  {
    "path": "web/backend/model/status.go",
    "content": "package model\n\n// StatusResponse represents the response payload for the GET /api/status endpoint.\ntype StatusResponse struct {\n\tStatus  string `json:\"status\"`\n\tVersion string `json:\"version\"`\n\tUptime  string `json:\"uptime\"`\n}\n"
  },
  {
    "path": "web/backend/systray.go",
    "content": "//go:build (!darwin && !freebsd) || cgo\n\npackage main\n\nimport (\n\t_ \"embed\"\n\t\"fmt\"\n\n\t\"fyne.io/systray\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n\t\"github.com/sipeed/picoclaw/web/backend/utils\"\n)\n\nfunc runTray() {\n\tsystray.Run(onReady, onExit)\n}\n\n// onReady is called when the system tray is ready\nfunc onReady() {\n\t// Set icon and tooltip\n\tsystray.SetIcon(getIcon())\n\tsystray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName))\n\n\t// Create menu items\n\tmOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))\n\tmAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))\n\n\t// Add version info under About menu\n\tmVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip))\n\tmVersion.Disable()\n\tmRepo := mAbout.AddSubMenuItem(T(MenuGitHub), \"\")\n\tmDocs := mAbout.AddSubMenuItem(T(MenuDocs), \"\")\n\n\tsystray.AddSeparator()\n\n\t// Add restart option\n\tmRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip))\n\n\tsystray.AddSeparator()\n\n\t// Quit option\n\tmQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip))\n\n\t// Handle menu clicks\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-mOpen.ClickedCh:\n\t\t\t\tif err := openBrowser(); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Failed to open browser: %v\", err)\n\t\t\t\t}\n\n\t\t\tcase <-mVersion.ClickedCh:\n\t\t\t\t// Version info - do nothing, just shows current version\n\n\t\t\tcase <-mRepo.ClickedCh:\n\t\t\t\tif err := utils.OpenBrowser(\"https://github.com/sipeed/picoclaw\"); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Failed to open GitHub: %v\", err)\n\t\t\t\t}\n\n\t\t\tcase <-mDocs.ClickedCh:\n\t\t\t\tif err := utils.OpenBrowser(T(DocUrl)); err != nil {\n\t\t\t\t\tlogger.Errorf(\"Failed to open docs: %v\", err)\n\t\t\t\t}\n\n\t\t\tcase <-mRestart.ClickedCh:\n\t\t\t\tfmt.Println(\"Restart request received...\")\n\t\t\t\tif apiHandler != nil {\n\t\t\t\t\tif pid, err := apiHandler.RestartGateway(); err != nil {\n\t\t\t\t\t\tlogger.Errorf(\"Failed to restart gateway: %v\", err)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.Infof(\"Gateway restarted (PID: %d)\", pid)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase <-mQuit.ClickedCh:\n\t\t\t\tsystray.Quit()\n\t\t\t}\n\t\t}\n\t}()\n\n\tif !*noBrowser {\n\t\t// Auto-open browser after systray is ready (if not disabled)\n\t\t// Check no-browser flag via environment or pass as parameter if needed\n\t\tif err := openBrowser(); err != nil {\n\t\t\tlogger.Errorf(\"Warning: Failed to auto-open browser: %v\", err)\n\t\t}\n\t}\n}\n\n// onExit is called when the system tray is exiting\nfunc onExit() {\n\tlogger.Info(T(Exiting))\n}\n\n// getIcon returns the system tray icon\nfunc getIcon() []byte {\n\treturn iconData\n}\n"
  },
  {
    "path": "web/backend/systray_unix.go",
    "content": "//go:build !windows\n\npackage main\n\nimport _ \"embed\"\n\n//go:embed icon.png\nvar iconData []byte\n"
  },
  {
    "path": "web/backend/systray_windows.go",
    "content": "//go:build windows\n\npackage main\n\nimport _ \"embed\"\n\n//go:embed icon.ico\nvar iconData []byte\n"
  },
  {
    "path": "web/backend/tray_stub_nocgo.go",
    "content": "//go:build (darwin || freebsd) && !cgo\n\npackage main\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/sipeed/picoclaw/pkg/logger\"\n)\n\nfunc runTray() {\n\tlogger.Infof(\"System tray is unavailable in %s builds without cgo; running without tray\", runtime.GOOS)\n\n\tif !*noBrowser {\n\t\tgo func() {\n\t\t\ttime.Sleep(browserDelay)\n\t\t\tif err := openBrowser(); err != nil {\n\t\t\t\tlogger.Errorf(\"Warning: Failed to auto-open browser: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\t<-ctx.Done()\n\tshutdownApp()\n}\n"
  },
  {
    "path": "web/backend/utils/banner.go",
    "content": "package utils\n\nconst (\n\tcolorBlue  = \"\\x1b[38;2;62;93;185m\"\n\tcolorRed   = \"\\x1b[38;2;213;70;70m\"\n\tcolorReset = \"\\x1b[0m\"\n\tBanner     = \"\\r\\n\" +\n\t\tcolorBlue + \"██████╗ ██╗ ██████╗ ██████╗ \" + colorRed + \" ██████╗██╗      █████╗ ██╗    ██╗\\n\" +\n\t\tcolorBlue + \"██╔══██╗██║██╔════╝██╔═══██╗\" + colorRed + \"██╔════╝██║     ██╔══██╗██║    ██║\\n\" +\n\t\tcolorBlue + \"██████╔╝██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ███████║██║ █╗ ██║\\n\" +\n\t\tcolorBlue + \"██╔═══╝ ██║██║     ██║   ██║\" + colorRed + \"██║     ██║     ██╔══██║██║███╗██║\\n\" +\n\t\tcolorBlue + \"██║     ██║╚██████╗╚██████╔╝\" + colorRed + \"╚██████╗███████╗██║  ██║╚███╔███╔╝\\n\" +\n\t\tcolorBlue + \"╚═╝     ╚═╝ ╚═════╝ ╚═════╝ \" + colorRed + \" ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝\\n\" +\n\t\tcolorReset\n)\n"
  },
  {
    "path": "web/backend/utils/onboard.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\nvar execCommand = exec.Command\n\nfunc EnsureOnboarded(configPath string) error {\n\t_, err := os.Stat(configPath)\n\tif err == nil {\n\t\treturn nil\n\t}\n\tif !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"stat config: %w\", err)\n\t}\n\n\tcmd := execCommand(FindPicoclawBinary(), \"onboard\")\n\tcmd.Env = append(os.Environ(), config.EnvConfig+\"=\"+configPath)\n\tcmd.Stdin = strings.NewReader(\"n\\n\")\n\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\ttrimmed := strings.TrimSpace(string(output))\n\t\tif trimmed == \"\" {\n\t\t\treturn fmt.Errorf(\"run onboard: %w\", err)\n\t\t}\n\t\treturn fmt.Errorf(\"run onboard: %w: %s\", err, trimmed)\n\t}\n\n\tif _, err := os.Stat(configPath); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn fmt.Errorf(\"onboard completed but did not create config %s\", configPath)\n\t\t}\n\t\treturn fmt.Errorf(\"verify config after onboard: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "web/backend/utils/onboard_test.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {\n\t\tt.Fatalf(\"WriteFile() error = %v\", err)\n\t}\n\n\torigExecCommand := execCommand\n\tdefer func() { execCommand = origExecCommand }()\n\n\tcalled := false\n\texecCommand = func(name string, args ...string) *exec.Cmd {\n\t\tcalled = true\n\t\treturn exec.Command(\"sh\", \"-c\", \"exit 1\")\n\t}\n\n\tif err := EnsureOnboarded(configPath); err != nil {\n\t\tt.Fatalf(\"EnsureOnboarded() error = %v\", err)\n\t}\n\tif called {\n\t\tt.Fatal(\"expected onboard command not to run when config already exists\")\n\t}\n}\n\nfunc TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\tt.Setenv(\"EXPECTED_CONFIG_PATH\", configPath)\n\n\torigExecCommand := execCommand\n\tdefer func() { execCommand = origExecCommand }()\n\n\tvar gotName string\n\tvar gotArgs []string\n\texecCommand = func(name string, args ...string) *exec.Cmd {\n\t\tgotName = name\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn exec.Command(\n\t\t\t\"sh\",\n\t\t\t\"-c\",\n\t\t\t`test \"$PICOCLAW_CONFIG\" = \"$EXPECTED_CONFIG_PATH\" &&\nmkdir -p \"$(dirname \"$PICOCLAW_CONFIG\")\" &&\nprintf '{}' > \"$PICOCLAW_CONFIG\"`,\n\t\t)\n\t}\n\n\tif err := EnsureOnboarded(configPath); err != nil {\n\t\tt.Fatalf(\"EnsureOnboarded() error = %v\", err)\n\t}\n\tif gotName == \"\" {\n\t\tt.Fatal(\"expected onboard command to run\")\n\t}\n\tif len(gotArgs) != 1 || gotArgs[0] != \"onboard\" {\n\t\tt.Fatalf(\"command args = %#v, want []string{\\\"onboard\\\"}\", gotArgs)\n\t}\n\tif _, err := os.Stat(configPath); err != nil {\n\t\tt.Fatalf(\"expected config to be created: %v\", err)\n\t}\n}\n\nfunc TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\n\torigExecCommand := execCommand\n\tdefer func() { execCommand = origExecCommand }()\n\n\texecCommand = func(name string, args ...string) *exec.Cmd {\n\t\treturn exec.Command(\"sh\", \"-c\", \"exit 0\")\n\t}\n\n\tif err := EnsureOnboarded(configPath); err == nil {\n\t\tt.Fatal(\"EnsureOnboarded() error = nil, want failure when onboard does not create config\")\n\t}\n}\n\nfunc TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) {\n\tconfigPath := filepath.Join(t.TempDir(), \"config.json\")\n\n\torigExecCommand := execCommand\n\tdefer func() { execCommand = origExecCommand }()\n\n\texecCommand = func(name string, args ...string) *exec.Cmd {\n\t\treturn exec.Command(\"sh\", \"-c\", \"echo onboarding failed >&2; exit 2\")\n\t}\n\n\terr := EnsureOnboarded(configPath)\n\tif err == nil {\n\t\tt.Fatal(\"EnsureOnboarded() error = nil, want failure\")\n\t}\n\tif !strings.Contains(err.Error(), \"onboarding failed\") {\n\t\tt.Fatalf(\"error = %q, want onboard output included\", err)\n\t}\n}\n"
  },
  {
    "path": "web/backend/utils/runtime.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\n\t\"github.com/sipeed/picoclaw/pkg/config\"\n)\n\n// GetPicoclawHome returns the picoclaw home directory.\n// Priority: $PICOCLAW_HOME > ~/.picoclaw\nfunc GetPicoclawHome() string {\n\tif home := os.Getenv(config.EnvHome); home != \"\" {\n\t\treturn home\n\t}\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".picoclaw\")\n}\n\n// GetDefaultConfigPath returns the default path to the picoclaw config file.\nfunc GetDefaultConfigPath() string {\n\tif configPath := os.Getenv(config.EnvConfig); configPath != \"\" {\n\t\treturn configPath\n\t}\n\treturn filepath.Join(GetPicoclawHome(), \"config.json\")\n}\n\n// FindPicoclawBinary locates the picoclaw executable.\n// Search order:\n//  1. PICOCLAW_BINARY environment variable (explicit override)\n//  2. Same directory as the current executable\n//  3. Falls back to \"picoclaw\" and relies on $PATH\nfunc FindPicoclawBinary() string {\n\tbinaryName := \"picoclaw\"\n\tif runtime.GOOS == \"windows\" {\n\t\tbinaryName = \"picoclaw.exe\"\n\t}\n\n\tif p := os.Getenv(config.EnvBinary); p != \"\" {\n\t\tif info, _ := os.Stat(p); info != nil && !info.IsDir() {\n\t\t\treturn p\n\t\t}\n\t}\n\n\tif exe, err := os.Executable(); err == nil {\n\t\tcandidate := filepath.Join(filepath.Dir(exe), binaryName)\n\t\tif info, err := os.Stat(candidate); err == nil && !info.IsDir() {\n\t\t\treturn candidate\n\t\t}\n\t}\n\n\treturn \"picoclaw\"\n}\n\n// GetLocalIP returns the local IP address of the machine.\nfunc GetLocalIP() string {\n\taddrs, err := net.InterfaceAddrs()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tfor _, a := range addrs {\n\t\tif ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {\n\t\t\treturn ipnet.IP.String()\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// OpenBrowser automatically opens the given URL in the default browser.\nfunc OpenBrowser(url string) error {\n\tswitch runtime.GOOS {\n\tcase \"linux\":\n\t\treturn exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\treturn exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url).Start()\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", url).Start()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported platform\")\n\t}\n}\n"
  },
  {
    "path": "web/backend/winres/winres.json",
    "content": "{\n  \"RT_GROUP_ICON\": {\n    \"APP\": {\n      \"0000\": \"../icon.ico\"\n    }\n  },\n  \"RT_MANIFEST\": {\n    \"#1\": {\n      \"0409\": {\n        \"identity\": {\n          \"name\": \"PicoClaw Launcher\",\n          \"version\": \"0.0.0.0\"\n        },\n        \"description\": \"PicoClaw Launcher - Web-based configuration editor\",\n        \"minimum-os\": \"win7\",\n        \"execution-level\": \"asInvoker\",\n        \"dpi-awareness\": \"system\",\n        \"use-common-controls-v6\": true\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "web/frontend/.editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf"
  },
  {
    "path": "web/frontend/.gitignore",
    "content": "# Logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n.tanstack\n"
  },
  {
    "path": "web/frontend/.prettierignore",
    "content": "package-lock.json\npnpm-lock.yaml\nyarn.lock\nrouteTree.gen.ts\nsrc/components/ui"
  },
  {
    "path": "web/frontend/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"radix-vega\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"tabler\",\n  \"rtl\": false,\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"menuColor\": \"default\",\n  \"menuAccent\": \"subtle\",\n  \"registries\": {}\n}\n"
  },
  {
    "path": "web/frontend/eslint.config.js",
    "content": "import js from \"@eslint/js\"\nimport eslintConfigPrettier from \"eslint-config-prettier\"\nimport reactHooks from \"eslint-plugin-react-hooks\"\nimport reactRefresh from \"eslint-plugin-react-refresh\"\nimport { defineConfig, globalIgnores } from \"eslint/config\"\nimport globals from \"globals\"\nimport tseslint from \"typescript-eslint\"\n\nexport default defineConfig([\n  globalIgnores([\"dist\", \"src/components/ui\", \"src/routeTree.gen.ts\"]),\n  {\n    files: [\"**/*.{ts,tsx}\"],\n    extends: [\n      js.configs.recommended,\n      tseslint.configs.recommended,\n      reactHooks.configs.flat.recommended,\n      reactRefresh.configs.vite,\n      eslintConfigPrettier,\n    ],\n    languageOptions: {\n      ecmaVersion: \"latest\",\n      globals: globals.browser,\n    },\n    rules: {\n      \"react-refresh/only-export-components\": [\n        \"warn\",\n        { allowConstantExport: true },\n      ],\n    },\n  },\n])\n"
  },
  {
    "path": "web/frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/favicon-96x96.png\" sizes=\"96x96\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <link rel=\"shortcut icon\" href=\"/favicon.ico\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\" />\n    <link rel=\"manifest\" href=\"/site.webmanifest\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>PicoClaw</title>\n  </head>\n\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/frontend/package.json",
    "content": "{\n  \"name\": \"picoclaw-web\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"build:backend\": \"tsc -b && vite build --outDir ../backend/dist --emptyOutDir && node ./scripts/ensure-backend-gitkeep.cjs\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\",\n    \"format\": \"prettier --check .\",\n    \"check\": \"prettier --write . && eslint --fix\"\n  },\n  \"dependencies\": {\n    \"@fontsource-variable/inter\": \"^5.2.8\",\n    \"@tabler/icons-react\": \"^3.40.0\",\n    \"@tailwindcss/vite\": \"^4.2.2\",\n    \"@tanstack/react-query\": \"^5.90.21\",\n    \"@tanstack/react-router\": \"^1.167.0\",\n    \"@tanstack/react-router-devtools\": \"^1.163.3\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"dayjs\": \"^1.11.20\",\n    \"i18next\": \"^25.8.14\",\n    \"i18next-browser-languagedetector\": \"^8.2.1\",\n    \"jotai\": \"^2.18.1\",\n    \"radix-ui\": \"^1.4.3\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-i18next\": \"^16.5.8\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-textarea-autosize\": \"^8.5.9\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"shadcn\": \"^4.1.0\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwindcss\": \"^4.2.2\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"wrap-ansi\": \"^10.0.0\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.4\",\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"@tanstack/router-plugin\": \"^1.164.0\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^6.0.2\",\n    \"@types/node\": \"^25.5.0\",\n    \"@types/react\": \"^19.2.7\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.57.1\",\n    \"@vitejs/plugin-react\": \"^5.2.0\",\n    \"eslint\": \"^9.39.4\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.4.26\",\n    \"globals\": \"^16.5.0\",\n    \"prettier\": \"^3.8.1\",\n    \"prettier-plugin-tailwindcss\": \"^0.7.2\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.57.1\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "web/frontend/prettier.config.js",
    "content": "//  @ts-check\n\n/** @type {import('prettier').Config} */\nconst config = {\n  semi: false,\n  printWidth: 80,\n  tabWidth: 2,\n  importOrder: [\"<BUILTIN_MODULES>\", \"<THIRD_PARTY_MODULES>\", \"^@/\", \"^[./]\"],\n  importOrderSeparation: true,\n  importOrderSortSpecifiers: true,\n  plugins: [\n    \"@trivago/prettier-plugin-sort-imports\",\n    \"prettier-plugin-tailwindcss\",\n  ],\n}\n\nexport default config\n"
  },
  {
    "path": "web/frontend/public/site.webmanifest",
    "content": "{\n  \"name\": \"MyWebSite\",\n  \"short_name\": \"MySite\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "web/frontend/scripts/ensure-backend-gitkeep.cjs",
    "content": "const fs = require(\"node:fs\")\nconst path = require(\"node:path\")\n\nconst gitkeepPath = path.resolve(__dirname, \"../../backend/dist/.gitkeep\")\nconst gitkeepContents =\n  \"# Keep the embedded web backend dist directory in version control.\\n\"\n\nfs.mkdirSync(path.dirname(gitkeepPath), { recursive: true })\nfs.writeFileSync(gitkeepPath, gitkeepContents)\n"
  },
  {
    "path": "web/frontend/src/api/channels.ts",
    "content": "// API client for channels navigation and channel-specific config flows.\n\nexport type ChannelConfig = Record<string, unknown>\nexport type AppConfig = Record<string, unknown>\n\nexport interface SupportedChannel {\n  name: string\n  display_name?: string\n  config_key: string\n  variant?: string\n}\n\ninterface ChannelsCatalogResponse {\n  channels: SupportedChannel[]\n}\n\ninterface ConfigActionResponse {\n  status: string\n  errors?: string[]\n}\n\nconst BASE_URL = \"\"\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(`${BASE_URL}${path}`, options)\n  if (!res.ok) {\n    let message = `API error: ${res.status} ${res.statusText}`\n    try {\n      const body = (await res.json()) as {\n        error?: string\n        errors?: string[]\n        status?: string\n      }\n      if (Array.isArray(body.errors) && body.errors.length > 0) {\n        message = body.errors.join(\"; \")\n      } else if (typeof body.error === \"string\" && body.error.trim() !== \"\") {\n        message = body.error\n      }\n    } catch {\n      // Keep default fallback message if response body is not JSON.\n    }\n    throw new Error(message)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getChannelsCatalog(): Promise<ChannelsCatalogResponse> {\n  return request<ChannelsCatalogResponse>(\"/api/channels/catalog\")\n}\n\nexport async function getAppConfig(): Promise<AppConfig> {\n  return request<AppConfig>(\"/api/config\")\n}\n\nexport async function patchAppConfig(\n  patch: Record<string, unknown>,\n): Promise<ConfigActionResponse> {\n  return request<ConfigActionResponse>(\"/api/config\", {\n    method: \"PATCH\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(patch),\n  })\n}\n\nexport type { ChannelsCatalogResponse, ConfigActionResponse }\n"
  },
  {
    "path": "web/frontend/src/api/gateway.ts",
    "content": "// API client for gateway process management.\n\ninterface GatewayStatusResponse {\n  gateway_status: \"running\" | \"starting\" | \"restarting\" | \"stopped\" | \"error\"\n  gateway_start_allowed?: boolean\n  gateway_start_reason?: string\n  gateway_restart_required?: boolean\n  pid?: number\n  boot_default_model?: string\n  config_default_model?: string\n  [key: string]: unknown\n}\n\ninterface GatewayLogsResponse {\n  logs?: string[]\n  log_total?: number\n  log_run_id?: number\n}\n\ninterface GatewayActionResponse {\n  status: string\n  pid?: number\n  log_total?: number\n  log_run_id?: number\n}\n\nconst BASE_URL = \"\"\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(`${BASE_URL}${path}`, options)\n  if (!res.ok) {\n    throw new Error(`API error: ${res.status} ${res.statusText}`)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getGatewayStatus(): Promise<GatewayStatusResponse> {\n  return request<GatewayStatusResponse>(\"/api/gateway/status\")\n}\n\nexport async function getGatewayLogs(options?: {\n  log_offset?: number\n  log_run_id?: number\n}): Promise<GatewayLogsResponse> {\n  const params = new URLSearchParams()\n  if (options?.log_offset !== undefined) {\n    params.set(\"log_offset\", options.log_offset.toString())\n  }\n  if (options?.log_run_id !== undefined) {\n    params.set(\"log_run_id\", options.log_run_id.toString())\n  }\n  const queryString = params.toString() ? `?${params.toString()}` : \"\"\n  return request<GatewayLogsResponse>(`/api/gateway/logs${queryString}`)\n}\n\nexport async function startGateway(): Promise<GatewayActionResponse> {\n  return request<GatewayActionResponse>(\"/api/gateway/start\", {\n    method: \"POST\",\n  })\n}\n\nexport async function stopGateway(): Promise<GatewayActionResponse> {\n  return request<GatewayActionResponse>(\"/api/gateway/stop\", {\n    method: \"POST\",\n  })\n}\n\nexport async function restartGateway(): Promise<GatewayActionResponse> {\n  return request<GatewayActionResponse>(\"/api/gateway/restart\", {\n    method: \"POST\",\n  })\n}\n\nexport async function clearGatewayLogs(): Promise<GatewayActionResponse> {\n  return request<GatewayActionResponse>(\"/api/gateway/logs/clear\", {\n    method: \"POST\",\n  })\n}\n\nexport type {\n  GatewayStatusResponse,\n  GatewayLogsResponse,\n  GatewayActionResponse,\n}\n"
  },
  {
    "path": "web/frontend/src/api/models.ts",
    "content": "import { refreshGatewayState } from \"@/store/gateway\"\n\n// API client for model list management.\n\nexport interface ModelInfo {\n  index: number\n  model_name: string\n  model: string\n  api_base?: string\n  api_key: string\n  proxy?: string\n  auth_method?: string\n  // Advanced fields\n  connect_mode?: string\n  workspace?: string\n  rpm?: number\n  max_tokens_field?: string\n  request_timeout?: number\n  thinking_level?: string\n  // Meta\n  configured: boolean\n  is_default: boolean\n}\n\ninterface ModelsListResponse {\n  models: ModelInfo[]\n  total: number\n  default_model: string\n}\n\ninterface ModelActionResponse {\n  status: string\n  index?: number\n  default_model?: string\n}\n\nconst BASE_URL = \"\"\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(`${BASE_URL}${path}`, options)\n  if (!res.ok) {\n    throw new Error(`API error: ${res.status} ${res.statusText}`)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getModels(): Promise<ModelsListResponse> {\n  return request<ModelsListResponse>(\"/api/models\")\n}\n\nexport async function addModel(\n  model: Partial<ModelInfo>,\n): Promise<ModelActionResponse> {\n  return request<ModelActionResponse>(\"/api/models\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(model),\n  })\n}\n\nexport async function updateModel(\n  index: number,\n  model: Partial<ModelInfo>,\n): Promise<ModelActionResponse> {\n  return request<ModelActionResponse>(`/api/models/${index}`, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(model),\n  })\n}\n\nexport async function deleteModel(index: number): Promise<ModelActionResponse> {\n  return request<ModelActionResponse>(`/api/models/${index}`, {\n    method: \"DELETE\",\n  })\n}\n\nexport async function setDefaultModel(\n  modelName: string,\n): Promise<ModelActionResponse> {\n  const response = await request<ModelActionResponse>(\"/api/models/default\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ model_name: modelName }),\n  })\n\n  await refreshGatewayState()\n  return response\n}\n\nexport type { ModelsListResponse, ModelActionResponse }\n"
  },
  {
    "path": "web/frontend/src/api/oauth.ts",
    "content": "export type OAuthProvider = \"openai\" | \"anthropic\" | \"google-antigravity\"\nexport type OAuthMethod = \"browser\" | \"device_code\" | \"token\"\n\nexport interface OAuthProviderStatus {\n  provider: OAuthProvider\n  display_name: string\n  methods: OAuthMethod[]\n  logged_in: boolean\n  status: \"connected\" | \"expired\" | \"needs_refresh\" | \"not_logged_in\"\n  auth_method?: string\n  expires_at?: string\n  account_id?: string\n  email?: string\n  project_id?: string\n}\n\nexport interface OAuthFlowState {\n  flow_id: string\n  provider: OAuthProvider\n  method: OAuthMethod\n  status: \"pending\" | \"success\" | \"error\" | \"expired\"\n  expires_at?: string\n  error?: string\n  user_code?: string\n  verify_url?: string\n  interval?: number\n}\n\nexport interface OAuthLoginRequest {\n  provider: OAuthProvider\n  method: OAuthMethod\n  token?: string\n}\n\nexport interface OAuthLoginResponse {\n  status: string\n  provider: OAuthProvider\n  method: OAuthMethod\n  flow_id?: string\n  auth_url?: string\n  user_code?: string\n  verify_url?: string\n  interval?: number\n  expires_at?: string\n}\n\ninterface OAuthProvidersResponse {\n  providers: OAuthProviderStatus[]\n}\n\nconst BASE_URL = \"\"\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(`${BASE_URL}${path}`, options)\n  if (!res.ok) {\n    const message = await res.text()\n    throw new Error(message || `API error: ${res.status} ${res.statusText}`)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getOAuthProviders(): Promise<OAuthProvidersResponse> {\n  return request<OAuthProvidersResponse>(\"/api/oauth/providers\")\n}\n\nexport async function loginOAuth(\n  payload: OAuthLoginRequest,\n): Promise<OAuthLoginResponse> {\n  return request<OAuthLoginResponse>(\"/api/oauth/login\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload),\n  })\n}\n\nexport async function getOAuthFlow(flowID: string): Promise<OAuthFlowState> {\n  return request<OAuthFlowState>(\n    `/api/oauth/flows/${encodeURIComponent(flowID)}`,\n  )\n}\n\nexport async function pollOAuthFlow(flowID: string): Promise<OAuthFlowState> {\n  return request<OAuthFlowState>(\n    `/api/oauth/flows/${encodeURIComponent(flowID)}/poll`,\n    {\n      method: \"POST\",\n    },\n  )\n}\n\nexport async function logoutOAuth(\n  provider: OAuthProvider,\n): Promise<{ status: string; provider: OAuthProvider }> {\n  return request<{ status: string; provider: OAuthProvider }>(\n    \"/api/oauth/logout\",\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ provider }),\n    },\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/api/pico.ts",
    "content": "// API client for Pico Channel configuration.\n\ninterface PicoTokenResponse {\n  token: string\n  ws_url: string\n  enabled: boolean\n}\n\ninterface PicoSetupResponse {\n  token: string\n  ws_url: string\n  enabled: boolean\n  changed: boolean\n}\n\nconst BASE_URL = \"\"\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(`${BASE_URL}${path}`, options)\n  if (!res.ok) {\n    throw new Error(`API error: ${res.status} ${res.statusText}`)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getPicoToken(): Promise<PicoTokenResponse> {\n  return request<PicoTokenResponse>(\"/api/pico/token\")\n}\n\nexport async function regenPicoToken(): Promise<PicoTokenResponse> {\n  return request<PicoTokenResponse>(\"/api/pico/token\", { method: \"POST\" })\n}\n\nexport async function setupPico(): Promise<PicoSetupResponse> {\n  return request<PicoSetupResponse>(\"/api/pico/setup\", { method: \"POST\" })\n}\n\nexport type { PicoTokenResponse, PicoSetupResponse }\n"
  },
  {
    "path": "web/frontend/src/api/sessions.ts",
    "content": "// Sessions API — list and retrieve chat session history\n\nexport interface SessionSummary {\n  id: string\n  title: string\n  preview: string\n  message_count: number\n  created: string\n  updated: string\n}\n\nexport interface SessionDetail {\n  id: string\n  messages: { role: \"user\" | \"assistant\"; content: string }[]\n  summary: string\n  created: string\n  updated: string\n}\n\nexport async function getSessions(\n  offset: number = 0,\n  limit: number = 20,\n): Promise<SessionSummary[]> {\n  const params = new URLSearchParams({\n    offset: offset.toString(),\n    limit: limit.toString(),\n  })\n\n  const res = await fetch(`/api/sessions?${params.toString()}`)\n  if (!res.ok) {\n    throw new Error(`Failed to fetch sessions: ${res.status}`)\n  }\n  return res.json()\n}\n\nexport async function getSessionHistory(id: string): Promise<SessionDetail> {\n  const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)\n  if (!res.ok) {\n    throw new Error(`Failed to fetch session ${id}: ${res.status}`)\n  }\n  return res.json()\n}\n\nexport async function deleteSession(id: string): Promise<void> {\n  const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {\n    method: \"DELETE\",\n  })\n  if (!res.ok) {\n    throw new Error(`Failed to delete session ${id}: ${res.status}`)\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/api/skills.ts",
    "content": "export interface SkillSupportItem {\n  name: string\n  path: string\n  source: \"workspace\" | \"global\" | \"builtin\" | string\n  description: string\n}\n\nexport interface SkillDetailResponse extends SkillSupportItem {\n  content: string\n}\n\ninterface SkillsResponse {\n  skills: SkillSupportItem[]\n}\n\ninterface SkillActionResponse {\n  status?: string\n  name?: string\n  path?: string\n  source?: string\n  description?: string\n}\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(path, options)\n  if (!res.ok) {\n    throw new Error(await extractErrorMessage(res))\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getSkills(): Promise<SkillsResponse> {\n  return request<SkillsResponse>(\"/api/skills\")\n}\n\nexport async function getSkill(name: string): Promise<SkillDetailResponse> {\n  return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)\n}\n\nexport async function importSkill(file: File): Promise<SkillActionResponse> {\n  const formData = new FormData()\n  formData.set(\"file\", file)\n\n  const res = await fetch(\"/api/skills/import\", {\n    method: \"POST\",\n    body: formData,\n  })\n  if (!res.ok) {\n    throw new Error(await extractErrorMessage(res))\n  }\n  return res.json() as Promise<SkillActionResponse>\n}\n\nexport async function deleteSkill(name: string): Promise<SkillActionResponse> {\n  return request<SkillActionResponse>(\n    `/api/skills/${encodeURIComponent(name)}`,\n    {\n      method: \"DELETE\",\n    },\n  )\n}\n\nasync function extractErrorMessage(res: Response): Promise<string> {\n  try {\n    const body = (await res.json()) as {\n      error?: string\n      errors?: string[]\n    }\n    if (Array.isArray(body.errors) && body.errors.length > 0) {\n      return body.errors.join(\"; \")\n    }\n    if (typeof body.error === \"string\" && body.error.trim() !== \"\") {\n      return body.error\n    }\n  } catch {\n    // ignore invalid body\n  }\n  return `API error: ${res.status} ${res.statusText}`\n}\n"
  },
  {
    "path": "web/frontend/src/api/system.ts",
    "content": "export interface AutoStartStatus {\n  enabled: boolean\n  supported: boolean\n  platform: string\n  message?: string\n}\n\nexport interface LauncherConfig {\n  port: number\n  public: boolean\n  allowed_cidrs: string[]\n}\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(path, options)\n  if (!res.ok) {\n    let message = `API error: ${res.status} ${res.statusText}`\n    try {\n      const body = (await res.json()) as {\n        error?: string\n        errors?: string[]\n      }\n      if (Array.isArray(body.errors) && body.errors.length > 0) {\n        message = body.errors.join(\"; \")\n      } else if (typeof body.error === \"string\" && body.error.trim() !== \"\") {\n        message = body.error\n      }\n    } catch {\n      // Keep fallback error message when response body is not JSON.\n    }\n    throw new Error(message)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getAutoStartStatus(): Promise<AutoStartStatus> {\n  return request<AutoStartStatus>(\"/api/system/autostart\")\n}\n\nexport async function setAutoStartEnabled(\n  enabled: boolean,\n): Promise<AutoStartStatus> {\n  return request<AutoStartStatus>(\"/api/system/autostart\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ enabled }),\n  })\n}\n\nexport async function getLauncherConfig(): Promise<LauncherConfig> {\n  return request<LauncherConfig>(\"/api/system/launcher-config\")\n}\n\nexport async function setLauncherConfig(\n  payload: LauncherConfig,\n): Promise<LauncherConfig> {\n  return request<LauncherConfig>(\"/api/system/launcher-config\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload),\n  })\n}\n"
  },
  {
    "path": "web/frontend/src/api/tools.ts",
    "content": "export interface ToolSupportItem {\n  name: string\n  description: string\n  category: string\n  config_key: string\n  status: \"enabled\" | \"disabled\" | \"blocked\"\n  reason_code?: string\n}\n\ninterface ToolsResponse {\n  tools: ToolSupportItem[]\n}\n\ninterface ToolActionResponse {\n  status: string\n}\n\nasync function request<T>(path: string, options?: RequestInit): Promise<T> {\n  const res = await fetch(path, options)\n  if (!res.ok) {\n    let message = `API error: ${res.status} ${res.statusText}`\n    try {\n      const body = (await res.json()) as {\n        error?: string\n        errors?: string[]\n      }\n      if (Array.isArray(body.errors) && body.errors.length > 0) {\n        message = body.errors.join(\"; \")\n      } else if (typeof body.error === \"string\" && body.error.trim() !== \"\") {\n        message = body.error\n      }\n    } catch {\n      // ignore invalid body\n    }\n    throw new Error(message)\n  }\n  return res.json() as Promise<T>\n}\n\nexport async function getTools(): Promise<ToolsResponse> {\n  return request<ToolsResponse>(\"/api/tools\")\n}\n\nexport async function setToolEnabled(\n  name: string,\n  enabled: boolean,\n): Promise<ToolActionResponse> {\n  return request<ToolActionResponse>(\n    `/api/tools/${encodeURIComponent(name)}/state`,\n    {\n      method: \"PUT\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ enabled }),\n    },\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/app-header.tsx",
    "content": "import {\n  IconBook,\n  IconLanguage,\n  IconLoader2,\n  IconMenu2,\n  IconMoon,\n  IconPlayerPlay,\n  IconPower,\n  IconRefresh,\n  IconSun,\n} from \"@tabler/icons-react\"\nimport { Link } from \"@tanstack/react-router\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog.tsx\"\nimport { Button } from \"@/components/ui/button.tsx\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu.tsx\"\nimport { Separator } from \"@/components/ui/separator.tsx\"\nimport { SidebarTrigger } from \"@/components/ui/sidebar\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { useGateway } from \"@/hooks/use-gateway.ts\"\nimport { useTheme } from \"@/hooks/use-theme.ts\"\n\nexport function AppHeader() {\n  const { i18n, t } = useTranslation()\n  const { theme, toggleTheme } = useTheme()\n  const {\n    state: gwState,\n    loading: gwLoading,\n    canStart,\n    restartRequired,\n    start,\n    restart,\n    stop,\n  } = useGateway()\n\n  const isRunning = gwState === \"running\"\n  const isStarting = gwState === \"starting\"\n  const isRestarting = gwState === \"restarting\"\n  const isStopping = gwState === \"stopping\"\n  const isStopped = gwState === \"stopped\" || gwState === \"unknown\"\n  const showNotConnectedHint =\n    !isRestarting &&\n    !isStopping &&\n    canStart &&\n    (gwState === \"stopped\" || gwState === \"error\")\n\n  const [showStopDialog, setShowStopDialog] = React.useState(false)\n\n  const handleGatewayToggle = () => {\n    if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {\n      return\n    }\n    if (isRunning) {\n      setShowStopDialog(true)\n    } else {\n      void start()\n    }\n  }\n\n  const handleGatewayRestart = () => {\n    if (gwLoading || isRestarting || !restartRequired || !canStart) return\n    void restart()\n  }\n\n  const confirmStop = () => {\n    setShowStopDialog(false)\n    stop()\n  }\n\n  return (\n    <header className=\"bg-background/95 supports-backdrop-filter:bg-background/60 border-b-border/50 sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between border-b px-4 backdrop-blur\">\n      <div className=\"flex items-center gap-2\">\n        <SidebarTrigger className=\"text-muted-foreground hover:bg-accent hover:text-foreground flex h-9 w-9 items-center justify-center rounded-lg sm:hidden [&>svg]:size-5\">\n          <IconMenu2 />\n        </SidebarTrigger>\n        <div className=\"hidden w-36 shrink-0 items-center sm:flex\">\n          <Link to=\"/\">\n            <img className=\"w-full\" src=\"/logo_with_text.png\" alt=\"Logo\" />\n          </Link>\n        </div>\n      </div>\n\n      {/* Center prominent connection status */}\n      <div className=\"pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex\">\n        {showNotConnectedHint && (\n          <div className=\"text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md\">\n            <span className=\"bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full\">\n              <span className=\"bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75\"></span>\n            </span>\n            {t(\"chat.notConnected\")}\n          </div>\n        )}\n      </div>\n\n      <AlertDialog open={showStopDialog} onOpenChange={setShowStopDialog}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>\n              {t(\"header.gateway.stopDialog.title\")}\n            </AlertDialogTitle>\n            <AlertDialogDescription>\n              {t(\"header.gateway.stopDialog.description\")}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>{t(\"common.cancel\")}</AlertDialogCancel>\n            <AlertDialogAction\n              onClick={confirmStop}\n              className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n            >\n              {t(\"header.gateway.stopDialog.confirm\")}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n\n      <div className=\"text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2\">\n        {restartRequired && (\n          <Tooltip delayDuration={700}>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"secondary\"\n                size=\"icon-sm\"\n                className=\"bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25\"\n                onClick={handleGatewayRestart}\n                disabled={gwLoading || isRestarting || isStopping || !canStart}\n                aria-label={t(\"header.gateway.action.restart\")}\n              >\n                <IconRefresh className=\"size-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              {t(\"header.gateway.restartRequired\")}\n            </TooltipContent>\n          </Tooltip>\n        )}\n\n        {/* Gateway Start/Stop */}\n        {isRunning ? (\n          <Tooltip delayDuration={700}>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"destructive\"\n                size=\"icon-sm\"\n                className=\"size-8\"\n                onClick={handleGatewayToggle}\n                disabled={gwLoading}\n                aria-label={t(\"header.gateway.action.stop\")}\n              >\n                <IconPower className=\"h-4 w-4 opacity-80\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{t(\"header.gateway.action.stop\")}</TooltipContent>\n          </Tooltip>\n        ) : (\n          <Button\n            variant={\n              isStarting || isRestarting || isStopping ? \"secondary\" : \"default\"\n            }\n            size=\"sm\"\n            className={`h-8 gap-2 px-3 ${\n              isStopped ? \"bg-green-500 text-white hover:bg-green-600\" : \"\"\n            }`}\n            onClick={handleGatewayToggle}\n            disabled={\n              gwLoading || isStarting || isRestarting || isStopping || !canStart\n            }\n          >\n            {gwLoading || isStarting || isRestarting || isStopping ? (\n              <IconLoader2 className=\"h-4 w-4 animate-spin opacity-70\" />\n            ) : (\n              <IconPlayerPlay className=\"h-4 w-4 opacity-80\" />\n            )}\n            <span className=\"text-xs font-semibold\">\n              {isStopping\n                ? t(\"header.gateway.status.stopping\")\n                : isRestarting\n                  ? t(\"header.gateway.status.restarting\")\n                  : isStarting\n                    ? t(\"header.gateway.status.starting\")\n                    : t(\"header.gateway.action.start\")}\n            </span>\n          </Button>\n        )}\n\n        <Separator\n          className=\"mx-4 my-2 hidden md:block\"\n          orientation=\"vertical\"\n        />\n\n        {/* Docs Link */}\n        <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" asChild>\n          <a href=\"https://docs.picoclaw.io\" target=\"_blank\" rel=\"noreferrer\">\n            <IconBook className=\"size-4.5\" />\n          </a>\n        </Button>\n\n        {/* Language Switcher */}\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\">\n              <IconLanguage className=\"size-4.5\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\">\n            <DropdownMenuItem onClick={() => i18n.changeLanguage(\"en\")}>\n              English\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={() => i18n.changeLanguage(\"zh\")}>\n              简体中文\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n\n        {/* Theme Toggle */}\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"size-8\"\n          onClick={toggleTheme}\n        >\n          {theme === \"dark\" ? (\n            <IconSun className=\"size-4.5\" />\n          ) : (\n            <IconMoon className=\"size-4.5\" />\n          )}\n        </Button>\n      </div>\n    </header>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/app-layout.tsx",
    "content": "import type { ReactNode } from \"react\"\nimport { Toaster } from \"sonner\"\n\nimport { AppHeader } from \"@/components/app-header\"\nimport { AppSidebar } from \"@/components/app-sidebar\"\nimport { SidebarProvider } from \"@/components/ui/sidebar\"\nimport { TooltipProvider } from \"@/components/ui/tooltip\"\n\nexport function AppLayout({ children }: { children: ReactNode }) {\n  return (\n    <TooltipProvider>\n      <SidebarProvider className=\"flex h-dvh flex-col overflow-hidden\">\n        <AppHeader />\n\n        <div className=\"flex flex-1 overflow-hidden\">\n          <AppSidebar />\n          <div className=\"flex w-full flex-col overflow-hidden\">\n            <main className=\"flex min-h-0 w-full max-w-full flex-1 flex-col overflow-hidden\">\n              {children}\n            </main>\n          </div>\n        </div>\n        <Toaster position=\"bottom-center\" />\n      </SidebarProvider>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/app-sidebar.tsx",
    "content": "import { IconChevronRight } from \"@tabler/icons-react\"\nimport {\n  IconAtom,\n  IconChevronsDown,\n  IconChevronsUp,\n  IconKey,\n  IconListDetails,\n  IconMessageCircle,\n  IconSettings,\n  IconSparkles,\n  IconTools,\n} from \"@tabler/icons-react\"\nimport { Link, useRouterState } from \"@tanstack/react-router\"\nimport * as React from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\"\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarRail,\n} from \"@/components/ui/sidebar\"\nimport { useSidebarChannels } from \"@/hooks/use-sidebar-channels\"\n\ninterface NavItem {\n  title: string\n  url: string\n  icon: React.ComponentType<{ className?: string }>\n  translateTitle?: boolean\n}\n\ninterface NavGroup {\n  label: string\n  defaultOpen: boolean\n  items: NavItem[]\n  isChannelsGroup?: boolean\n}\n\nconst baseNavGroups: Omit<NavGroup, \"items\">[] = [\n  {\n    label: \"navigation.chat\",\n    defaultOpen: true,\n  },\n  {\n    label: \"navigation.model_group\",\n    defaultOpen: true,\n  },\n  {\n    label: \"navigation.agent_group\",\n    defaultOpen: true,\n  },\n  {\n    label: \"navigation.services\",\n    defaultOpen: true,\n  },\n]\n\nexport function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {\n  const routerState = useRouterState()\n  const { t } = useTranslation()\n  const currentPath = routerState.location.pathname\n  const {\n    channelItems,\n    hasMoreChannels,\n    showAllChannels,\n    toggleShowAllChannels,\n  } = useSidebarChannels({ t })\n\n  const navGroups: NavGroup[] = React.useMemo(() => {\n    return [\n      {\n        ...baseNavGroups[0],\n        items: [\n          {\n            title: \"navigation.chat\",\n            url: \"/\",\n            icon: IconMessageCircle,\n            translateTitle: true,\n          },\n        ],\n      },\n      {\n        ...baseNavGroups[1],\n        items: [\n          {\n            title: \"navigation.models\",\n            url: \"/models\",\n            icon: IconAtom,\n            translateTitle: true,\n          },\n          {\n            title: \"navigation.credentials\",\n            url: \"/credentials\",\n            icon: IconKey,\n            translateTitle: true,\n          },\n        ],\n      },\n      {\n        label: \"navigation.channels_group\",\n        defaultOpen: true,\n        items: channelItems.map((item) => ({\n          title: item.title,\n          url: item.url,\n          icon: item.icon,\n          translateTitle: false,\n        })),\n        isChannelsGroup: true,\n      },\n      {\n        ...baseNavGroups[2],\n        items: [\n          {\n            title: \"navigation.skills\",\n            url: \"/agent/skills\",\n            icon: IconSparkles,\n            translateTitle: true,\n          },\n          {\n            title: \"navigation.tools\",\n            url: \"/agent/tools\",\n            icon: IconTools,\n            translateTitle: true,\n          },\n        ],\n      },\n      {\n        ...baseNavGroups[3],\n        items: [\n          {\n            title: \"navigation.config\",\n            url: \"/config\",\n            icon: IconSettings,\n            translateTitle: true,\n          },\n          {\n            title: \"navigation.logs\",\n            url: \"/logs\",\n            icon: IconListDetails,\n            translateTitle: true,\n          },\n        ],\n      },\n    ]\n  }, [channelItems])\n\n  return (\n    <Sidebar\n      {...props}\n      className=\"bg-background border-r-border/20 border-r pt-3\"\n    >\n      <SidebarContent className=\"bg-background\">\n        {navGroups.map((group) => (\n          <Collapsible\n            key={group.label}\n            defaultOpen={group.defaultOpen}\n            className=\"group/collapsible mb-1\"\n          >\n            <SidebarGroup className=\"px-2 py-0\">\n              <SidebarGroupLabel asChild>\n                <CollapsibleTrigger className=\"hover:bg-muted/60 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 transition-colors\">\n                  <span>{t(group.label)}</span>\n                  <IconChevronRight className=\"size-3.5 opacity-50 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90\" />\n                </CollapsibleTrigger>\n              </SidebarGroupLabel>\n              <CollapsibleContent>\n                <SidebarGroupContent className=\"pt-1\">\n                  <SidebarMenu>\n                    {group.items.map((item) => {\n                      const isActive =\n                        currentPath === item.url ||\n                        (item.url !== \"/\" &&\n                          currentPath.startsWith(`${item.url}/`))\n                      return (\n                        <SidebarMenuItem key={item.title}>\n                          <SidebarMenuButton\n                            asChild\n                            isActive={isActive}\n                            className={`h-9 px-3 ${isActive ? \"bg-accent/80 text-foreground font-medium\" : \"text-muted-foreground hover:bg-muted/60\"}`}\n                          >\n                            <Link to={item.url}>\n                              <item.icon\n                                className={`size-4 ${isActive ? \"opacity-100\" : \"opacity-60\"}`}\n                              />\n                              <span\n                                className={\n                                  isActive ? \"opacity-100\" : \"opacity-80\"\n                                }\n                              >\n                                {item.translateTitle === false\n                                  ? item.title\n                                  : t(item.title)}\n                              </span>\n                            </Link>\n                          </SidebarMenuButton>\n                        </SidebarMenuItem>\n                      )\n                    })}\n                    {group.isChannelsGroup && hasMoreChannels && (\n                      <SidebarMenuItem key=\"channels-more-toggle\">\n                        <SidebarMenuButton\n                          onClick={toggleShowAllChannels}\n                          className=\"text-muted-foreground hover:bg-muted/60 h-9 px-3\"\n                        >\n                          {showAllChannels ? (\n                            <IconChevronsUp className=\"size-4 opacity-60\" />\n                          ) : (\n                            <IconChevronsDown className=\"size-4 opacity-60\" />\n                          )}\n                          <span className=\"opacity-80\">\n                            {showAllChannels\n                              ? t(\"navigation.show_less_channels\")\n                              : t(\"navigation.show_more_channels\")}\n                          </span>\n                        </SidebarMenuButton>\n                      </SidebarMenuItem>\n                    )}\n                  </SidebarMenu>\n                </SidebarGroupContent>\n              </CollapsibleContent>\n            </SidebarGroup>\n          </Collapsible>\n        ))}\n      </SidebarContent>\n      <SidebarRail />\n    </Sidebar>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-config-page.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useAtomValue } from \"jotai\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport {\n  type ChannelConfig,\n  type SupportedChannel,\n  getAppConfig,\n  getChannelsCatalog,\n  patchAppConfig,\n} from \"@/api/channels\"\nimport { getChannelDisplayName } from \"@/components/channels/channel-display-name\"\nimport { DiscordForm } from \"@/components/channels/channel-forms/discord-form\"\nimport { FeishuForm } from \"@/components/channels/channel-forms/feishu-form\"\nimport { GenericForm } from \"@/components/channels/channel-forms/generic-form\"\nimport { SlackForm } from \"@/components/channels/channel-forms/slack-form\"\nimport { TelegramForm } from \"@/components/channels/channel-forms/telegram-form\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { gatewayAtom } from \"@/store/gateway\"\n\ninterface ChannelConfigPageProps {\n  channelName: string\n}\n\nconst SECRET_FIELD_MAP: Record<string, string> = {\n  token: \"_token\",\n  app_secret: \"_app_secret\",\n  client_secret: \"_client_secret\",\n  corp_secret: \"_corp_secret\",\n  channel_secret: \"_channel_secret\",\n  channel_access_token: \"_channel_access_token\",\n  access_token: \"_access_token\",\n  bot_token: \"_bot_token\",\n  app_token: \"_app_token\",\n  encoding_aes_key: \"_encoding_aes_key\",\n  encrypt_key: \"_encrypt_key\",\n  verification_token: \"_verification_token\",\n  password: \"_password\",\n  nickserv_password: \"_nickserv_password\",\n  sasl_password: \"_sasl_password\",\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return {}\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asBool(value: unknown): boolean {\n  return value === true\n}\n\nfunction buildEditConfig(config: ChannelConfig): ChannelConfig {\n  const edit: ChannelConfig = { ...config }\n  for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {\n    if (secretKey in config) {\n      edit[SECRET_FIELD_MAP[secretKey]] = \"\"\n    }\n  }\n  return edit\n}\n\nfunction normalizeConfig(\n  channel: SupportedChannel,\n  rawConfig: ChannelConfig,\n): ChannelConfig {\n  const config = { ...rawConfig }\n  if (channel.name === \"whatsapp_native\") {\n    config.use_native = true\n  }\n  if (channel.name === \"whatsapp\") {\n    config.use_native = false\n  }\n  return config\n}\n\nfunction buildSavePayload(\n  channel: SupportedChannel,\n  editConfig: ChannelConfig,\n  enabled: boolean,\n): ChannelConfig {\n  const payload: ChannelConfig = { enabled }\n\n  for (const [key, value] of Object.entries(editConfig)) {\n    if (key.startsWith(\"_\")) continue\n    if (key === \"enabled\") continue\n\n    if (key in SECRET_FIELD_MAP) {\n      const editKey = SECRET_FIELD_MAP[key]\n      const incoming = asString(editConfig[editKey])\n      payload[key] = incoming !== \"\" ? incoming : value\n      continue\n    }\n\n    payload[key] = value\n  }\n\n  if (channel.name === \"whatsapp_native\") {\n    payload.use_native = true\n  }\n  if (channel.name === \"whatsapp\") {\n    payload.use_native = false\n  }\n\n  return payload\n}\n\nfunction isConfigured(\n  channel: SupportedChannel,\n  config: ChannelConfig,\n): boolean {\n  switch (channel.name) {\n    case \"telegram\":\n      return asString(config.token) !== \"\"\n    case \"discord\":\n      return asString(config.token) !== \"\"\n    case \"slack\":\n      return asString(config.bot_token) !== \"\"\n    case \"feishu\":\n      return (\n        asString(config.app_id) !== \"\" && asString(config.app_secret) !== \"\"\n      )\n    case \"dingtalk\":\n      return (\n        asString(config.client_id) !== \"\" &&\n        asString(config.client_secret) !== \"\"\n      )\n    case \"line\":\n      return asString(config.channel_access_token) !== \"\"\n    case \"qq\":\n      return (\n        asString(config.app_id) !== \"\" && asString(config.app_secret) !== \"\"\n      )\n    case \"onebot\":\n      return asString(config.ws_url) !== \"\"\n    case \"wecom\":\n      return asString(config.token) !== \"\"\n    case \"wecom_app\":\n      return (\n        asString(config.corp_id) !== \"\" && asString(config.corp_secret) !== \"\"\n      )\n    case \"wecom_aibot\":\n      return asString(config.token) !== \"\"\n    case \"whatsapp\":\n      return asString(config.bridge_url) !== \"\"\n    case \"whatsapp_native\":\n      return asBool(config.use_native)\n    case \"pico\":\n      return asString(config.token) !== \"\"\n    case \"maixcam\":\n      return asString(config.host) !== \"\"\n    case \"matrix\":\n      return (\n        asString(config.homeserver) !== \"\" &&\n        asString(config.user_id) !== \"\" &&\n        asString(config.access_token) !== \"\"\n      )\n    case \"irc\":\n      return asString(config.server) !== \"\"\n    default:\n      return false\n  }\n}\n\nfunction getRequiredFieldKeys(channelName: string): string[] {\n  switch (channelName) {\n    case \"telegram\":\n      return [\"token\"]\n    case \"discord\":\n      return [\"token\"]\n    case \"slack\":\n      return [\"bot_token\"]\n    case \"feishu\":\n      return [\"app_id\", \"app_secret\"]\n    case \"dingtalk\":\n      return [\"client_id\", \"client_secret\"]\n    case \"line\":\n      return [\"channel_secret\", \"channel_access_token\"]\n    case \"qq\":\n      return [\"app_id\", \"app_secret\"]\n    case \"onebot\":\n      return [\"ws_url\"]\n    case \"wecom\":\n      return [\"token\"]\n    case \"wecom_app\":\n      return [\"corp_id\", \"corp_secret\"]\n    case \"wecom_aibot\":\n      return [\"token\"]\n    case \"whatsapp\":\n      return [\"bridge_url\"]\n    case \"pico\":\n      return [\"token\"]\n    case \"maixcam\":\n      return [\"host\"]\n    case \"matrix\":\n      return [\"homeserver\", \"user_id\", \"access_token\"]\n    case \"irc\":\n      return [\"server\"]\n    default:\n      return []\n  }\n}\n\nfunction isMissingRequiredValue(value: unknown): boolean {\n  if (value === null || value === undefined) {\n    return true\n  }\n  if (typeof value === \"string\") {\n    return value.trim() === \"\"\n  }\n  if (Array.isArray(value)) {\n    return value.length === 0\n  }\n  return false\n}\n\nfunction getChannelDocSlug(channelName: string): string {\n  return channelName.replaceAll(\"_\", \"-\")\n}\n\nconst CHANNELS_WITHOUT_DOCS = new Set([\n  \"pico\",\n  \"wecom\",\n  \"matrix\",\n  \"irc\",\n  \"whatsapp\",\n  \"whatsapp_native\",\n])\n\nexport function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {\n  const { t, i18n } = useTranslation()\n  const gateway = useAtomValue(gatewayAtom)\n\n  const [loading, setLoading] = useState(true)\n  const [saving, setSaving] = useState(false)\n  const [fetchError, setFetchError] = useState(\"\")\n  const [serverError, setServerError] = useState(\"\")\n  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})\n\n  const [channel, setChannel] = useState<SupportedChannel | null>(null)\n  const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})\n  const [editConfig, setEditConfig] = useState<ChannelConfig>({})\n  const [enabled, setEnabled] = useState(false)\n\n  const loadData = useCallback(async () => {\n    setLoading(true)\n    try {\n      const [catalog, appConfig] = await Promise.all([\n        getChannelsCatalog(),\n        getAppConfig(),\n      ])\n      const matched =\n        catalog.channels.find((item) => item.name === channelName) ?? null\n\n      if (!matched) {\n        setChannel(null)\n        setFetchError(\n          t(\"channels.page.notFound\", {\n            name: channelName,\n          }),\n        )\n        return\n      }\n\n      const channelsConfig = asRecord(asRecord(appConfig).channels)\n      const raw = asRecord(channelsConfig[matched.config_key])\n      const normalized = normalizeConfig(matched, raw)\n\n      setChannel(matched)\n      setBaseConfig(normalized)\n      setEditConfig(buildEditConfig(normalized))\n      setEnabled(asBool(normalized.enabled))\n      setFetchError(\"\")\n      setServerError(\"\")\n      setFieldErrors({})\n    } catch (e) {\n      setFetchError(e instanceof Error ? e.message : t(\"channels.loadError\"))\n    } finally {\n      setLoading(false)\n    }\n  }, [channelName, t])\n\n  useEffect(() => {\n    loadData()\n  }, [loadData])\n\n  const previousGatewayStatusRef = useRef(gateway.status)\n  useEffect(() => {\n    const previousStatus = previousGatewayStatusRef.current\n    if (previousStatus !== \"running\" && gateway.status === \"running\") {\n      void loadData()\n    }\n    previousGatewayStatusRef.current = gateway.status\n  }, [gateway.status, loadData])\n\n  const savePayload = useMemo(() => {\n    if (!channel) return null\n    return buildSavePayload(channel, editConfig, enabled)\n  }, [channel, editConfig, enabled])\n\n  const configured = useMemo(() => {\n    if (!channel || !savePayload) return false\n    return isConfigured(channel, savePayload)\n  }, [channel, savePayload])\n\n  const docsUrl = useMemo(() => {\n    if (!channel) return \"\"\n    if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return \"\"\n    const language = (\n      i18n.resolvedLanguage ??\n      i18n.language ??\n      \"\"\n    ).toLowerCase()\n    const base = language.startsWith(\"zh\")\n      ? \"https://docs.picoclaw.io/zh-Hans/docs/channels\"\n      : \"https://docs.picoclaw.io/docs/channels\"\n    return `${base}/${getChannelDocSlug(channel.name)}`\n  }, [channel, i18n.language, i18n.resolvedLanguage])\n\n  const channelDisplayName = useMemo(() => {\n    if (!channel) return channelName\n    return getChannelDisplayName(channel, t)\n  }, [channel, channelName, t])\n\n  const hiddenKeys = useMemo(() => {\n    if (!channel) return []\n    if (channel.name === \"whatsapp\") {\n      return [\"use_native\"]\n    }\n    if (channel.name === \"whatsapp_native\") {\n      return [\"use_native\", \"bridge_url\"]\n    }\n    return []\n  }, [channel])\n  const requiredKeys = useMemo(\n    () => getRequiredFieldKeys(channelName),\n    [channelName],\n  )\n\n  const handleChange = useCallback((key: string, value: unknown) => {\n    const normalizedKey = key.startsWith(\"_\") ? key.slice(1) : key\n    setEditConfig((prev) => ({ ...prev, [key]: value }))\n    setFieldErrors((prev) => {\n      if (!(key in prev) && !(normalizedKey in prev)) {\n        return prev\n      }\n      const next = { ...prev }\n      delete next[key]\n      delete next[normalizedKey]\n      return next\n    })\n  }, [])\n\n  const handleReset = () => {\n    setEditConfig(buildEditConfig(baseConfig))\n    setEnabled(asBool(baseConfig.enabled))\n    setServerError(\"\")\n    setFieldErrors({})\n  }\n\n  const handleSave = async () => {\n    if (!channel || !savePayload) return\n\n    const missingRequiredFields = requiredKeys.filter((key) =>\n      isMissingRequiredValue(savePayload[key]),\n    )\n    if (missingRequiredFields.length > 0) {\n      const requiredFieldError = t(\"channels.validation.requiredField\")\n      const nextFieldErrors: Record<string, string> = {}\n      for (const key of missingRequiredFields) {\n        nextFieldErrors[key] = requiredFieldError\n      }\n      setFieldErrors(nextFieldErrors)\n      setServerError(\"\")\n      return\n    }\n\n    setSaving(true)\n    setServerError(\"\")\n    setFieldErrors({})\n    try {\n      await patchAppConfig({\n        channels: {\n          [channel.config_key]: savePayload,\n        },\n      })\n      toast.success(t(\"channels.page.saveSuccess\"))\n      await loadData()\n    } catch (e) {\n      const message =\n        e instanceof Error ? e.message : t(\"channels.page.saveError\")\n      setServerError(message)\n      toast.error(message)\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  const renderForm = () => {\n    if (!channel) return null\n    const isEdit = configured\n\n    switch (channel.name) {\n      case \"telegram\":\n        return (\n          <TelegramForm\n            config={editConfig}\n            onChange={handleChange}\n            isEdit={isEdit}\n            fieldErrors={fieldErrors}\n          />\n        )\n      case \"discord\":\n        return (\n          <DiscordForm\n            config={editConfig}\n            onChange={handleChange}\n            isEdit={isEdit}\n            fieldErrors={fieldErrors}\n          />\n        )\n      case \"slack\":\n        return (\n          <SlackForm\n            config={editConfig}\n            onChange={handleChange}\n            isEdit={isEdit}\n            fieldErrors={fieldErrors}\n          />\n        )\n      case \"feishu\":\n        return (\n          <FeishuForm\n            config={editConfig}\n            onChange={handleChange}\n            isEdit={isEdit}\n            fieldErrors={fieldErrors}\n          />\n        )\n      default:\n        return (\n          <GenericForm\n            config={editConfig}\n            onChange={handleChange}\n            isEdit={isEdit}\n            hiddenKeys={hiddenKeys}\n            requiredKeys={requiredKeys}\n            fieldErrors={fieldErrors}\n          />\n        )\n    }\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader\n        title={channelDisplayName}\n        titleExtra={\n          channel ? (\n            <div className=\"flex items-center gap-1.5\">\n              {enabled ? (\n                <span className=\"rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400\">\n                  {t(\"channels.page.enabled\")}\n                </span>\n              ) : configured ? (\n                <span className=\"rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400\">\n                  {t(\"channels.status.configured\")}\n                </span>\n              ) : null}\n            </div>\n          ) : undefined\n        }\n      />\n\n      <div className=\"flex min-h-0 flex-1 justify-center overflow-y-auto px-4 pb-8 sm:px-6\">\n        {loading ? (\n          <div className=\"flex items-center justify-center py-20\">\n            <IconLoader2 className=\"text-muted-foreground size-6 animate-spin\" />\n          </div>\n        ) : fetchError ? (\n          <div className=\"text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm\">\n            {fetchError}\n          </div>\n        ) : (\n          <div className=\"w-full max-w-250 space-y-5 pt-2\">\n            <div className=\"flex items-center gap-2 text-sm\">\n              <p className=\"font-medium\">\n                {t(\"channels.edit\", {\n                  name: channelDisplayName,\n                })}\n              </p>\n              {channel && docsUrl && (\n                <a\n                  href={docsUrl}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"text-muted-foreground hover:text-foreground text-xs underline underline-offset-2\"\n                >\n                  {t(\"channels.page.docLink\")}\n                </a>\n              )}\n            </div>\n\n            <div className=\"border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3\">\n              <p className=\"text-sm font-medium\">\n                {t(\"channels.page.enableLabel\")}\n              </p>\n              <Switch checked={enabled} onCheckedChange={setEnabled} />\n            </div>\n\n            {renderForm()}\n\n            {serverError && (\n              <p className=\"text-destructive text-sm\">{serverError}</p>\n            )}\n\n            <div className=\"border-border/60 flex justify-end gap-2 border-t py-4\">\n              <Button variant=\"outline\" onClick={handleReset} disabled={saving}>\n                {t(\"common.reset\")}\n              </Button>\n              <Button onClick={handleSave} disabled={saving}>\n                {saving ? t(\"common.saving\") : t(\"common.save\")}\n              </Button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-display-name.ts",
    "content": "import type { TFunction } from \"i18next\"\n\nimport type { SupportedChannel } from \"@/api/channels\"\n\nexport function getChannelDisplayName(\n  channel: Pick<SupportedChannel, \"name\" | \"display_name\">,\n  t: TFunction,\n): string {\n  const key = `channels.name.${channel.name}`\n  const translated = t(key)\n  if (translated !== key) {\n    return translated\n  }\n\n  if (channel.display_name && channel.display_name.trim() !== \"\") {\n    return channel.display_name\n  }\n\n  return channel.name\n    .split(\"_\")\n    .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))\n    .join(\" \")\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-forms/discord-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ChannelConfig } from \"@/api/channels\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport { Field, KeyInput, SwitchCardField } from \"@/components/shared-form\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface DiscordFormProps {\n  config: ChannelConfig\n  onChange: (key: string, value: unknown) => void\n  isEdit: boolean\n  fieldErrors?: Record<string, string>\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is string => typeof item === \"string\")\n}\n\nfunction asBool(value: unknown): boolean {\n  return value === true\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return {}\n}\n\nexport function DiscordForm({\n  config,\n  onChange,\n  isEdit,\n  fieldErrors = {},\n}: DiscordFormProps) {\n  const { t } = useTranslation()\n  const groupTriggerConfig = asRecord(config.group_trigger)\n  const tokenExtraHint =\n    isEdit && asString(config.token)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n\n  return (\n    <div className=\"space-y-5\">\n      <Field\n        label={t(\"channels.field.token\")}\n        required\n        hint={`${t(\"channels.form.desc.token\")}${tokenExtraHint}`}\n        error={fieldErrors.token}\n      >\n        <KeyInput\n          value={asString(config._token)}\n          onChange={(v) => onChange(\"_token\", v)}\n          placeholder={maskedSecretPlaceholder(\n            config.token,\n            t(\"channels.field.tokenPlaceholder\"),\n          )}\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.proxy\")}\n        hint={t(\"channels.form.desc.proxy\")}\n      >\n        <Input\n          value={asString(config.proxy)}\n          onChange={(e) => onChange(\"proxy\", e.target.value)}\n          placeholder=\"http://127.0.0.1:7890\"\n        />\n      </Field>\n      <Field\n        label={t(\"channels.field.allowFrom\")}\n        hint={t(\"channels.form.desc.allowFrom\")}\n      >\n        <Input\n          value={asStringArray(config.allow_from).join(\", \")}\n          onChange={(e) =>\n            onChange(\n              \"allow_from\",\n              e.target.value\n                .split(\",\")\n                .map((s: string) => s.trim())\n                .filter(Boolean),\n            )\n          }\n          placeholder={t(\"channels.field.allowFromPlaceholder\")}\n        />\n      </Field>\n\n      <SwitchCardField\n        label={t(\"channels.field.mentionOnly\")}\n        hint={t(\"channels.form.desc.mentionOnly\")}\n        checked={asBool(groupTriggerConfig.mention_only)}\n        onCheckedChange={(checked) => {\n          onChange(\"group_trigger\", {\n            ...groupTriggerConfig,\n            mention_only: checked,\n          })\n        }}\n        ariaLabel={t(\"channels.field.mentionOnly\")}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-forms/feishu-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ChannelConfig } from \"@/api/channels\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport { Field, KeyInput, SwitchCardField } from \"@/components/shared-form\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface FeishuFormProps {\n  config: ChannelConfig\n  onChange: (key: string, value: unknown) => void\n  isEdit: boolean\n  fieldErrors?: Record<string, string>\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asBool(value: unknown): boolean {\n  return typeof value === \"boolean\" ? value : false\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is string => typeof item === \"string\")\n}\n\nexport function FeishuForm({\n  config,\n  onChange,\n  isEdit,\n  fieldErrors = {},\n}: FeishuFormProps) {\n  const { t } = useTranslation()\n  const appSecretExtraHint =\n    isEdit && asString(config.app_secret)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n  const verificationExtraHint =\n    isEdit && asString(config.verification_token)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n  const encryptExtraHint =\n    isEdit && asString(config.encrypt_key)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n\n  return (\n    <div className=\"space-y-5\">\n      <Field\n        label={t(\"channels.field.appId\")}\n        required\n        hint={t(\"channels.form.desc.appId\")}\n        error={fieldErrors.app_id}\n      >\n        <Input\n          value={asString(config.app_id)}\n          onChange={(e) => onChange(\"app_id\", e.target.value)}\n          placeholder=\"cli_xxxx\"\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.appSecret\")}\n        required\n        hint={`${t(\"channels.form.desc.appSecret\")}${appSecretExtraHint}`}\n        error={fieldErrors.app_secret}\n      >\n        <KeyInput\n          value={asString(config._app_secret)}\n          onChange={(v) => onChange(\"_app_secret\", v)}\n          placeholder={maskedSecretPlaceholder(\n            config.app_secret,\n            t(\"channels.field.secretPlaceholder\"),\n          )}\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.verificationToken\")}\n        hint={`${t(\"channels.form.desc.verificationToken\")}${verificationExtraHint}`}\n      >\n        <KeyInput\n          value={asString(config._verification_token)}\n          onChange={(v) => onChange(\"_verification_token\", v)}\n          placeholder={maskedSecretPlaceholder(\n            config.verification_token,\n            t(\"channels.field.secretPlaceholder\"),\n          )}\n        />\n      </Field>\n      <Field\n        label={t(\"channels.field.encryptKey\")}\n        hint={`${t(\"channels.form.desc.encryptKey\")}${encryptExtraHint}`}\n      >\n        <KeyInput\n          value={asString(config._encrypt_key)}\n          onChange={(v) => onChange(\"_encrypt_key\", v)}\n          placeholder={maskedSecretPlaceholder(\n            config.encrypt_key,\n            t(\"channels.field.secretPlaceholder\"),\n          )}\n        />\n      </Field>\n      <SwitchCardField\n        label={t(\"channels.field.isLark\")}\n        hint={t(\"channels.form.desc.isLark\")}\n        checked={asBool(config.is_lark)}\n        onCheckedChange={(checked) => onChange(\"is_lark\", checked)}\n      />\n      <Field\n        label={t(\"channels.field.allowFrom\")}\n        hint={t(\"channels.form.desc.allowFrom\")}\n      >\n        <Input\n          value={asStringArray(config.allow_from).join(\", \")}\n          onChange={(e) =>\n            onChange(\n              \"allow_from\",\n              e.target.value\n                .split(\",\")\n                .map((s: string) => s.trim())\n                .filter(Boolean),\n            )\n          }\n          placeholder={t(\"channels.field.allowFromPlaceholder\")}\n        />\n      </Field>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-forms/generic-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ChannelConfig } from \"@/api/channels\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport { Field, KeyInput, SwitchCardField } from \"@/components/shared-form\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface GenericFormProps {\n  config: ChannelConfig\n  onChange: (key: string, value: unknown) => void\n  isEdit: boolean\n  hiddenKeys?: string[]\n  requiredKeys?: string[]\n  fieldErrors?: Record<string, string>\n}\n\n// Secret field names that should use masked input.\nconst SECRET_FIELDS = new Set([\n  \"token\",\n  \"app_secret\",\n  \"client_secret\",\n  \"corp_secret\",\n  \"channel_secret\",\n  \"channel_access_token\",\n  \"access_token\",\n  \"bot_token\",\n  \"app_token\",\n  \"encoding_aes_key\",\n  \"encrypt_key\",\n  \"verification_token\",\n  \"password\",\n  \"nickserv_password\",\n  \"sasl_password\",\n])\n\n// Fields to skip in the generic form (handled by enabled toggle or internal).\nconst SKIP_FIELDS = new Set([\"enabled\", \"reasoning_channel_id\"])\n\n// Fields that are objects/nested — show as JSON or skip.\nconst OBJECT_FIELDS = new Set([\n  \"group_trigger\",\n  \"typing\",\n  \"placeholder\",\n  \"allow_token_query\",\n  \"allow_from\",\n  \"allow_origins\",\n])\n\nfunction formatLabel(key: string): string {\n  return key\n    .split(\"_\")\n    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n    .join(\" \")\n}\n\nfunction formatSentenceFieldName(key: string): string {\n  const label = formatLabel(key)\n  return label.charAt(0).toLowerCase() + label.slice(1)\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is string => typeof item === \"string\")\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return {}\n}\n\nfunction asBool(value: unknown): boolean {\n  return value === true\n}\n\nexport function GenericForm({\n  config,\n  onChange,\n  isEdit,\n  hiddenKeys = [],\n  requiredKeys = [],\n  fieldErrors = {},\n}: GenericFormProps) {\n  const { t } = useTranslation()\n  const hiddenFieldSet = new Set(hiddenKeys)\n  const requiredFieldSet = new Set(requiredKeys)\n  const groupTriggerConfig = asRecord(config.group_trigger)\n  const typingConfig = asRecord(config.typing)\n  const placeholderConfig = asRecord(config.placeholder)\n  const placeholderEnabled = asBool(placeholderConfig.enabled)\n\n  const fields = Object.keys(config).filter(\n    (k) =>\n      !k.startsWith(\"_\") &&\n      !SKIP_FIELDS.has(k) &&\n      !OBJECT_FIELDS.has(k) &&\n      !hiddenFieldSet.has(k),\n  )\n\n  const buildHint = (key: string): string => {\n    const descriptions: Record<string, string> = {\n      ws_url: t(\"channels.form.desc.wsUrl\"),\n      reconnect_interval: t(\"channels.form.desc.reconnectInterval\"),\n      bridge_url: t(\"channels.form.desc.bridgeUrl\"),\n      session_store_path: t(\"channels.form.desc.sessionStorePath\"),\n      use_native: t(\"channels.form.desc.useNative\"),\n      host: t(\"channels.form.desc.host\"),\n      port: t(\"channels.form.desc.port\"),\n      homeserver: t(\"channels.form.desc.homeserver\"),\n      user_id: t(\"channels.form.desc.userId\"),\n      device_id: t(\"channels.form.desc.deviceId\"),\n      join_on_invite: t(\"channels.form.desc.joinOnInvite\"),\n      app_id: t(\"channels.form.desc.appId\"),\n      client_id: t(\"channels.form.desc.clientId\"),\n      corp_id: t(\"channels.form.desc.corpId\"),\n      agent_id: t(\"channels.form.desc.agentId\"),\n      webhook_url: t(\"channels.form.desc.webhookUrl\"),\n      webhook_host: t(\"channels.form.desc.webhookHost\"),\n      webhook_port: t(\"channels.form.desc.webhookPort\"),\n      webhook_path: t(\"channels.form.desc.webhookPath\"),\n      reply_timeout: t(\"channels.form.desc.replyTimeout\"),\n      max_steps: t(\"channels.form.desc.maxSteps\"),\n      welcome_message: t(\"channels.form.desc.welcomeMessage\"),\n      allow_token_query: t(\"channels.form.desc.allowTokenQuery\"),\n      ping_interval: t(\"channels.form.desc.pingInterval\"),\n      read_timeout: t(\"channels.form.desc.readTimeout\"),\n      write_timeout: t(\"channels.form.desc.writeTimeout\"),\n      max_connections: t(\"channels.form.desc.maxConnections\"),\n      server: t(\"channels.form.desc.server\"),\n      tls: t(\"channels.form.desc.tls\"),\n      nick: t(\"channels.form.desc.nick\"),\n      user: t(\"channels.form.desc.user\"),\n      real_name: t(\"channels.form.desc.realName\"),\n      channels: t(\"channels.form.desc.channels\"),\n      request_caps: t(\"channels.form.desc.requestCaps\"),\n      max_base64_file_size_mib: t(\"channels.form.desc.maxBase64FileSizeMiB\"),\n    }\n    return (\n      descriptions[key] ??\n      t(\"channels.form.desc.genericField\", {\n        field: formatSentenceFieldName(key),\n      })\n    )\n  }\n\n  return (\n    <div className=\"space-y-5\">\n      {fields.map((key) => {\n        const isRequired = requiredFieldSet.has(key)\n        if (SECRET_FIELDS.has(key)) {\n          const editKey = `_${key}`\n          const extraHint =\n            isEdit && config[key] ? ` ${t(\"channels.field.secretHintSet\")}` : \"\"\n          return (\n            <Field\n              key={key}\n              label={formatLabel(key)}\n              required={isRequired}\n              hint={`${buildHint(key)}${extraHint}`}\n              error={fieldErrors[key]}\n            >\n              <KeyInput\n                value={asString(config[editKey])}\n                onChange={(v) => onChange(editKey, v)}\n                placeholder={maskedSecretPlaceholder(config[key])}\n              />\n            </Field>\n          )\n        }\n\n        const value = config[key]\n        if (typeof value === \"boolean\") {\n          return (\n            <SwitchCardField\n              key={key}\n              label={formatLabel(key)}\n              hint={buildHint(key)}\n              error={fieldErrors[key]}\n              checked={value}\n              onCheckedChange={(checked) => onChange(key, checked)}\n              ariaLabel={formatLabel(key)}\n            />\n          )\n        }\n\n        if (Array.isArray(value)) {\n          return (\n            <Field\n              key={key}\n              label={formatLabel(key)}\n              required={isRequired}\n              hint={buildHint(key)}\n              error={fieldErrors[key]}\n            >\n              <Input\n                value={asStringArray(value).join(\", \")}\n                onChange={(e) =>\n                  onChange(\n                    key,\n                    e.target.value\n                      .split(\",\")\n                      .map((s: string) => s.trim())\n                      .filter(Boolean),\n                  )\n                }\n              />\n            </Field>\n          )\n        }\n\n        return (\n          <Field\n            key={key}\n            label={formatLabel(key)}\n            required={isRequired}\n            hint={buildHint(key)}\n            error={fieldErrors[key]}\n          >\n            <Input\n              value={String(value ?? \"\")}\n              onChange={(e) => {\n                // Attempt to preserve number types\n                const v = e.target.value\n                if (typeof config[key] === \"number\") {\n                  onChange(key, v === \"\" ? 0 : Number(v))\n                } else {\n                  onChange(key, v)\n                }\n              }}\n            />\n          </Field>\n        )\n      })}\n\n      {/* Allow From field */}\n      {config.allow_from !== undefined && !hiddenFieldSet.has(\"allow_from\") && (\n        <Field\n          label={t(\"channels.field.allowFrom\")}\n          hint={t(\"channels.form.desc.allowFrom\")}\n        >\n          <Input\n            value={asStringArray(config.allow_from).join(\", \")}\n            onChange={(e) =>\n              onChange(\n                \"allow_from\",\n                e.target.value\n                  .split(\",\")\n                  .map((s: string) => s.trim())\n                  .filter(Boolean),\n              )\n            }\n            placeholder={t(\"channels.field.allowFromPlaceholder\")}\n          />\n        </Field>\n      )}\n\n      {config.allow_origins !== undefined &&\n        !hiddenFieldSet.has(\"allow_origins\") && (\n          <Field\n            label={t(\"channels.field.allowOrigins\")}\n            hint={t(\"channels.form.desc.allowOrigins\")}\n          >\n            <Input\n              value={asStringArray(config.allow_origins).join(\", \")}\n              onChange={(e) =>\n                onChange(\n                  \"allow_origins\",\n                  e.target.value\n                    .split(\",\")\n                    .map((s: string) => s.trim())\n                    .filter(Boolean),\n                )\n              }\n              placeholder={t(\"channels.field.allowOriginsPlaceholder\")}\n            />\n          </Field>\n        )}\n\n      {config.allow_token_query !== undefined &&\n        !hiddenFieldSet.has(\"allow_token_query\") && (\n          <SwitchCardField\n            label={formatLabel(\"allow_token_query\")}\n            hint={buildHint(\"allow_token_query\")}\n            checked={asBool(config.allow_token_query)}\n            onCheckedChange={(checked) =>\n              onChange(\"allow_token_query\", checked)\n            }\n            ariaLabel={formatLabel(\"allow_token_query\")}\n          />\n        )}\n\n      {config.group_trigger !== undefined &&\n        !hiddenFieldSet.has(\"group_trigger\") && (\n          <>\n            <SwitchCardField\n              label={t(\"channels.field.groupTriggerMentionOnly\")}\n              hint={t(\"channels.form.desc.groupTriggerMentionOnly\")}\n              checked={asBool(groupTriggerConfig.mention_only)}\n              onCheckedChange={(checked) =>\n                onChange(\"group_trigger\", {\n                  ...groupTriggerConfig,\n                  mention_only: checked,\n                })\n              }\n              ariaLabel={t(\"channels.field.groupTriggerMentionOnly\")}\n            />\n            <Field\n              label={t(\"channels.field.groupTriggerPrefixes\")}\n              hint={t(\"channels.form.desc.groupTriggerPrefixes\")}\n            >\n              <Input\n                value={asStringArray(groupTriggerConfig.prefixes).join(\", \")}\n                onChange={(e) =>\n                  onChange(\"group_trigger\", {\n                    ...groupTriggerConfig,\n                    prefixes: e.target.value\n                      .split(\",\")\n                      .map((s: string) => s.trim())\n                      .filter(Boolean),\n                  })\n                }\n                placeholder={t(\"channels.field.groupTriggerPrefixes\")}\n              />\n            </Field>\n          </>\n        )}\n\n      {config.typing !== undefined && !hiddenFieldSet.has(\"typing\") && (\n        <SwitchCardField\n          label={t(\"channels.field.typingEnabled\")}\n          hint={t(\"channels.form.desc.typingEnabled\")}\n          checked={asBool(typingConfig.enabled)}\n          onCheckedChange={(checked) =>\n            onChange(\"typing\", { ...typingConfig, enabled: checked })\n          }\n          ariaLabel={t(\"channels.field.typingEnabled\")}\n        />\n      )}\n\n      {config.placeholder !== undefined &&\n        !hiddenFieldSet.has(\"placeholder\") && (\n          <SwitchCardField\n            label={t(\"channels.field.placeholderEnabled\")}\n            hint={t(\"channels.form.desc.placeholderEnabled\")}\n            checked={placeholderEnabled}\n            onCheckedChange={(checked) =>\n              onChange(\"placeholder\", {\n                ...placeholderConfig,\n                enabled: checked,\n              })\n            }\n            ariaLabel={t(\"channels.field.placeholderEnabled\")}\n          >\n            {placeholderEnabled && (\n              <div className=\"space-y-1\">\n                <Input\n                  value={asString(placeholderConfig.text)}\n                  onChange={(e) =>\n                    onChange(\"placeholder\", {\n                      ...placeholderConfig,\n                      text: e.target.value,\n                    })\n                  }\n                  placeholder={t(\"channels.field.placeholderText\")}\n                  aria-label={t(\"channels.field.placeholderText\")}\n                />\n              </div>\n            )}\n          </SwitchCardField>\n        )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-forms/slack-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ChannelConfig } from \"@/api/channels\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport { Field, KeyInput } from \"@/components/shared-form\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface SlackFormProps {\n  config: ChannelConfig\n  onChange: (key: string, value: unknown) => void\n  isEdit: boolean\n  fieldErrors?: Record<string, string>\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is string => typeof item === \"string\")\n}\n\nexport function SlackForm({\n  config,\n  onChange,\n  isEdit,\n  fieldErrors = {},\n}: SlackFormProps) {\n  const { t } = useTranslation()\n  const botTokenExtraHint =\n    isEdit && asString(config.bot_token)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n  const appTokenExtraHint =\n    isEdit && asString(config.app_token)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n\n  return (\n    <div className=\"space-y-5\">\n      <Field\n        label={t(\"channels.field.botToken\")}\n        required\n        hint={`${t(\"channels.form.desc.botToken\")}${botTokenExtraHint}`}\n        error={fieldErrors.bot_token}\n      >\n        <KeyInput\n          value={asString(config._bot_token)}\n          onChange={(v) => onChange(\"_bot_token\", v)}\n          placeholder={maskedSecretPlaceholder(config.bot_token, \"xoxb-xxxx\")}\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.appToken\")}\n        hint={`${t(\"channels.form.desc.appToken\")}${appTokenExtraHint}`}\n      >\n        <KeyInput\n          value={asString(config._app_token)}\n          onChange={(v) => onChange(\"_app_token\", v)}\n          placeholder={maskedSecretPlaceholder(config.app_token, \"xapp-xxxx\")}\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.allowFrom\")}\n        hint={t(\"channels.form.desc.allowFrom\")}\n      >\n        <Input\n          value={asStringArray(config.allow_from).join(\", \")}\n          onChange={(e) =>\n            onChange(\n              \"allow_from\",\n              e.target.value\n                .split(\",\")\n                .map((s: string) => s.trim())\n                .filter(Boolean),\n            )\n          }\n          placeholder={t(\"channels.field.allowFromPlaceholder\")}\n        />\n      </Field>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/channels/channel-forms/telegram-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ChannelConfig } from \"@/api/channels\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport { Field, KeyInput, SwitchCardField } from \"@/components/shared-form\"\nimport { Input } from \"@/components/ui/input\"\n\ninterface TelegramFormProps {\n  config: ChannelConfig\n  onChange: (key: string, value: unknown) => void\n  isEdit: boolean\n  fieldErrors?: Record<string, string>\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is string => typeof item === \"string\")\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return {}\n}\n\nfunction asBool(value: unknown): boolean {\n  return value === true\n}\n\nexport function TelegramForm({\n  config,\n  onChange,\n  isEdit,\n  fieldErrors = {},\n}: TelegramFormProps) {\n  const { t } = useTranslation()\n  const typingConfig = asRecord(config.typing)\n  const placeholderConfig = asRecord(config.placeholder)\n  const placeholderEnabled = asBool(placeholderConfig.enabled)\n  const tokenExtraHint =\n    isEdit && asString(config.token)\n      ? ` ${t(\"channels.field.secretHintSet\")}`\n      : \"\"\n\n  return (\n    <div className=\"space-y-5\">\n      <Field\n        label={t(\"channels.field.token\")}\n        required\n        hint={`${t(\"channels.form.desc.token\")}${tokenExtraHint}`}\n        error={fieldErrors.token}\n      >\n        <KeyInput\n          value={asString(config._token)}\n          onChange={(v) => onChange(\"_token\", v)}\n          placeholder={maskedSecretPlaceholder(\n            config.token,\n            t(\"channels.field.tokenPlaceholder\"),\n          )}\n        />\n      </Field>\n\n      <Field\n        label={t(\"channels.field.baseUrl\")}\n        hint={t(\"channels.form.desc.baseUrl\")}\n      >\n        <Input\n          value={asString(config.base_url)}\n          onChange={(e) => onChange(\"base_url\", e.target.value)}\n          placeholder=\"https://api.telegram.org\"\n        />\n      </Field>\n      <Field\n        label={t(\"channels.field.proxy\")}\n        hint={t(\"channels.form.desc.proxy\")}\n      >\n        <Input\n          value={asString(config.proxy)}\n          onChange={(e) => onChange(\"proxy\", e.target.value)}\n          placeholder=\"http://127.0.0.1:7890\"\n        />\n      </Field>\n      <Field\n        label={t(\"channels.field.allowFrom\")}\n        hint={t(\"channels.form.desc.allowFrom\")}\n      >\n        <Input\n          value={asStringArray(config.allow_from).join(\", \")}\n          onChange={(e) =>\n            onChange(\n              \"allow_from\",\n              e.target.value\n                .split(\",\")\n                .map((s: string) => s.trim())\n                .filter(Boolean),\n            )\n          }\n          placeholder={t(\"channels.field.allowFromPlaceholder\")}\n        />\n      </Field>\n\n      <SwitchCardField\n        label={t(\"channels.field.typingEnabled\")}\n        hint={t(\"channels.form.desc.typingEnabled\")}\n        checked={asBool(typingConfig.enabled)}\n        onCheckedChange={(checked) =>\n          onChange(\"typing\", { ...typingConfig, enabled: checked })\n        }\n        ariaLabel={t(\"channels.field.typingEnabled\")}\n      />\n\n      <SwitchCardField\n        label={t(\"channels.field.placeholderEnabled\")}\n        hint={t(\"channels.form.desc.placeholderEnabled\")}\n        checked={placeholderEnabled}\n        onCheckedChange={(checked) =>\n          onChange(\"placeholder\", {\n            ...placeholderConfig,\n            enabled: checked,\n          })\n        }\n        ariaLabel={t(\"channels.field.placeholderEnabled\")}\n      >\n        {placeholderEnabled && (\n          <div className=\"space-y-1\">\n            <Input\n              value={asString(placeholderConfig.text)}\n              onChange={(e) =>\n                onChange(\"placeholder\", {\n                  ...placeholderConfig,\n                  text: e.target.value,\n                })\n              }\n              placeholder={t(\"channels.field.placeholderText\")}\n              aria-label={t(\"channels.field.placeholderText\")}\n            />\n          </div>\n        )}\n      </SwitchCardField>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/assistant-message.tsx",
    "content": "import { IconCheck, IconCopy } from \"@tabler/icons-react\"\nimport { useState } from \"react\"\nimport ReactMarkdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { formatMessageTime } from \"@/hooks/use-pico-chat\"\n\ninterface AssistantMessageProps {\n  content: string\n  timestamp?: string | number\n}\n\nexport function AssistantMessage({\n  content,\n  timestamp = \"\",\n}: AssistantMessageProps) {\n  const [isCopied, setIsCopied] = useState(false)\n  const formattedTimestamp =\n    timestamp !== \"\" ? formatMessageTime(timestamp) : \"\"\n\n  const handleCopy = () => {\n    navigator.clipboard.writeText(content).then(() => {\n      setIsCopied(true)\n      setTimeout(() => setIsCopied(false), 2000)\n    })\n  }\n\n  return (\n    <div className=\"group flex w-full flex-col gap-1.5\">\n      <div className=\"text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70\">\n        <div className=\"flex items-center gap-2\">\n          <span>PicoClaw</span>\n          {formattedTimestamp && (\n            <>\n              <span className=\"opacity-50\">•</span>\n              <span>{formattedTimestamp}</span>\n            </>\n          )}\n        </div>\n      </div>\n\n      <div className=\"bg-card text-card-foreground relative overflow-hidden rounded-xl border\">\n        <div className=\"prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed\">\n          <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>\n        </div>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100\"\n          onClick={handleCopy}\n        >\n          {isCopied ? (\n            <IconCheck className=\"h-4 w-4 text-green-500\" />\n          ) : (\n            <IconCopy className=\"text-muted-foreground h-4 w-4\" />\n          )}\n        </Button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/chat-composer.tsx",
    "content": "import { IconArrowUp } from \"@tabler/icons-react\"\nimport type { KeyboardEvent } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport TextareaAutosize from \"react-textarea-autosize\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\n\ninterface ChatComposerProps {\n  input: string\n  onInputChange: (value: string) => void\n  onSend: () => void\n  isConnected: boolean\n  hasDefaultModel: boolean\n}\n\nexport function ChatComposer({\n  input,\n  onInputChange,\n  onSend,\n  isConnected,\n  hasDefaultModel,\n}: ChatComposerProps) {\n  const { t } = useTranslation()\n  const canInput = isConnected && hasDefaultModel\n\n  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.nativeEvent.isComposing) return\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault()\n      onSend()\n    }\n  }\n\n  return (\n    <div className=\"bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48\">\n      <div className=\"bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md\">\n        <TextareaAutosize\n          value={input}\n          onChange={(e) => onInputChange(e.target.value)}\n          onKeyDown={handleKeyDown}\n          placeholder={t(\"chat.placeholder\")}\n          disabled={!canInput}\n          className={cn(\n            \"placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent\",\n            !canInput && \"cursor-not-allowed\",\n          )}\n          minRows={1}\n          maxRows={8}\n        />\n\n        <div className=\"mt-2 flex items-center justify-between px-1\">\n          <div className=\"flex items-center gap-1\">{/* action buttons */}</div>\n\n          <Button\n            size=\"icon\"\n            className=\"size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95\"\n            onClick={onSend}\n            disabled={!input.trim() || !canInput}\n          >\n            <IconArrowUp className=\"size-4\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/chat-empty-state.tsx",
    "content": "import {\n  IconPlugConnectedX,\n  IconRobot,\n  IconRobotOff,\n  IconStar,\n} from \"@tabler/icons-react\"\nimport { Link } from \"@tanstack/react-router\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface ChatEmptyStateProps {\n  hasConfiguredModels: boolean\n  defaultModelName: string\n  isConnected: boolean\n}\n\nexport function ChatEmptyState({\n  hasConfiguredModels,\n  defaultModelName,\n  isConnected,\n}: ChatEmptyStateProps) {\n  const { t } = useTranslation()\n\n  if (!hasConfiguredModels) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-20 opacity-70\">\n        <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500\">\n          <IconRobotOff className=\"h-8 w-8\" />\n        </div>\n        <h3 className=\"mb-2 text-xl font-medium\">\n          {t(\"chat.empty.noConfiguredModel\")}\n        </h3>\n        <p className=\"text-muted-foreground mb-4 text-center text-sm\">\n          {t(\"chat.empty.noConfiguredModelDescription\")}\n        </p>\n        <Button asChild variant=\"outline\" size=\"sm\" className=\"px-4\">\n          <Link to=\"/models\">{t(\"chat.empty.goToModels\")}</Link>\n        </Button>\n      </div>\n    )\n  }\n\n  if (!defaultModelName) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-20 opacity-70\">\n        <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500\">\n          <IconStar className=\"h-8 w-8\" />\n        </div>\n        <h3 className=\"mb-2 text-xl font-medium\">\n          {t(\"chat.empty.noSelectedModel\")}\n        </h3>\n        <p className=\"text-muted-foreground mb-4 text-center text-sm\">\n          {t(\"chat.empty.noSelectedModelDescription\")}\n        </p>\n      </div>\n    )\n  }\n\n  if (!isConnected) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-20 opacity-70\">\n        <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500\">\n          <IconPlugConnectedX className=\"h-8 w-8\" />\n        </div>\n        <h3 className=\"mb-2 text-xl font-medium\">\n          {t(\"chat.empty.notRunning\")}\n        </h3>\n        <p className=\"text-muted-foreground mb-4 text-center text-sm\">\n          {t(\"chat.empty.notRunningDescription\")}\n        </p>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex flex-col items-center justify-center py-20 opacity-70\">\n      <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-500\">\n        <IconRobot className=\"h-8 w-8\" />\n      </div>\n      <h3 className=\"mb-2 text-xl font-medium\">{t(\"chat.welcome\")}</h3>\n      <p className=\"text-muted-foreground text-center text-sm\">\n        {t(\"chat.welcomeDesc\")}\n      </p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/chat-page.tsx",
    "content": "import { IconPlus } from \"@tabler/icons-react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { AssistantMessage } from \"@/components/chat/assistant-message\"\nimport { ChatComposer } from \"@/components/chat/chat-composer\"\nimport { ChatEmptyState } from \"@/components/chat/chat-empty-state\"\nimport { ModelSelector } from \"@/components/chat/model-selector\"\nimport { SessionHistoryMenu } from \"@/components/chat/session-history-menu\"\nimport { TypingIndicator } from \"@/components/chat/typing-indicator\"\nimport { UserMessage } from \"@/components/chat/user-message\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\nimport { useChatModels } from \"@/hooks/use-chat-models\"\nimport { useGateway } from \"@/hooks/use-gateway\"\nimport { usePicoChat } from \"@/hooks/use-pico-chat\"\nimport { useSessionHistory } from \"@/hooks/use-session-history\"\n\nexport function ChatPage() {\n  const { t } = useTranslation()\n  const scrollRef = useRef<HTMLDivElement>(null)\n  const [isAtBottom, setIsAtBottom] = useState(true)\n  const [hasScrolled, setHasScrolled] = useState(false)\n  const [input, setInput] = useState(\"\")\n\n  const {\n    messages,\n    connectionState,\n    isTyping,\n    activeSessionId,\n    sendMessage,\n    switchSession,\n    newChat,\n  } = usePicoChat()\n\n  const { state: gwState } = useGateway()\n  const isGatewayRunning = gwState === \"running\"\n  const isChatConnected = connectionState === \"connected\"\n\n  const {\n    defaultModelName,\n    hasConfiguredModels,\n    apiKeyModels,\n    oauthModels,\n    localModels,\n    handleSetDefault,\n  } = useChatModels({ isConnected: isGatewayRunning })\n  const canSend = isChatConnected && Boolean(defaultModelName)\n\n  const {\n    sessions,\n    hasMore,\n    loadError,\n    loadErrorMessage,\n    observerRef,\n    loadSessions,\n    handleDeleteSession,\n  } = useSessionHistory({\n    activeSessionId,\n    onDeletedActiveSession: newChat,\n  })\n\n  const syncScrollState = (element: HTMLDivElement) => {\n    const { scrollTop, scrollHeight, clientHeight } = element\n    setHasScrolled(scrollTop > 0)\n    setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)\n  }\n\n  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {\n    syncScrollState(e.currentTarget)\n  }\n\n  useEffect(() => {\n    if (scrollRef.current) {\n      if (isAtBottom) {\n        scrollRef.current.scrollTop = scrollRef.current.scrollHeight\n      }\n      syncScrollState(scrollRef.current)\n    }\n  }, [messages, isTyping, isAtBottom])\n\n  const handleSend = () => {\n    if (!input.trim() || !canSend) return\n    if (sendMessage(input.trim())) {\n      setInput(\"\")\n    }\n  }\n\n  return (\n    <div className=\"bg-background/95 flex h-full flex-col\">\n      <PageHeader\n        title={t(\"navigation.chat\")}\n        className={`transition-shadow ${\n          hasScrolled ? \"shadow-sm\" : \"shadow-none\"\n        }`}\n        titleExtra={\n          hasConfiguredModels && (\n            <ModelSelector\n              defaultModelName={defaultModelName}\n              apiKeyModels={apiKeyModels}\n              oauthModels={oauthModels}\n              localModels={localModels}\n              onValueChange={handleSetDefault}\n            />\n          )\n        }\n      >\n        <Button\n          variant=\"secondary\"\n          size=\"sm\"\n          onClick={newChat}\n          className=\"h-9 gap-2\"\n        >\n          <IconPlus className=\"size-4\" />\n          <span className=\"hidden sm:inline\">{t(\"chat.newChat\")}</span>\n        </Button>\n\n        <SessionHistoryMenu\n          sessions={sessions}\n          activeSessionId={activeSessionId}\n          hasMore={hasMore}\n          loadError={loadError}\n          loadErrorMessage={loadErrorMessage}\n          observerRef={observerRef}\n          onOpenChange={(open) => {\n            if (open) {\n              void loadSessions(true)\n            }\n          }}\n          onSwitchSession={switchSession}\n          onDeleteSession={handleDeleteSession}\n        />\n      </PageHeader>\n\n      <div\n        ref={scrollRef}\n        onScroll={handleScroll}\n        className=\"min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48\"\n      >\n        <div className=\"mx-auto flex w-full max-w-250 flex-col gap-8 pb-8\">\n          {messages.length === 0 && !isTyping && (\n            <ChatEmptyState\n              hasConfiguredModels={hasConfiguredModels}\n              defaultModelName={defaultModelName}\n              isConnected={isGatewayRunning}\n            />\n          )}\n\n          {messages.map((msg) => (\n            <div key={msg.id} className=\"flex w-full\">\n              {msg.role === \"assistant\" ? (\n                <AssistantMessage\n                  content={msg.content}\n                  timestamp={msg.timestamp}\n                />\n              ) : (\n                <UserMessage content={msg.content} />\n              )}\n            </div>\n          ))}\n\n          {isTyping && <TypingIndicator />}\n        </div>\n      </div>\n\n      <ChatComposer\n        input={input}\n        onInputChange={setInput}\n        onSend={handleSend}\n        isConnected={isChatConnected}\n        hasDefaultModel={Boolean(defaultModelName)}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/model-selector.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { ModelInfo } from \"@/api/models\"\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\n\ninterface ModelSelectorProps {\n  defaultModelName: string\n  apiKeyModels: ModelInfo[]\n  oauthModels: ModelInfo[]\n  localModels: ModelInfo[]\n  onValueChange: (modelName: string) => void\n}\n\nexport function ModelSelector({\n  defaultModelName,\n  apiKeyModels,\n  oauthModels,\n  localModels,\n  onValueChange,\n}: ModelSelectorProps) {\n  const { t } = useTranslation()\n\n  return (\n    <Select value={defaultModelName} onValueChange={onValueChange}>\n      <SelectTrigger\n        size=\"sm\"\n        className=\"text-muted-foreground hover:text-foreground focus-visible:border-input h-8 max-w-[160px] min-w-[80px] bg-transparent shadow-none focus-visible:ring-0 sm:max-w-[220px]\"\n      >\n        <SelectValue placeholder={t(\"chat.noModel\")} />\n      </SelectTrigger>\n      <SelectContent position=\"popper\" align=\"start\">\n        {apiKeyModels.length > 0 && (\n          <SelectGroup>\n            <SelectLabel>{t(\"chat.modelGroup.apikey\")}</SelectLabel>\n            {apiKeyModels.map((model) => (\n              <SelectItem key={model.index} value={model.model_name}>\n                {model.model_name}\n              </SelectItem>\n            ))}\n          </SelectGroup>\n        )}\n        {apiKeyModels.length > 0 &&\n          (oauthModels.length > 0 || localModels.length > 0) && (\n            <SelectSeparator />\n          )}\n\n        {oauthModels.length > 0 && (\n          <SelectGroup>\n            <SelectLabel>{t(\"chat.modelGroup.oauth\")}</SelectLabel>\n            {oauthModels.map((model) => (\n              <SelectItem key={model.index} value={model.model_name}>\n                {model.model_name}\n              </SelectItem>\n            ))}\n          </SelectGroup>\n        )}\n        {oauthModels.length > 0 &&\n          (localModels.length > 0 || apiKeyModels.length > 0) && (\n            <SelectSeparator />\n          )}\n\n        {localModels.length > 0 && (\n          <SelectGroup>\n            <SelectLabel>{t(\"chat.modelGroup.local\")}</SelectLabel>\n            {localModels.map((model) => (\n              <SelectItem key={model.index} value={model.model_name}>\n                {model.model_name}\n              </SelectItem>\n            ))}\n          </SelectGroup>\n        )}\n      </SelectContent>\n    </Select>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/session-history-menu.tsx",
    "content": "import { IconHistory, IconTrash } from \"@tabler/icons-react\"\nimport dayjs from \"dayjs\"\nimport type { RefObject } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { SessionSummary } from \"@/api/sessions\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\n\ninterface SessionHistoryMenuProps {\n  sessions: SessionSummary[]\n  activeSessionId: string\n  hasMore: boolean\n  loadError: boolean\n  loadErrorMessage: string\n  observerRef: RefObject<HTMLDivElement | null>\n  onOpenChange: (open: boolean) => void\n  onSwitchSession: (sessionId: string) => void\n  onDeleteSession: (sessionId: string) => void\n}\n\nexport function SessionHistoryMenu({\n  sessions,\n  activeSessionId,\n  hasMore,\n  loadError,\n  loadErrorMessage,\n  observerRef,\n  onOpenChange,\n  onSwitchSession,\n  onDeleteSession,\n}: SessionHistoryMenuProps) {\n  const { t } = useTranslation()\n\n  return (\n    <DropdownMenu onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"secondary\" size=\"sm\" className=\"h-9 gap-2\">\n          <IconHistory className=\"size-4\" />\n          <span className=\"hidden sm:inline\">{t(\"chat.history\")}</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-72\">\n        <ScrollArea className=\"max-h-[300px]\">\n          {loadError && (\n            <DropdownMenuItem disabled>\n              <span className=\"text-destructive text-xs\">\n                {loadErrorMessage}\n              </span>\n            </DropdownMenuItem>\n          )}\n          {sessions.length === 0 && !loadError ? (\n            <DropdownMenuItem disabled>\n              <span className=\"text-muted-foreground text-xs\">\n                {t(\"chat.noHistory\")}\n              </span>\n            </DropdownMenuItem>\n          ) : (\n            sessions.map((session) => (\n              <DropdownMenuItem\n                key={session.id}\n                className={`group relative my-0.5 flex flex-col items-start gap-0.5 pr-8 ${\n                  session.id === activeSessionId ? \"bg-accent\" : \"\"\n                }`}\n                onClick={() => onSwitchSession(session.id)}\n              >\n                <span className=\"line-clamp-1 text-sm font-medium\">\n                  {session.title || session.preview}\n                </span>\n                <span className=\"text-muted-foreground text-xs\">\n                  {t(\"chat.messagesCount\", {\n                    count: session.message_count,\n                  })}{\" \"}\n                  · {dayjs(session.updated).fromNow()}\n                </span>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  aria-label={t(\"chat.deleteSession\")}\n                  className=\"text-muted-foreground hover:bg-destructive/10 hover:text-destructive absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100\"\n                  onClick={(e) => {\n                    e.preventDefault()\n                    e.stopPropagation()\n                    onDeleteSession(session.id)\n                  }}\n                >\n                  <IconTrash className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuItem>\n            ))\n          )}\n          {hasMore && sessions.length > 0 && (\n            <div ref={observerRef} className=\"py-2 text-center\">\n              <span className=\"text-muted-foreground animate-pulse text-xs\">\n                {t(\"chat.loadingMore\")}\n              </span>\n            </div>\n          )}\n        </ScrollArea>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/typing-indicator.tsx",
    "content": "import { useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nexport function TypingIndicator() {\n  const { t } = useTranslation()\n  const thinkingSteps = [\n    t(\"chat.thinking.step1\"),\n    t(\"chat.thinking.step2\"),\n    t(\"chat.thinking.step3\"),\n    t(\"chat.thinking.step4\"),\n  ]\n  const [stepIndex, setStepIndex] = useState(0)\n\n  useEffect(() => {\n    const stepsCount = thinkingSteps.length\n    const interval = setInterval(() => {\n      setStepIndex((prev) => (prev + 1) % stepsCount)\n    }, 3000)\n    return () => clearInterval(interval)\n  }, [thinkingSteps.length])\n\n  return (\n    <div className=\"flex w-full flex-col gap-1.5\">\n      <div className=\"text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70\">\n        <span>PicoClaw</span>\n      </div>\n      <div className=\"bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4\">\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]\" />\n          <span className=\"size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]\" />\n          <span className=\"size-2 animate-bounce rounded-full bg-violet-400/70\" />\n        </div>\n\n        <div className=\"bg-muted relative h-1 w-36 overflow-hidden rounded-full\">\n          <div className=\"absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]\" />\n        </div>\n\n        <p\n          key={stepIndex}\n          className=\"text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs\"\n        >\n          {thinkingSteps[stepIndex]}\n        </p>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/chat/user-message.tsx",
    "content": "interface UserMessageProps {\n  content: string\n}\n\nexport function UserMessage({ content }: UserMessageProps) {\n  return (\n    <div className=\"flex w-full flex-col items-end gap-1.5\">\n      <div className=\"max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm\">\n        {content}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/config/config-page.tsx",
    "content": "import { IconCode, IconDeviceFloppy } from \"@tabler/icons-react\"\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport { Link } from \"@tanstack/react-router\"\nimport { useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { patchAppConfig } from \"@/api/channels\"\nimport {\n  getAutoStartStatus,\n  getLauncherConfig,\n  setAutoStartEnabled as updateAutoStartEnabled,\n  setLauncherConfig as updateLauncherConfig,\n} from \"@/api/system\"\nimport {\n  AgentDefaultsSection,\n  CronSection,\n  DevicesSection,\n  ExecSection,\n  LauncherSection,\n  RuntimeSection,\n} from \"@/components/config/config-sections\"\nimport {\n  type CoreConfigForm,\n  EMPTY_FORM,\n  EMPTY_LAUNCHER_FORM,\n  type LauncherForm,\n  buildFormFromConfig,\n  parseCIDRText,\n  parseIntField,\n  parseMultilineList,\n} from \"@/components/config/form-model\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\n\nexport function ConfigPage() {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n  const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)\n  const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)\n  const [launcherForm, setLauncherForm] =\n    useState<LauncherForm>(EMPTY_LAUNCHER_FORM)\n  const [launcherBaseline, setLauncherBaseline] =\n    useState<LauncherForm>(EMPTY_LAUNCHER_FORM)\n  const [autoStartEnabled, setAutoStartEnabled] = useState(false)\n  const [autoStartBaseline, setAutoStartBaseline] = useState(false)\n  const [saving, setSaving] = useState(false)\n\n  const { data, isLoading, error } = useQuery({\n    queryKey: [\"config\"],\n    queryFn: async () => {\n      const res = await fetch(\"/api/config\")\n      if (!res.ok) {\n        throw new Error(\"Failed to load config\")\n      }\n      return res.json()\n    },\n  })\n\n  const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({\n    queryKey: [\"system\", \"launcher-config\"],\n    queryFn: getLauncherConfig,\n  })\n\n  const {\n    data: autoStartStatus,\n    isLoading: isAutoStartLoading,\n    error: autoStartError,\n  } = useQuery({\n    queryKey: [\"system\", \"autostart\"],\n    queryFn: getAutoStartStatus,\n  })\n\n  useEffect(() => {\n    if (!data) return\n    const parsed = buildFormFromConfig(data)\n    setForm(parsed)\n    setBaseline(parsed)\n  }, [data])\n\n  useEffect(() => {\n    if (!launcherConfig) return\n    const parsed: LauncherForm = {\n      port: String(launcherConfig.port),\n      publicAccess: launcherConfig.public,\n      allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join(\"\\n\"),\n    }\n    setLauncherForm(parsed)\n    setLauncherBaseline(parsed)\n  }, [launcherConfig])\n\n  useEffect(() => {\n    if (!autoStartStatus) return\n    setAutoStartEnabled(autoStartStatus.enabled)\n    setAutoStartBaseline(autoStartStatus.enabled)\n  }, [autoStartStatus])\n\n  const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)\n  const launcherDirty =\n    JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)\n  const autoStartDirty = autoStartEnabled !== autoStartBaseline\n  const isDirty = configDirty || launcherDirty || autoStartDirty\n\n  const autoStartSupported = autoStartStatus?.supported !== false\n  const autoStartHint = autoStartError\n    ? t(\"pages.config.autostart_load_error\")\n    : !autoStartSupported\n      ? t(\"pages.config.autostart_unsupported\")\n      : t(\"pages.config.autostart_hint\")\n\n  const updateField = <K extends keyof CoreConfigForm>(\n    key: K,\n    value: CoreConfigForm[K],\n  ) => {\n    setForm((prev) => ({ ...prev, [key]: value }))\n  }\n\n  const updateLauncherField = <K extends keyof LauncherForm>(\n    key: K,\n    value: LauncherForm[K],\n  ) => {\n    setLauncherForm((prev) => ({ ...prev, [key]: value }))\n  }\n\n  const handleReset = () => {\n    setForm(baseline)\n    setLauncherForm(launcherBaseline)\n    setAutoStartEnabled(autoStartBaseline)\n    toast.info(t(\"pages.config.reset_success\"))\n  }\n\n  const handleSave = async () => {\n    try {\n      setSaving(true)\n\n      if (configDirty) {\n        const workspace = form.workspace.trim()\n        const dmScope = form.dmScope.trim()\n\n        if (!workspace) {\n          throw new Error(\"Workspace path is required.\")\n        }\n        if (!dmScope) {\n          throw new Error(\"Session scope is required.\")\n        }\n\n        const maxTokens = parseIntField(form.maxTokens, \"Max tokens\", {\n          min: 1,\n        })\n        const maxToolIterations = parseIntField(\n          form.maxToolIterations,\n          \"Max tool iterations\",\n          { min: 1 },\n        )\n        const summarizeMessageThreshold = parseIntField(\n          form.summarizeMessageThreshold,\n          \"Summarize message threshold\",\n          { min: 1 },\n        )\n        const summarizeTokenPercent = parseIntField(\n          form.summarizeTokenPercent,\n          \"Summarize token percent\",\n          { min: 1, max: 100 },\n        )\n        const heartbeatInterval = parseIntField(\n          form.heartbeatInterval,\n          \"Heartbeat interval\",\n          { min: 1 },\n        )\n        const cronExecTimeoutMinutes = parseIntField(\n          form.cronExecTimeoutMinutes,\n          \"Cron exec timeout\",\n          { min: 0 },\n        )\n        const execConfigPatch: Record<string, unknown> = {\n          enabled: form.execEnabled,\n        }\n\n        if (form.execEnabled) {\n          execConfigPatch.allow_remote = form.allowRemote\n          execConfigPatch.enable_deny_patterns = form.enableDenyPatterns\n          execConfigPatch.custom_allow_patterns = parseMultilineList(\n            form.customAllowPatternsText,\n          )\n          execConfigPatch.timeout_seconds = parseIntField(\n            form.execTimeoutSeconds,\n            \"Exec timeout\",\n            { min: 0 },\n          )\n\n          if (form.enableDenyPatterns) {\n            execConfigPatch.custom_deny_patterns = parseMultilineList(\n              form.customDenyPatternsText,\n            )\n          }\n        }\n\n        await patchAppConfig({\n          agents: {\n            defaults: {\n              workspace,\n              restrict_to_workspace: form.restrictToWorkspace,\n              max_tokens: maxTokens,\n              max_tool_iterations: maxToolIterations,\n              summarize_message_threshold: summarizeMessageThreshold,\n              summarize_token_percent: summarizeTokenPercent,\n            },\n          },\n          session: {\n            dm_scope: dmScope,\n          },\n          tools: {\n            cron: {\n              allow_command: form.allowCommand,\n              exec_timeout_minutes: cronExecTimeoutMinutes,\n            },\n            exec: execConfigPatch,\n          },\n          heartbeat: {\n            enabled: form.heartbeatEnabled,\n            interval: heartbeatInterval,\n          },\n          devices: {\n            enabled: form.devicesEnabled,\n            monitor_usb: form.monitorUSB,\n          },\n        })\n\n        setBaseline(form)\n        queryClient.invalidateQueries({ queryKey: [\"config\"] })\n      }\n\n      if (launcherDirty) {\n        const port = parseIntField(launcherForm.port, \"Service port\", {\n          min: 1,\n          max: 65535,\n        })\n        const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)\n        const savedLauncherConfig = await updateLauncherConfig({\n          port,\n          public: launcherForm.publicAccess,\n          allowed_cidrs: allowedCIDRs,\n        })\n        const parsedLauncher: LauncherForm = {\n          port: String(savedLauncherConfig.port),\n          publicAccess: savedLauncherConfig.public,\n          allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(\n            \"\\n\",\n          ),\n        }\n        setLauncherForm(parsedLauncher)\n        setLauncherBaseline(parsedLauncher)\n        queryClient.setQueryData(\n          [\"system\", \"launcher-config\"],\n          savedLauncherConfig,\n        )\n      }\n\n      if (autoStartDirty) {\n        if (!autoStartSupported) {\n          throw new Error(t(\"pages.config.autostart_unsupported\"))\n        }\n        const status = await updateAutoStartEnabled(autoStartEnabled)\n        setAutoStartEnabled(status.enabled)\n        setAutoStartBaseline(status.enabled)\n        queryClient.setQueryData([\"system\", \"autostart\"], status)\n      }\n\n      toast.success(t(\"pages.config.save_success\"))\n    } catch (err) {\n      toast.error(\n        err instanceof Error ? err.message : t(\"pages.config.save_error\"),\n      )\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader\n        title={t(\"navigation.config\")}\n        children={\n          <Button variant=\"outline\" asChild>\n            <Link to=\"/config/raw\">\n              <IconCode className=\"size-4\" />\n              {t(\"pages.config.open_raw\")}\n            </Link>\n          </Button>\n        }\n      />\n      <div className=\"flex-1 overflow-auto p-3 lg:p-6\">\n        <div className=\"mx-auto w-full max-w-[1000px] space-y-6\">\n          {isLoading ? (\n            <div className=\"text-muted-foreground py-6 text-sm\">\n              {t(\"labels.loading\")}\n            </div>\n          ) : error ? (\n            <div className=\"text-destructive py-6 text-sm\">\n              {t(\"pages.config.load_error\")}\n            </div>\n          ) : (\n            <div className=\"space-y-6\">\n              {isDirty && (\n                <div className=\"bg-yellow-50 px-3 py-2 text-sm text-yellow-700\">\n                  {t(\"pages.config.unsaved_changes\")}\n                </div>\n              )}\n\n              <AgentDefaultsSection form={form} onFieldChange={updateField} />\n\n              <RuntimeSection form={form} onFieldChange={updateField} />\n\n              <ExecSection form={form} onFieldChange={updateField} />\n\n              <CronSection form={form} onFieldChange={updateField} />\n\n              <LauncherSection\n                launcherForm={launcherForm}\n                onFieldChange={updateLauncherField}\n                disabled={saving || isLauncherLoading}\n              />\n\n              <DevicesSection\n                form={form}\n                onFieldChange={updateField}\n                autoStartEnabled={autoStartEnabled}\n                autoStartHint={autoStartHint}\n                autoStartDisabled={\n                  isAutoStartLoading ||\n                  Boolean(autoStartError) ||\n                  !autoStartSupported ||\n                  saving\n                }\n                onAutoStartChange={setAutoStartEnabled}\n              />\n\n              <div className=\"flex justify-end gap-2\">\n                <Button\n                  variant=\"outline\"\n                  onClick={handleReset}\n                  disabled={!isDirty || saving}\n                >\n                  {t(\"common.reset\")}\n                </Button>\n                <Button onClick={handleSave} disabled={!isDirty || saving}>\n                  <IconDeviceFloppy className=\"size-4\" />\n                  {saving ? t(\"common.saving\") : t(\"common.save\")}\n                </Button>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/config/config-sections.tsx",
    "content": "import type { ReactNode } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  type CoreConfigForm,\n  DM_SCOPE_OPTIONS,\n  type LauncherForm,\n} from \"@/components/config/form-model\"\nimport { Field, SwitchCardField } from \"@/components/shared-form\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\ntype UpdateCoreField = <K extends keyof CoreConfigForm>(\n  key: K,\n  value: CoreConfigForm[K],\n) => void\n\ntype UpdateLauncherField = <K extends keyof LauncherForm>(\n  key: K,\n  value: LauncherForm[K],\n) => void\n\ninterface ConfigSectionCardProps {\n  title: string\n  description?: string\n  children: ReactNode\n}\n\nfunction ConfigSectionCard({\n  title,\n  description,\n  children,\n}: ConfigSectionCardProps) {\n  return (\n    <Card size=\"sm\">\n      <CardHeader className=\"border-border border-b\">\n        <CardTitle>{title}</CardTitle>\n        {description && <CardDescription>{description}</CardDescription>}\n      </CardHeader>\n      <CardContent className=\"pt-0\">\n        <div className=\"divide-border/70 divide-y\">{children}</div>\n      </CardContent>\n    </Card>\n  )\n}\n\ninterface AgentDefaultsSectionProps {\n  form: CoreConfigForm\n  onFieldChange: UpdateCoreField\n}\n\nexport function AgentDefaultsSection({\n  form,\n  onFieldChange,\n}: AgentDefaultsSectionProps) {\n  const { t } = useTranslation()\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.agent\")}>\n      <Field\n        label={t(\"pages.config.workspace\")}\n        hint={t(\"pages.config.workspace_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          value={form.workspace}\n          onChange={(e) => onFieldChange(\"workspace\", e.target.value)}\n          placeholder=\"~/.picoclaw/workspace\"\n        />\n      </Field>\n\n      <SwitchCardField\n        label={t(\"pages.config.restrict_workspace\")}\n        hint={t(\"pages.config.restrict_workspace_hint\")}\n        layout=\"setting-row\"\n        checked={form.restrictToWorkspace}\n        onCheckedChange={(checked) =>\n          onFieldChange(\"restrictToWorkspace\", checked)\n        }\n      />\n\n      <Field\n        label={t(\"pages.config.max_tokens\")}\n        hint={t(\"pages.config.max_tokens_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={1}\n          value={form.maxTokens}\n          onChange={(e) => onFieldChange(\"maxTokens\", e.target.value)}\n        />\n      </Field>\n\n      <Field\n        label={t(\"pages.config.max_tool_iterations\")}\n        hint={t(\"pages.config.max_tool_iterations_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={1}\n          value={form.maxToolIterations}\n          onChange={(e) => onFieldChange(\"maxToolIterations\", e.target.value)}\n        />\n      </Field>\n\n      <Field\n        label={t(\"pages.config.summarize_threshold\")}\n        hint={t(\"pages.config.summarize_threshold_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={1}\n          value={form.summarizeMessageThreshold}\n          onChange={(e) =>\n            onFieldChange(\"summarizeMessageThreshold\", e.target.value)\n          }\n        />\n      </Field>\n\n      <Field\n        label={t(\"pages.config.summarize_token_percent\")}\n        hint={t(\"pages.config.summarize_token_percent_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={1}\n          max={100}\n          value={form.summarizeTokenPercent}\n          onChange={(e) =>\n            onFieldChange(\"summarizeTokenPercent\", e.target.value)\n          }\n        />\n      </Field>\n    </ConfigSectionCard>\n  )\n}\n\ninterface ExecSectionProps {\n  form: CoreConfigForm\n  onFieldChange: UpdateCoreField\n}\n\nexport function ExecSection({ form, onFieldChange }: ExecSectionProps) {\n  const { t } = useTranslation()\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.exec\")}>\n      <SwitchCardField\n        label={t(\"pages.config.exec_enabled\")}\n        hint={t(\"pages.config.exec_enabled_hint\")}\n        layout=\"setting-row\"\n        checked={form.execEnabled}\n        onCheckedChange={(checked) => onFieldChange(\"execEnabled\", checked)}\n      />\n\n      {form.execEnabled && (\n        <>\n          <SwitchCardField\n            label={t(\"pages.config.allow_remote\")}\n            hint={t(\"pages.config.allow_remote_hint\")}\n            layout=\"setting-row\"\n            checked={form.allowRemote}\n            onCheckedChange={(checked) => onFieldChange(\"allowRemote\", checked)}\n          />\n\n          <SwitchCardField\n            label={t(\"pages.config.enable_deny_patterns\")}\n            hint={t(\"pages.config.enable_deny_patterns_hint\")}\n            layout=\"setting-row\"\n            checked={form.enableDenyPatterns}\n            onCheckedChange={(checked) =>\n              onFieldChange(\"enableDenyPatterns\", checked)\n            }\n          />\n\n          {form.enableDenyPatterns && (\n            <Field\n              label={t(\"pages.config.custom_deny_patterns\")}\n              hint={t(\"pages.config.custom_deny_patterns_hint\")}\n              layout=\"setting-row\"\n              controlClassName=\"md:max-w-md\"\n            >\n              <Textarea\n                value={form.customDenyPatternsText}\n                placeholder={t(\"pages.config.custom_patterns_placeholder\")}\n                className=\"min-h-[88px]\"\n                onChange={(e) =>\n                  onFieldChange(\"customDenyPatternsText\", e.target.value)\n                }\n              />\n            </Field>\n          )}\n\n          <Field\n            label={t(\"pages.config.custom_allow_patterns\")}\n            hint={t(\"pages.config.custom_allow_patterns_hint\")}\n            layout=\"setting-row\"\n            controlClassName=\"md:max-w-md\"\n          >\n            <Textarea\n              value={form.customAllowPatternsText}\n              placeholder={t(\"pages.config.custom_patterns_placeholder\")}\n              className=\"min-h-[88px]\"\n              onChange={(e) =>\n                onFieldChange(\"customAllowPatternsText\", e.target.value)\n              }\n            />\n          </Field>\n\n          <Field\n            label={t(\"pages.config.exec_timeout_seconds\")}\n            hint={t(\"pages.config.exec_timeout_seconds_hint\")}\n            layout=\"setting-row\"\n          >\n            <Input\n              type=\"number\"\n              min={0}\n              value={form.execTimeoutSeconds}\n              onChange={(e) =>\n                onFieldChange(\"execTimeoutSeconds\", e.target.value)\n              }\n            />\n          </Field>\n        </>\n      )}\n    </ConfigSectionCard>\n  )\n}\n\ninterface RuntimeSectionProps {\n  form: CoreConfigForm\n  onFieldChange: UpdateCoreField\n}\n\nexport function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {\n  const { t } = useTranslation()\n  const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(\n    (scope) => scope.value === form.dmScope,\n  )\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.runtime\")}>\n      <Field\n        label={t(\"pages.config.session_scope\")}\n        hint={t(\"pages.config.session_scope_hint\")}\n        layout=\"setting-row\"\n      >\n        <Select\n          value={form.dmScope}\n          onValueChange={(value) => onFieldChange(\"dmScope\", value)}\n        >\n          <SelectTrigger className=\"w-full\">\n            <SelectValue>\n              {selectedDmScopeOption\n                ? t(\n                    selectedDmScopeOption.labelKey,\n                    selectedDmScopeOption.labelDefault,\n                  )\n                : form.dmScope}\n            </SelectValue>\n          </SelectTrigger>\n          <SelectContent>\n            {DM_SCOPE_OPTIONS.map((scope) => (\n              <SelectItem key={scope.value} value={scope.value}>\n                <div className=\"flex flex-col gap-0.5\">\n                  <span className=\"font-medium\">{t(scope.labelKey)}</span>\n                  <span className=\"text-muted-foreground text-xs\">\n                    {t(scope.descKey)}\n                  </span>\n                </div>\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </Field>\n\n      <SwitchCardField\n        label={t(\"pages.config.heartbeat_enabled\")}\n        hint={t(\"pages.config.heartbeat_enabled_hint\")}\n        layout=\"setting-row\"\n        checked={form.heartbeatEnabled}\n        onCheckedChange={(checked) =>\n          onFieldChange(\"heartbeatEnabled\", checked)\n        }\n      />\n\n      {form.heartbeatEnabled && (\n        <Field\n          label={t(\"pages.config.heartbeat_interval\")}\n          hint={t(\"pages.config.heartbeat_interval_hint\")}\n          layout=\"setting-row\"\n        >\n          <Input\n            type=\"number\"\n            min={1}\n            value={form.heartbeatInterval}\n            onChange={(e) => onFieldChange(\"heartbeatInterval\", e.target.value)}\n          />\n        </Field>\n      )}\n    </ConfigSectionCard>\n  )\n}\n\ninterface CronSectionProps {\n  form: CoreConfigForm\n  onFieldChange: UpdateCoreField\n}\n\nexport function CronSection({ form, onFieldChange }: CronSectionProps) {\n  const { t } = useTranslation()\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.cron\")}>\n      <SwitchCardField\n        label={t(\"pages.config.allow_shell_execution\")}\n        hint={t(\"pages.config.allow_shell_execution_hint\")}\n        layout=\"setting-row\"\n        checked={form.allowCommand}\n        disabled={!form.execEnabled}\n        onCheckedChange={(checked) => onFieldChange(\"allowCommand\", checked)}\n      />\n\n      <Field\n        label={t(\"pages.config.cron_exec_timeout\")}\n        hint={t(\"pages.config.cron_exec_timeout_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={0}\n          disabled={!form.execEnabled}\n          value={form.cronExecTimeoutMinutes}\n          onChange={(e) =>\n            onFieldChange(\"cronExecTimeoutMinutes\", e.target.value)\n          }\n        />\n      </Field>\n    </ConfigSectionCard>\n  )\n}\n\ninterface LauncherSectionProps {\n  launcherForm: LauncherForm\n  onFieldChange: UpdateLauncherField\n  disabled: boolean\n}\n\nexport function LauncherSection({\n  launcherForm,\n  onFieldChange,\n  disabled,\n}: LauncherSectionProps) {\n  const { t } = useTranslation()\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.launcher\")}>\n      <SwitchCardField\n        label={t(\"pages.config.lan_access\")}\n        hint={t(\"pages.config.lan_access_hint\")}\n        layout=\"setting-row\"\n        checked={launcherForm.publicAccess}\n        disabled={disabled}\n        onCheckedChange={(checked) => onFieldChange(\"publicAccess\", checked)}\n      />\n\n      <Field\n        label={t(\"pages.config.server_port\")}\n        hint={t(\"pages.config.server_port_hint\")}\n        layout=\"setting-row\"\n      >\n        <Input\n          type=\"number\"\n          min={1}\n          max={65535}\n          value={launcherForm.port}\n          disabled={disabled}\n          onChange={(e) => onFieldChange(\"port\", e.target.value)}\n        />\n      </Field>\n\n      <Field\n        label={t(\"pages.config.allowed_cidrs\")}\n        hint={t(\"pages.config.allowed_cidrs_hint\")}\n        layout=\"setting-row\"\n        controlClassName=\"md:max-w-md\"\n      >\n        <Textarea\n          value={launcherForm.allowedCIDRsText}\n          disabled={disabled}\n          placeholder={t(\"pages.config.allowed_cidrs_placeholder\")}\n          className=\"min-h-[88px]\"\n          onChange={(e) => onFieldChange(\"allowedCIDRsText\", e.target.value)}\n        />\n      </Field>\n    </ConfigSectionCard>\n  )\n}\n\ninterface DevicesSectionProps {\n  form: CoreConfigForm\n  onFieldChange: UpdateCoreField\n  autoStartEnabled: boolean\n  autoStartHint: string\n  autoStartDisabled: boolean\n  onAutoStartChange: (checked: boolean) => void\n}\n\nexport function DevicesSection({\n  form,\n  onFieldChange,\n  autoStartEnabled,\n  autoStartHint,\n  autoStartDisabled,\n  onAutoStartChange,\n}: DevicesSectionProps) {\n  const { t } = useTranslation()\n\n  return (\n    <ConfigSectionCard title={t(\"pages.config.sections.devices\")}>\n      <SwitchCardField\n        label={t(\"pages.config.devices_enabled\")}\n        hint={t(\"pages.config.devices_enabled_hint\")}\n        layout=\"setting-row\"\n        checked={form.devicesEnabled}\n        onCheckedChange={(checked) => onFieldChange(\"devicesEnabled\", checked)}\n      />\n\n      <SwitchCardField\n        label={t(\"pages.config.monitor_usb\")}\n        hint={t(\"pages.config.monitor_usb_hint\")}\n        layout=\"setting-row\"\n        checked={form.monitorUSB}\n        onCheckedChange={(checked) => onFieldChange(\"monitorUSB\", checked)}\n      />\n\n      <SwitchCardField\n        label={t(\"pages.config.autostart_label\")}\n        hint={autoStartHint}\n        layout=\"setting-row\"\n        checked={autoStartEnabled}\n        disabled={autoStartDisabled}\n        onCheckedChange={onAutoStartChange}\n      />\n    </ConfigSectionCard>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/config/form-model.ts",
    "content": "export type JsonRecord = Record<string, unknown>\n\nexport interface CoreConfigForm {\n  workspace: string\n  restrictToWorkspace: boolean\n  execEnabled: boolean\n  allowRemote: boolean\n  enableDenyPatterns: boolean\n  customDenyPatternsText: string\n  customAllowPatternsText: string\n  execTimeoutSeconds: string\n  allowCommand: boolean\n  cronExecTimeoutMinutes: string\n  maxTokens: string\n  maxToolIterations: string\n  summarizeMessageThreshold: string\n  summarizeTokenPercent: string\n  dmScope: string\n  heartbeatEnabled: boolean\n  heartbeatInterval: string\n  devicesEnabled: boolean\n  monitorUSB: boolean\n}\n\nexport interface LauncherForm {\n  port: string\n  publicAccess: boolean\n  allowedCIDRsText: string\n}\n\nexport const DM_SCOPE_OPTIONS = [\n  {\n    value: \"per-channel-peer\",\n    labelKey: \"pages.config.session_scope_per_channel_peer\",\n    labelDefault: \"Per Channel + Peer\",\n    descKey: \"pages.config.session_scope_per_channel_peer_desc\",\n    descDefault: \"Separate context for each user in each channel.\",\n  },\n  {\n    value: \"per-channel\",\n    labelKey: \"pages.config.session_scope_per_channel\",\n    labelDefault: \"Per Channel\",\n    descKey: \"pages.config.session_scope_per_channel_desc\",\n    descDefault: \"One shared context per channel.\",\n  },\n  {\n    value: \"per-peer\",\n    labelKey: \"pages.config.session_scope_per_peer\",\n    labelDefault: \"Per Peer\",\n    descKey: \"pages.config.session_scope_per_peer_desc\",\n    descDefault: \"One context per user across channels.\",\n  },\n  {\n    value: \"global\",\n    labelKey: \"pages.config.session_scope_global\",\n    labelDefault: \"Global\",\n    descKey: \"pages.config.session_scope_global_desc\",\n    descDefault: \"All messages share one global context.\",\n  },\n] as const\n\nexport const EMPTY_FORM: CoreConfigForm = {\n  workspace: \"\",\n  restrictToWorkspace: true,\n  execEnabled: true,\n  allowRemote: true,\n  enableDenyPatterns: true,\n  customDenyPatternsText: \"\",\n  customAllowPatternsText: \"\",\n  execTimeoutSeconds: \"0\",\n  allowCommand: true,\n  cronExecTimeoutMinutes: \"5\",\n  maxTokens: \"32768\",\n  maxToolIterations: \"50\",\n  summarizeMessageThreshold: \"20\",\n  summarizeTokenPercent: \"75\",\n  dmScope: \"per-channel-peer\",\n  heartbeatEnabled: true,\n  heartbeatInterval: \"30\",\n  devicesEnabled: false,\n  monitorUSB: true,\n}\n\nexport const EMPTY_LAUNCHER_FORM: LauncherForm = {\n  port: \"18800\",\n  publicAccess: false,\n  allowedCIDRsText: \"\",\n}\n\nfunction asRecord(value: unknown): JsonRecord {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as JsonRecord\n  }\n  return {}\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === \"string\" ? value : \"\"\n}\n\nfunction asBool(value: unknown): boolean {\n  return value === true\n}\n\nfunction asNumberString(value: unknown, fallback: string): string {\n  if (typeof value === \"number\" && Number.isFinite(value)) {\n    return String(value)\n  }\n  if (typeof value === \"string\" && value.trim() !== \"\") {\n    return value\n  }\n  return fallback\n}\n\nexport function buildFormFromConfig(config: unknown): CoreConfigForm {\n  const root = asRecord(config)\n  const agents = asRecord(root.agents)\n  const defaults = asRecord(agents.defaults)\n  const session = asRecord(root.session)\n  const heartbeat = asRecord(root.heartbeat)\n  const devices = asRecord(root.devices)\n  const tools = asRecord(root.tools)\n  const cron = asRecord(tools.cron)\n  const exec = asRecord(tools.exec)\n\n  return {\n    workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,\n    restrictToWorkspace:\n      defaults.restrict_to_workspace === undefined\n        ? EMPTY_FORM.restrictToWorkspace\n        : asBool(defaults.restrict_to_workspace),\n    execEnabled:\n      exec.enabled === undefined\n        ? EMPTY_FORM.execEnabled\n        : asBool(exec.enabled),\n    allowRemote:\n      exec.allow_remote === undefined\n        ? EMPTY_FORM.allowRemote\n        : asBool(exec.allow_remote),\n    enableDenyPatterns:\n      exec.enable_deny_patterns === undefined\n        ? EMPTY_FORM.enableDenyPatterns\n        : asBool(exec.enable_deny_patterns),\n    customDenyPatternsText: Array.isArray(exec.custom_deny_patterns)\n      ? exec.custom_deny_patterns\n          .filter((value): value is string => typeof value === \"string\")\n          .join(\"\\n\")\n      : EMPTY_FORM.customDenyPatternsText,\n    customAllowPatternsText: Array.isArray(exec.custom_allow_patterns)\n      ? exec.custom_allow_patterns\n          .filter((value): value is string => typeof value === \"string\")\n          .join(\"\\n\")\n      : EMPTY_FORM.customAllowPatternsText,\n    execTimeoutSeconds: asNumberString(\n      exec.timeout_seconds,\n      EMPTY_FORM.execTimeoutSeconds,\n    ),\n    allowCommand:\n      cron.allow_command === undefined\n        ? EMPTY_FORM.allowCommand\n        : asBool(cron.allow_command),\n    cronExecTimeoutMinutes: asNumberString(\n      cron.exec_timeout_minutes,\n      EMPTY_FORM.cronExecTimeoutMinutes,\n    ),\n    maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),\n    maxToolIterations: asNumberString(\n      defaults.max_tool_iterations,\n      EMPTY_FORM.maxToolIterations,\n    ),\n    summarizeMessageThreshold: asNumberString(\n      defaults.summarize_message_threshold,\n      EMPTY_FORM.summarizeMessageThreshold,\n    ),\n    summarizeTokenPercent: asNumberString(\n      defaults.summarize_token_percent,\n      EMPTY_FORM.summarizeTokenPercent,\n    ),\n    dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,\n    heartbeatEnabled:\n      heartbeat.enabled === undefined\n        ? EMPTY_FORM.heartbeatEnabled\n        : asBool(heartbeat.enabled),\n    heartbeatInterval: asNumberString(\n      heartbeat.interval,\n      EMPTY_FORM.heartbeatInterval,\n    ),\n    devicesEnabled:\n      devices.enabled === undefined\n        ? EMPTY_FORM.devicesEnabled\n        : asBool(devices.enabled),\n    monitorUSB:\n      devices.monitor_usb === undefined\n        ? EMPTY_FORM.monitorUSB\n        : asBool(devices.monitor_usb),\n  }\n}\n\nexport function parseIntField(\n  rawValue: string,\n  label: string,\n  options: { min?: number; max?: number } = {},\n): number {\n  const value = Number(rawValue)\n  if (!Number.isInteger(value)) {\n    throw new Error(`${label} must be an integer.`)\n  }\n  if (options.min !== undefined && value < options.min) {\n    throw new Error(`${label} must be >= ${options.min}.`)\n  }\n  if (options.max !== undefined && value > options.max) {\n    throw new Error(`${label} must be <= ${options.max}.`)\n  }\n  return value\n}\n\nexport function parseCIDRText(raw: string): string[] {\n  if (!raw.trim()) {\n    return []\n  }\n  return raw\n    .split(/[\\n,]/)\n    .map((v) => v.trim())\n    .filter((v) => v.length > 0)\n}\n\nexport function parseMultilineList(raw: string): string[] {\n  if (!raw.trim()) {\n    return []\n  }\n  return raw\n    .split(\"\\n\")\n    .map((value) => value.trim())\n    .filter((value) => value.length > 0)\n}\n"
  },
  {
    "path": "web/frontend/src/components/config/raw-config-page.tsx",
    "content": "import { IconAdjustments } from \"@tabler/icons-react\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport { Link } from \"@tanstack/react-router\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { PageHeader } from \"@/components/page-header\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nexport function RawConfigPage() {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n\n  const { data: config, isLoading } = useQuery({\n    queryKey: [\"config\"],\n    queryFn: async () => {\n      const res = await fetch(\"/api/config\")\n      if (!res.ok) {\n        throw new Error(\"Failed to fetch config\")\n      }\n      return res.json()\n    },\n  })\n\n  const mutation = useMutation({\n    mutationFn: async (newConfig: string) => {\n      const res = await fetch(\"/api/config\", {\n        method: \"PUT\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: newConfig,\n      })\n      if (!res.ok) {\n        throw new Error(\"Failed to save config\")\n      }\n    },\n    onSuccess: (_, submittedConfig) => {\n      toast.success(t(\"pages.config.save_success\"))\n      try {\n        const savedConfig = JSON.parse(submittedConfig)\n        setLastSavedConfig(savedConfig)\n        setIsDirty(false)\n        queryClient.invalidateQueries({ queryKey: [\"config\"] })\n      } catch {\n        queryClient.invalidateQueries({ queryKey: [\"config\"] })\n      }\n    },\n    onError: () => {\n      toast.error(t(\"pages.config.save_error\"))\n    },\n  })\n\n  const [editorValue, setEditorValue] = useState(\"\")\n  const [isDirty, setIsDirty] = useState(false)\n  const [lastSavedConfig, setLastSavedConfig] = useState<Record<\n    string,\n    unknown\n  > | null>(null)\n\n  const effectiveEditorValue =\n    editorValue || (config ? JSON.stringify(config, null, 2) : \"\")\n\n  const handleSave = () => {\n    try {\n      JSON.parse(effectiveEditorValue)\n      mutation.mutate(effectiveEditorValue)\n    } catch (error) {\n      toast.error(\n        t(\n          \"pages.config.invalid_json\",\n          error instanceof Error ? error.message : \"Invalid JSON format.\",\n        ),\n      )\n    }\n  }\n\n  const handleFormat = () => {\n    try {\n      const formatted = JSON.stringify(\n        JSON.parse(effectiveEditorValue),\n        null,\n        2,\n      )\n      setEditorValue(formatted)\n      toast.success(t(\"pages.config.format_success\"))\n    } catch (error) {\n      toast.error(\n        t(\n          \"pages.config.format_error\",\n          error instanceof Error ? error.message : \"Invalid JSON format.\",\n        ),\n      )\n    }\n  }\n\n  const [showResetDialog, setShowResetDialog] = useState(false)\n\n  const confirmReset = () => {\n    if (lastSavedConfig) {\n      setEditorValue(JSON.stringify(lastSavedConfig, null, 2))\n    } else if (config) {\n      setEditorValue(JSON.stringify(config, null, 2))\n    }\n    setIsDirty(false)\n    toast.info(t(\"pages.config.reset_success\"))\n    setShowResetDialog(false)\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader title={t(\"pages.config.raw_json_title\")}>\n        <Button variant=\"outline\" asChild>\n          <Link to=\"/config\">\n            <IconAdjustments className=\"size-4\" />\n            {t(\"pages.config.back_to_visual\")}\n          </Link>\n        </Button>\n      </PageHeader>\n\n      <div className=\"flex min-h-0 flex-1 flex-col p-1 lg:p-3 lg:p-6\">\n        <div className=\"mx-auto flex h-full min-h-0 w-full max-w-[1000px] flex-col\">\n          {isLoading ? (\n            <div className=\"flex flex-1 items-center justify-center\">\n              <p>{t(\"labels.loading\")}</p>\n            </div>\n          ) : (\n            <div className=\"flex min-h-0 flex-1 flex-col gap-3\">\n              {isDirty && (\n                <div className=\"shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700\">\n                  {t(\"pages.config.unsaved_changes\")}\n                </div>\n              )}\n              <div className=\"relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm\">\n                <Textarea\n                  value={effectiveEditorValue}\n                  onChange={(e) => {\n                    setEditorValue(e.target.value)\n                    setIsDirty(true)\n                  }}\n                  wrap=\"off\"\n                  className=\"h-full min-h-0 resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-sm [overflow-wrap:normal] whitespace-pre shadow-none focus-visible:ring-0\"\n                  placeholder={t(\"pages.config.json_placeholder\")}\n                />\n              </div>\n              <div className=\"flex shrink-0 justify-end gap-2\">\n                <Button\n                  variant=\"outline\"\n                  onClick={handleFormat}\n                  disabled={mutation.isPending}\n                >\n                  {t(\"pages.config.format\")}\n                </Button>\n                <AlertDialog\n                  open={showResetDialog}\n                  onOpenChange={setShowResetDialog}\n                >\n                  <AlertDialogTrigger asChild>\n                    <Button\n                      variant=\"outline\"\n                      disabled={!isDirty}\n                      onClick={() => setShowResetDialog(true)}\n                    >\n                      {t(\"common.reset\")}\n                    </Button>\n                  </AlertDialogTrigger>\n                  <AlertDialogContent>\n                    <AlertDialogHeader>\n                      <AlertDialogTitle>\n                        {t(\"pages.config.reset_confirm_title\")}\n                      </AlertDialogTitle>\n                      <AlertDialogDescription>\n                        {t(\"pages.config.reset_confirm_desc\")}\n                      </AlertDialogDescription>\n                    </AlertDialogHeader>\n                    <AlertDialogFooter>\n                      <AlertDialogCancel>\n                        {t(\"common.cancel\")}\n                      </AlertDialogCancel>\n                      <AlertDialogAction onClick={confirmReset}>\n                        {t(\"common.confirm\")}\n                      </AlertDialogAction>\n                    </AlertDialogFooter>\n                  </AlertDialogContent>\n                </AlertDialog>\n                <Button onClick={handleSave} disabled={mutation.isPending}>\n                  {mutation.isPending ? t(\"common.saving\") : t(\"common.save\")}\n                </Button>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/anthropic-credential-card.tsx",
    "content": "import {\n  IconKey,\n  IconLoader2,\n  IconPlayerStopFilled,\n  IconSparkles,\n} from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { OAuthProviderStatus } from \"@/api/oauth\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\n\nimport { CredentialCard } from \"./credential-card\"\n\ninterface AnthropicCredentialCardProps {\n  status?: OAuthProviderStatus\n  activeAction: string\n  token: string\n  onTokenChange: (value: string) => void\n  onStopLoading: () => void\n  onSaveToken: () => void\n  onAskLogout: () => void\n}\n\nexport function AnthropicCredentialCard({\n  status,\n  activeAction,\n  token,\n  onTokenChange,\n  onStopLoading,\n  onSaveToken,\n  onAskLogout,\n}: AnthropicCredentialCardProps) {\n  const { t } = useTranslation()\n  const actionBusy = activeAction !== \"\"\n  const tokenLoading = activeAction === \"anthropic:token\"\n  const stopLabel = t(\"credentials.actions.stopLoading\")\n\n  return (\n    <CredentialCard\n      title={\n        <span className=\"inline-flex items-center gap-2\">\n          <span className=\"border-muted inline-flex size-6 items-center justify-center rounded-full border\">\n            <IconSparkles className=\"size-3.5\" />\n          </span>\n          <span>Anthropic</span>\n        </span>\n      }\n      description={t(\"credentials.providers.anthropic.description\")}\n      status={status?.status ?? \"not_logged_in\"}\n      authMethod={status?.auth_method}\n      actions={\n        <div className=\"border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3\">\n          <div className=\"flex h-full flex-col gap-3\">\n            <div className=\"flex h-full items-center gap-2\">\n              <Input\n                value={token}\n                onChange={(e) => onTokenChange(e.target.value)}\n                type=\"password\"\n                placeholder={t(\"credentials.fields.anthropicToken\")}\n              />\n              <Button\n                size=\"sm\"\n                className=\"w-fit\"\n                disabled={actionBusy || !token.trim()}\n                onClick={onSaveToken}\n              >\n                {tokenLoading && (\n                  <IconLoader2 className=\"size-4 animate-spin\" />\n                )}\n                <IconKey className=\"size-4\" />\n                {t(\"credentials.actions.saveToken\")}\n              </Button>\n              {tokenLoading && (\n                <Button\n                  size=\"icon-sm\"\n                  variant=\"ghost\"\n                  onClick={onStopLoading}\n                  aria-label={stopLabel}\n                  title={stopLabel}\n                  className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                >\n                  <IconPlayerStopFilled className=\"size-4\" />\n                </Button>\n              )}\n            </div>\n          </div>\n        </div>\n      }\n      footer={\n        status?.logged_in ? (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            disabled={actionBusy}\n            onClick={onAskLogout}\n            className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n          >\n            {activeAction === \"anthropic:logout\" && (\n              <IconLoader2 className=\"size-4 animate-spin\" />\n            )}\n            {t(\"credentials.actions.logout\")}\n          </Button>\n        ) : null\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/antigravity-credential-card.tsx",
    "content": "import {\n  IconBrandGoogle,\n  IconLoader2,\n  IconLockOpen,\n  IconPlayerStopFilled,\n} from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { OAuthProviderStatus } from \"@/api/oauth\"\nimport { Button } from \"@/components/ui/button\"\n\nimport { CredentialCard } from \"./credential-card\"\n\ninterface AntigravityCredentialCardProps {\n  status?: OAuthProviderStatus\n  activeAction: string\n  onStopLoading: () => void\n  onStartBrowserOAuth: () => void\n  onAskLogout: () => void\n}\n\nexport function AntigravityCredentialCard({\n  status,\n  activeAction,\n  onStopLoading,\n  onStartBrowserOAuth,\n  onAskLogout,\n}: AntigravityCredentialCardProps) {\n  const { t } = useTranslation()\n  const actionBusy = activeAction !== \"\"\n  const browserLoading = activeAction === \"google-antigravity:browser\"\n\n  return (\n    <CredentialCard\n      title={\n        <span className=\"inline-flex items-center gap-2\">\n          <span className=\"border-muted inline-flex size-6 items-center justify-center rounded-full border\">\n            <IconBrandGoogle className=\"size-3.5\" />\n          </span>\n          <span>Google Antigravity</span>\n        </span>\n      }\n      description={t(\"credentials.providers.antigravity.description\")}\n      status={status?.status ?? \"not_logged_in\"}\n      authMethod={status?.auth_method}\n      details={\n        <div className=\"space-y-1\">\n          {status?.email && (\n            <p>\n              {t(\"credentials.labels.email\")}: {status.email}\n            </p>\n          )}\n          {status?.project_id && (\n            <p>\n              {t(\"credentials.labels.project\")}: {status.project_id}\n            </p>\n          )}\n        </div>\n      }\n      actions={\n        <div className=\"border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3\">\n          <div className=\"flex flex-wrap items-center gap-2\">\n            <Button\n              size=\"sm\"\n              variant=\"outline\"\n              disabled={actionBusy}\n              onClick={onStartBrowserOAuth}\n            >\n              {browserLoading && (\n                <IconLoader2 className=\"size-4 animate-spin\" />\n              )}\n              <IconLockOpen className=\"size-4\" />\n              {t(\"credentials.actions.browser\")}\n            </Button>\n            {browserLoading && (\n              <Button\n                size=\"icon-xs\"\n                variant=\"secondary\"\n                onClick={onStopLoading}\n                className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n              >\n                <IconPlayerStopFilled className=\"size-3\" />\n              </Button>\n            )}\n          </div>\n        </div>\n      }\n      footer={\n        status?.logged_in ? (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            disabled={actionBusy}\n            onClick={onAskLogout}\n            className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n          >\n            {activeAction === \"google-antigravity:logout\" && (\n              <IconLoader2 className=\"size-4 animate-spin\" />\n            )}\n            {t(\"credentials.actions.logout\")}\n          </Button>\n        ) : null\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/credential-card.tsx",
    "content": "import type { ReactNode } from \"react\"\n\nimport type { OAuthProviderStatus } from \"@/api/oauth\"\n\nimport { ProviderStatusLine } from \"./provider-status-line\"\n\ninterface CredentialCardProps {\n  title: ReactNode\n  description: string\n  status: OAuthProviderStatus[\"status\"]\n  authMethod?: string\n  details?: ReactNode\n  actions: ReactNode\n  footer?: ReactNode\n}\n\nexport function CredentialCard({\n  title,\n  description,\n  status,\n  authMethod,\n  details,\n  actions,\n  footer,\n}: CredentialCardProps) {\n  return (\n    <section className=\"bg-card flex h-full flex-col rounded-xl border p-4\">\n      <div className=\"min-h-16\">\n        <h3 className=\"text-base font-semibold\">{title}</h3>\n        <p className=\"text-muted-foreground mt-1 text-xs\">{description}</p>\n      </div>\n\n      <ProviderStatusLine status={status} authMethod={authMethod} />\n      <div className=\"text-muted-foreground mt-3 min-h-11 text-xs leading-5\">\n        {details}\n      </div>\n\n      <div className=\"mt-auto flex flex-col gap-4 pt-4\">\n        <div className=\"min-h-[112px]\">{actions}</div>\n        <div className=\"min-h-8\">{footer}</div>\n      </div>\n    </section>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/credentials-page.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { PageHeader } from \"@/components/page-header\"\nimport { useCredentialsPage } from \"@/hooks/use-credentials-page\"\n\nimport { AnthropicCredentialCard } from \"./anthropic-credential-card\"\nimport { AntigravityCredentialCard } from \"./antigravity-credential-card\"\nimport { DeviceCodeSheet } from \"./device-code-sheet\"\nimport { LogoutConfirmDialog } from \"./logout-confirm-dialog\"\nimport { OpenAICredentialCard } from \"./openai-credential-card\"\n\nexport function CredentialsPage() {\n  const { t } = useTranslation()\n  const {\n    loading,\n    error,\n    activeAction,\n    activeFlow,\n    flowHint,\n    openAIToken,\n    anthropicToken,\n    openaiStatus,\n    anthropicStatus,\n    antigravityStatus,\n    logoutDialogOpen,\n    logoutConfirmProvider,\n    logoutProviderLabel,\n    deviceSheetOpen,\n    deviceFlow,\n    setOpenAIToken,\n    setAnthropicToken,\n    startBrowserOAuth,\n    startOpenAIDeviceCode,\n    stopLoading,\n    saveToken,\n    askLogout,\n    handleConfirmLogout,\n    handleLogoutDialogOpenChange,\n    handleDeviceSheetOpenChange,\n  } = useCredentialsPage()\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader title={t(\"navigation.credentials\")} />\n\n      <div className=\"min-h-0 flex-1 overflow-y-auto px-4 sm:px-6\">\n        <div className=\"pt-2\">\n          <p className=\"text-muted-foreground text-sm\">\n            {t(\"credentials.description\")}\n          </p>\n        </div>\n\n        {error && (\n          <div className=\"text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm\">\n            {error}\n          </div>\n        )}\n\n        {activeFlow && (\n          <div className=\"bg-muted mt-4 rounded-lg border px-4 py-3 text-sm\">\n            <p className=\"font-medium\">{t(\"credentials.flow.current\")}</p>\n            <p className=\"text-muted-foreground mt-1\">{flowHint}</p>\n          </div>\n        )}\n\n        {loading ? (\n          <div className=\"text-muted-foreground flex items-center gap-2 py-10 text-sm\">\n            <IconLoader2 className=\"size-4 animate-spin\" />\n            {t(\"credentials.loading\")}\n          </div>\n        ) : (\n          <div className=\"grid grid-cols-1 gap-4 py-5 lg:auto-rows-fr lg:grid-cols-3\">\n            <OpenAICredentialCard\n              status={openaiStatus}\n              activeAction={activeAction}\n              token={openAIToken}\n              onTokenChange={setOpenAIToken}\n              onStartBrowserOAuth={() => void startBrowserOAuth(\"openai\")}\n              onStartDeviceCode={() => void startOpenAIDeviceCode()}\n              onStopLoading={stopLoading}\n              onSaveToken={() => void saveToken(\"openai\", openAIToken.trim())}\n              onAskLogout={() => askLogout(\"openai\")}\n            />\n\n            <AnthropicCredentialCard\n              status={anthropicStatus}\n              activeAction={activeAction}\n              token={anthropicToken}\n              onTokenChange={setAnthropicToken}\n              onStopLoading={stopLoading}\n              onSaveToken={() =>\n                void saveToken(\"anthropic\", anthropicToken.trim())\n              }\n              onAskLogout={() => askLogout(\"anthropic\")}\n            />\n\n            <AntigravityCredentialCard\n              status={antigravityStatus}\n              activeAction={activeAction}\n              onStopLoading={stopLoading}\n              onStartBrowserOAuth={() =>\n                void startBrowserOAuth(\"google-antigravity\")\n              }\n              onAskLogout={() => askLogout(\"google-antigravity\")}\n            />\n          </div>\n        )}\n      </div>\n\n      <LogoutConfirmDialog\n        open={logoutDialogOpen}\n        providerLabel={logoutProviderLabel}\n        isSubmitting={activeAction === `${logoutConfirmProvider}:logout`}\n        onOpenChange={handleLogoutDialogOpenChange}\n        onConfirm={handleConfirmLogout}\n      />\n\n      <DeviceCodeSheet\n        open={deviceSheetOpen}\n        flow={deviceFlow}\n        flowHint={flowHint}\n        onOpenChange={handleDeviceSheetOpenChange}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/device-code-sheet.tsx",
    "content": "import { IconRefresh } from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { OAuthFlowState } from \"@/api/oauth\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\n\ninterface DeviceCodeSheetProps {\n  open: boolean\n  flow: OAuthFlowState | null\n  flowHint: string\n  onOpenChange: (open: boolean) => void\n}\n\nexport function DeviceCodeSheet({\n  open,\n  flow,\n  flowHint,\n  onOpenChange,\n}: DeviceCodeSheetProps) {\n  const { t } = useTranslation()\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent\n        side=\"right\"\n        className=\"data-[side=right]:!w-full data-[side=right]:sm:!w-[480px] data-[side=right]:sm:!max-w-[480px]\"\n      >\n        <SheetHeader className=\"border-b-muted border-b px-6 py-5\">\n          <SheetTitle>{t(\"credentials.device.title\")}</SheetTitle>\n          <SheetDescription>\n            {t(\"credentials.device.description\")}\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"space-y-4 px-6 py-5\">\n          <div>\n            <p className=\"text-muted-foreground text-xs uppercase\">\n              {t(\"credentials.device.code\")}\n            </p>\n            <p className=\"mt-1 rounded-md border px-3 py-2 font-mono text-lg font-semibold tracking-wide\">\n              {flow?.user_code || \"-\"}\n            </p>\n          </div>\n\n          <div>\n            <p className=\"text-muted-foreground text-xs uppercase\">\n              {t(\"credentials.device.url\")}\n            </p>\n            <a\n              href={flow?.verify_url || \"#\"}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"text-primary mt-1 block text-sm break-all underline\"\n            >\n              {flow?.verify_url || \"-\"}\n            </a>\n          </div>\n\n          <div className=\"text-muted-foreground flex items-center gap-2 text-sm\">\n            <IconRefresh className=\"size-4\" />\n            {t(\"credentials.device.polling\")}\n          </div>\n\n          {flow && (\n            <div className=\"bg-muted rounded-md border px-3 py-2 text-sm\">\n              {flowHint}\n            </div>\n          )}\n        </div>\n\n        <SheetFooter className=\"border-t-muted border-t px-6 py-4\">\n          <Button variant=\"ghost\" onClick={() => onOpenChange(false)}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button asChild disabled={!flow?.verify_url}>\n            <a href={flow?.verify_url || \"#\"} target=\"_blank\" rel=\"noreferrer\">\n              {t(\"credentials.device.open\")}\n            </a>\n          </Button>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/logout-confirm-dialog.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\n\ninterface LogoutConfirmDialogProps {\n  open: boolean\n  providerLabel: string\n  isSubmitting: boolean\n  onOpenChange: (open: boolean) => void\n  onConfirm: () => void | Promise<void>\n}\n\nexport function LogoutConfirmDialog({\n  open,\n  providerLabel,\n  isSubmitting,\n  onOpenChange,\n  onConfirm,\n}: LogoutConfirmDialogProps) {\n  const { t } = useTranslation()\n\n  return (\n    <AlertDialog open={open} onOpenChange={onOpenChange}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>\n            {t(\"credentials.logoutDialog.title\")}\n          </AlertDialogTitle>\n          <AlertDialogDescription>\n            {t(\n              \"credentials.logoutDialog.description\",\n              \"This will remove your saved credential for {{provider}}.\",\n              { provider: providerLabel },\n            )}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>{t(\"common.cancel\")}</AlertDialogCancel>\n          <AlertDialogAction onClick={onConfirm} variant=\"destructive\">\n            {isSubmitting && <IconLoader2 className=\"size-4 animate-spin\" />}\n            {t(\"credentials.actions.logout\")}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/openai-credential-card.tsx",
    "content": "import {\n  IconBrandOpenai,\n  IconClockHour4,\n  IconKey,\n  IconLoader2,\n  IconPlayerStopFilled,\n} from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { OAuthProviderStatus } from \"@/api/oauth\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\n\nimport { CredentialCard } from \"./credential-card\"\n\ninterface OpenAICredentialCardProps {\n  status?: OAuthProviderStatus\n  activeAction: string\n  token: string\n  onTokenChange: (value: string) => void\n  onStartBrowserOAuth: () => void\n  onStartDeviceCode: () => void\n  onStopLoading: () => void\n  onSaveToken: () => void\n  onAskLogout: () => void\n}\n\nexport function OpenAICredentialCard({\n  status,\n  activeAction,\n  token,\n  onTokenChange,\n  onStartBrowserOAuth,\n  onStartDeviceCode,\n  onStopLoading,\n  onSaveToken,\n  onAskLogout,\n}: OpenAICredentialCardProps) {\n  const { t } = useTranslation()\n  const actionBusy = activeAction !== \"\"\n  const browserLoading = activeAction === \"openai:browser\"\n  const deviceLoading = activeAction === \"openai:device\"\n  const oauthLoading = browserLoading || deviceLoading\n  const tokenLoading = activeAction === \"openai:token\"\n\n  return (\n    <CredentialCard\n      title={\n        <span className=\"inline-flex items-center gap-2\">\n          <span className=\"border-muted inline-flex size-6 items-center justify-center rounded-full border\">\n            <IconBrandOpenai className=\"size-3.5\" />\n          </span>\n          <span>OpenAI</span>\n        </span>\n      }\n      description={t(\"credentials.providers.openai.description\")}\n      status={status?.status ?? \"not_logged_in\"}\n      authMethod={status?.auth_method}\n      details={\n        status?.account_id ? (\n          <p>\n            {t(\"credentials.labels.account\")}: {status.account_id}\n          </p>\n        ) : null\n      }\n      actions={\n        <div className=\"border-muted flex h-[120px] flex-col rounded-lg border p-3\">\n          <div className=\"flex h-full flex-col gap-3\">\n            <div className=\"min-h-8\">\n              <div className=\"flex flex-nowrap items-center gap-2 overflow-x-auto\">\n                <Button\n                  size=\"sm\"\n                  variant=\"outline\"\n                  disabled={actionBusy}\n                  onClick={onStartBrowserOAuth}\n                >\n                  {browserLoading && (\n                    <IconLoader2 className=\"size-4 animate-spin\" />\n                  )}\n                  <IconBrandOpenai className=\"size-4\" />\n                  {t(\"credentials.actions.browser\")}\n                </Button>\n\n                {oauthLoading && !deviceLoading && (\n                  <Button\n                    size=\"icon-xs\"\n                    variant=\"secondary\"\n                    onClick={onStopLoading}\n                    className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                  >\n                    <IconPlayerStopFilled className=\"size-4\" />\n                  </Button>\n                )}\n\n                <Button\n                  size=\"sm\"\n                  variant=\"outline\"\n                  disabled={actionBusy}\n                  onClick={onStartDeviceCode}\n                >\n                  {deviceLoading && (\n                    <IconLoader2 className=\"size-4 animate-spin\" />\n                  )}\n                  <IconClockHour4 className=\"size-4\" />\n                  {t(\"credentials.actions.deviceCode\")}\n                </Button>\n              </div>\n            </div>\n\n            <div className=\"min-h-9 flex-1\">\n              <div className=\"flex h-full items-center gap-2\">\n                <Input\n                  value={token}\n                  onChange={(e) => onTokenChange(e.target.value)}\n                  type=\"password\"\n                  placeholder={t(\"credentials.fields.openaiToken\")}\n                />\n                <Button\n                  size=\"sm\"\n                  disabled={actionBusy || !token.trim()}\n                  onClick={onSaveToken}\n                >\n                  {tokenLoading && (\n                    <IconLoader2 className=\"size-4 animate-spin\" />\n                  )}\n                  <IconKey className=\"size-4\" />\n                  {t(\"credentials.actions.saveToken\")}\n                </Button>\n                {tokenLoading && (\n                  <Button\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                    onClick={onStopLoading}\n                    className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                  >\n                    <IconPlayerStopFilled className=\"size-4\" />\n                  </Button>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      }\n      footer={\n        status?.logged_in ? (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            disabled={actionBusy}\n            onClick={onAskLogout}\n            className=\"text-destructive hover:bg-destructive/10 hover:text-destructive\"\n          >\n            {activeAction === \"openai:logout\" && (\n              <IconLoader2 className=\"size-4 animate-spin\" />\n            )}\n            {t(\"credentials.actions.logout\")}\n          </Button>\n        ) : null\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/credentials/provider-status-line.tsx",
    "content": "import { useTranslation } from \"react-i18next\"\n\nimport type { OAuthProviderStatus } from \"@/api/oauth\"\n\ninterface ProviderStatusLineProps {\n  status: OAuthProviderStatus[\"status\"]\n  authMethod?: string\n}\n\nexport function ProviderStatusLine({\n  status,\n  authMethod,\n}: ProviderStatusLineProps) {\n  const { t } = useTranslation()\n\n  const style =\n    status === \"connected\"\n      ? \"bg-green-500/10 text-green-700 dark:text-green-300\"\n      : status === \"needs_refresh\"\n        ? \"bg-amber-500/10 text-amber-700 dark:text-amber-300\"\n        : status === \"expired\"\n          ? \"bg-red-500/10 text-red-700 dark:text-red-300\"\n          : \"bg-muted text-muted-foreground\"\n\n  return (\n    <div className=\"flex items-center justify-between gap-2\">\n      <span className={`rounded px-2 py-1 text-xs font-medium ${style}`}>\n        {status === \"connected\"\n          ? t(\"credentials.status.connected\")\n          : status === \"needs_refresh\"\n            ? t(\"credentials.status.needsRefresh\")\n            : status === \"expired\"\n              ? t(\"credentials.status.expired\")\n              : t(\"credentials.status.notLoggedIn\")}\n      </span>\n      {authMethod && (\n        <span className=\"text-muted-foreground text-xs uppercase\">\n          {authMethod}\n        </span>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/logs/ansi-log-line.tsx",
    "content": "import { Fragment, useMemo } from \"react\"\n\nimport { parseAnsiSegments, wrapLogLine } from \"@/lib/ansi-log\"\n\ntype AnsiLogLineProps = {\n  line: string\n  wrapColumns: number\n}\n\nexport function AnsiLogLine({ line, wrapColumns }: AnsiLogLineProps) {\n  const segments = useMemo(() => {\n    return parseAnsiSegments(wrapLogLine(line, wrapColumns))\n  }, [line, wrapColumns])\n\n  return (\n    <div className=\"break-normal whitespace-pre-wrap\">\n      {segments.map((segment, index) => (\n        <Fragment key={`${index}-${segment.text.length}`}>\n          <span style={segment.style}>{segment.text}</span>\n        </Fragment>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/logs/logs-page.tsx",
    "content": "import { IconTrash } from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { LogsPanel } from \"@/components/logs/logs-panel\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\nimport { useGatewayLogs } from \"@/hooks/use-gateway-logs\"\nimport { useLogWrapColumns } from \"@/hooks/use-log-wrap-columns\"\n\nexport function LogsPage() {\n  const { t } = useTranslation()\n  const { clearLogs, clearing, logs } = useGatewayLogs()\n  const { contentRef, measureRef, wrapColumns } = useLogWrapColumns()\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader\n        title={t(\"navigation.logs\")}\n        children={\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={clearLogs}\n            disabled={logs.length === 0 || clearing}\n          >\n            <IconTrash className=\"size-4\" />\n            {t(\"pages.logs.clear\")}\n          </Button>\n        }\n      />\n\n      <div className=\"flex flex-1 flex-col gap-4 overflow-hidden p-4 sm:p-8\">\n        <LogsPanel\n          logs={logs}\n          wrapColumns={wrapColumns}\n          contentRef={contentRef}\n          measureRef={measureRef}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/logs/logs-panel.tsx",
    "content": "import { type RefObject, useEffect, useRef } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { AnsiLogLine } from \"@/components/logs/ansi-log-line\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\n\ntype LogsPanelProps = {\n  logs: string[]\n  wrapColumns: number\n  contentRef: RefObject<HTMLDivElement | null>\n  measureRef: RefObject<HTMLSpanElement | null>\n}\n\nexport function LogsPanel({\n  logs,\n  wrapColumns,\n  contentRef,\n  measureRef,\n}: LogsPanelProps) {\n  const { t } = useTranslation()\n  const scrollRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    if (scrollRef.current) {\n      scrollRef.current.scrollIntoView({ behavior: \"smooth\" })\n    }\n  }, [logs])\n\n  return (\n    <div className=\"relative flex-1 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-100\">\n      <ScrollArea className=\"h-full\">\n        <div\n          ref={contentRef}\n          className=\"relative p-4 font-mono text-sm leading-relaxed\"\n        >\n          <span\n            ref={measureRef}\n            aria-hidden\n            className=\"pointer-events-none invisible absolute font-mono text-sm\"\n          >\n            0\n          </span>\n          {logs.length === 0 ? (\n            <div className=\"text-zinc-500 italic\">{t(\"pages.logs.empty\")}</div>\n          ) : (\n            logs.map((log, index) => (\n              <AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} />\n            ))\n          )}\n          <div ref={scrollRef} />\n        </div>\n      </ScrollArea>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/add-model-sheet.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { addModel, setDefaultModel } from \"@/api/models\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport {\n  AdvancedSection,\n  Field,\n  KeyInput,\n  SwitchCardField,\n} from \"@/components/shared-form\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\n\ninterface AddForm {\n  modelName: string\n  model: string\n  apiBase: string\n  apiKey: string\n  proxy: string\n  authMethod: string\n  connectMode: string\n  workspace: string\n  rpm: string\n  maxTokensField: string\n  requestTimeout: string\n  thinkingLevel: string\n}\n\nconst EMPTY_ADD_FORM: AddForm = {\n  modelName: \"\",\n  model: \"\",\n  apiBase: \"\",\n  apiKey: \"\",\n  proxy: \"\",\n  authMethod: \"\",\n  connectMode: \"\",\n  workspace: \"\",\n  rpm: \"\",\n  maxTokensField: \"\",\n  requestTimeout: \"\",\n  thinkingLevel: \"\",\n}\n\ninterface AddModelSheetProps {\n  open: boolean\n  onClose: () => void\n  onSaved: () => void\n  existingModelNames: string[]\n}\n\nexport function AddModelSheet({\n  open,\n  onClose,\n  onSaved,\n  existingModelNames,\n}: AddModelSheetProps) {\n  const { t } = useTranslation()\n  const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)\n  const [saving, setSaving] = useState(false)\n  const [setAsDefault, setSetAsDefault] = useState(false)\n  const [fieldErrors, setFieldErrors] = useState<\n    Partial<Record<keyof AddForm, string>>\n  >({})\n  const [serverError, setServerError] = useState(\"\")\n  const apiKeyPlaceholder = maskedSecretPlaceholder(\n    form.apiKey,\n    t(\"models.field.apiKeyPlaceholder\"),\n  )\n\n  useEffect(() => {\n    if (open) {\n      setForm(EMPTY_ADD_FORM)\n      setSetAsDefault(false)\n      setFieldErrors({})\n      setServerError(\"\")\n    }\n  }, [open])\n\n  const validate = (): boolean => {\n    const errors: Partial<Record<keyof AddForm, string>> = {}\n    const modelName = form.modelName.trim()\n    if (!modelName) {\n      errors.modelName = t(\"models.add.errorRequired\")\n    } else if (existingModelNames.some((name) => name.trim() === modelName)) {\n      errors.modelName = t(\"models.add.errorDuplicateModelName\")\n    }\n    if (!form.model.trim()) errors.model = t(\"models.add.errorRequired\")\n    setFieldErrors(errors)\n    return Object.keys(errors).length === 0\n  }\n\n  const setField =\n    (key: keyof AddForm) => (e: React.ChangeEvent<HTMLInputElement>) => {\n      setForm((f) => ({ ...f, [key]: e.target.value }))\n      if (fieldErrors[key]) {\n        setFieldErrors((prev) => ({ ...prev, [key]: undefined }))\n      }\n    }\n\n  const handleSave = async () => {\n    if (!validate()) return\n    setSaving(true)\n    setServerError(\"\")\n    try {\n      const modelName = form.modelName.trim()\n      const modelId = form.model.trim()\n      await addModel({\n        model_name: modelName,\n        model: modelId,\n        api_base: form.apiBase.trim() || undefined,\n        api_key: form.apiKey.trim() || undefined,\n        proxy: form.proxy.trim() || undefined,\n        auth_method: form.authMethod.trim() || undefined,\n        connect_mode: form.connectMode.trim() || undefined,\n        workspace: form.workspace.trim() || undefined,\n        rpm: form.rpm ? Number(form.rpm) : undefined,\n        max_tokens_field: form.maxTokensField.trim() || undefined,\n        request_timeout: form.requestTimeout\n          ? Number(form.requestTimeout)\n          : undefined,\n        thinking_level: form.thinkingLevel.trim() || undefined,\n      })\n      if (setAsDefault) {\n        await setDefaultModel(modelName)\n      }\n      onSaved()\n      onClose()\n    } catch (e) {\n      setServerError(e instanceof Error ? e.message : t(\"models.add.saveError\"))\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  return (\n    <Sheet open={open} onOpenChange={(v) => !v && onClose()}>\n      <SheetContent\n        side=\"right\"\n        className=\"flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]\"\n      >\n        <SheetHeader className=\"border-b-muted border-b px-6 py-5\">\n          <SheetTitle className=\"text-base\">{t(\"models.add.title\")}</SheetTitle>\n          <SheetDescription className=\"text-xs\">\n            {t(\"models.add.description\")}\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto\">\n          <div className=\"space-y-5 px-6 py-5\">\n            <Field\n              label={t(\"models.add.modelName\")}\n              hint={t(\"models.add.modelNameHint\")}\n            >\n              <Input\n                value={form.modelName}\n                onChange={setField(\"modelName\")}\n                placeholder={t(\"models.add.modelNamePlaceholder\")}\n                aria-invalid={!!fieldErrors.modelName}\n              />\n              {fieldErrors.modelName && (\n                <p className=\"text-destructive text-xs\">\n                  {fieldErrors.modelName}\n                </p>\n              )}\n            </Field>\n\n            <Field\n              label={t(\"models.add.modelId\")}\n              hint={t(\"models.add.modelIdHint\")}\n            >\n              <Input\n                value={form.model}\n                onChange={setField(\"model\")}\n                placeholder={t(\"models.add.modelIdPlaceholder\")}\n                className=\"font-mono text-sm\"\n                aria-invalid={!!fieldErrors.model}\n              />\n              {fieldErrors.model && (\n                <p className=\"text-destructive text-xs\">{fieldErrors.model}</p>\n              )}\n            </Field>\n\n            <Field label={t(\"models.field.apiKey\")}>\n              <KeyInput\n                value={form.apiKey}\n                onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}\n                placeholder={apiKeyPlaceholder}\n              />\n            </Field>\n\n            <Field label={t(\"models.field.apiBase\")}>\n              <Input\n                value={form.apiBase}\n                onChange={setField(\"apiBase\")}\n                placeholder=\"https://api.example.com/v1\"\n              />\n            </Field>\n\n            <SwitchCardField\n              label={t(\"models.defaultOnSave.label\")}\n              hint={t(\"models.defaultOnSave.description\")}\n              checked={setAsDefault}\n              onCheckedChange={setSetAsDefault}\n            />\n\n            <AdvancedSection>\n              <Field\n                label={t(\"models.field.proxy\")}\n                hint={t(\"models.field.proxyHint\")}\n              >\n                <Input\n                  value={form.proxy}\n                  onChange={setField(\"proxy\")}\n                  placeholder=\"http://127.0.0.1:7890\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.authMethod\")}\n                hint={t(\"models.field.authMethodHint\")}\n              >\n                <Input\n                  value={form.authMethod}\n                  onChange={setField(\"authMethod\")}\n                  placeholder=\"oauth\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.connectMode\")}\n                hint={t(\"models.field.connectModeHint\")}\n              >\n                <Input\n                  value={form.connectMode}\n                  onChange={setField(\"connectMode\")}\n                  placeholder=\"stdio\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.workspace\")}\n                hint={t(\"models.field.workspaceHint\")}\n              >\n                <Input\n                  value={form.workspace}\n                  onChange={setField(\"workspace\")}\n                  placeholder=\"/path/to/workspace\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.requestTimeout\")}\n                hint={t(\"models.field.requestTimeoutHint\")}\n              >\n                <Input\n                  value={form.requestTimeout}\n                  onChange={setField(\"requestTimeout\")}\n                  placeholder=\"60\"\n                  type=\"number\"\n                  min={0}\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.rpm\")}\n                hint={t(\"models.field.rpmHint\")}\n              >\n                <Input\n                  value={form.rpm}\n                  onChange={setField(\"rpm\")}\n                  placeholder=\"60\"\n                  type=\"number\"\n                  min={0}\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.thinkingLevel\")}\n                hint={t(\"models.field.thinkingLevelHint\")}\n              >\n                <Input\n                  value={form.thinkingLevel}\n                  onChange={setField(\"thinkingLevel\")}\n                  placeholder=\"off\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.maxTokensField\")}\n                hint={t(\"models.field.maxTokensFieldHint\")}\n              >\n                <Input\n                  value={form.maxTokensField}\n                  onChange={setField(\"maxTokensField\")}\n                  placeholder=\"max_completion_tokens\"\n                />\n              </Field>\n            </AdvancedSection>\n\n            {serverError && (\n              <p className=\"text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm\">\n                {serverError}\n              </p>\n            )}\n          </div>\n        </div>\n\n        <SheetFooter className=\"border-t-muted border-t px-6 py-4\">\n          <Button variant=\"ghost\" onClick={onClose} disabled={saving}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button onClick={handleSave} disabled={saving}>\n            {saving && <IconLoader2 className=\"size-4 animate-spin\" />}\n            {t(\"models.add.confirm\")}\n          </Button>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/delete-model-dialog.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { type ModelInfo, deleteModel } from \"@/api/models\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\n\ninterface DeleteModelDialogProps {\n  model: ModelInfo | null\n  onClose: () => void\n  onDeleted: () => void\n}\n\nexport function DeleteModelDialog({\n  model,\n  onClose,\n  onDeleted,\n}: DeleteModelDialogProps) {\n  const { t } = useTranslation()\n  const [deleting, setDeleting] = useState(false)\n\n  const handleConfirm = async () => {\n    if (!model) return\n    if (model.is_default) {\n      onClose()\n      return\n    }\n    setDeleting(true)\n    try {\n      await deleteModel(model.index)\n      onDeleted()\n    } catch {\n      // ignore, user can retry from list\n    } finally {\n      setDeleting(false)\n      onClose()\n    }\n  }\n\n  return (\n    <AlertDialog open={model !== null} onOpenChange={(v) => !v && onClose()}>\n      <AlertDialogContent size=\"sm\">\n        <AlertDialogHeader>\n          <AlertDialogTitle>{t(\"models.delete.title\")}</AlertDialogTitle>\n          <AlertDialogDescription>\n            {t(\"models.delete.description\", { name: model?.model_name })}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel onClick={onClose} disabled={deleting}>\n            {t(\"common.cancel\")}\n          </AlertDialogCancel>\n          <AlertDialogAction\n            variant=\"destructive\"\n            onClick={handleConfirm}\n            disabled={deleting}\n          >\n            {deleting && <IconLoader2 className=\"size-4 animate-spin\" />}\n            {t(\"models.delete.confirm\")}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/edit-model-sheet.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { type ModelInfo, setDefaultModel, updateModel } from \"@/api/models\"\nimport { maskedSecretPlaceholder } from \"@/components/secret-placeholder\"\nimport {\n  AdvancedSection,\n  Field,\n  KeyInput,\n  SwitchCardField,\n} from \"@/components/shared-form\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\n\ninterface EditForm {\n  apiKey: string\n  apiBase: string\n  proxy: string\n  authMethod: string\n  connectMode: string\n  workspace: string\n  rpm: string\n  maxTokensField: string\n  requestTimeout: string\n  thinkingLevel: string\n}\n\ninterface EditModelSheetProps {\n  model: ModelInfo | null\n  open: boolean\n  onClose: () => void\n  onSaved: () => void\n}\n\nexport function EditModelSheet({\n  model,\n  open,\n  onClose,\n  onSaved,\n}: EditModelSheetProps) {\n  const { t } = useTranslation()\n  const [form, setForm] = useState<EditForm>({\n    apiKey: \"\",\n    apiBase: \"\",\n    proxy: \"\",\n    authMethod: \"\",\n    connectMode: \"\",\n    workspace: \"\",\n    rpm: \"\",\n    maxTokensField: \"\",\n    requestTimeout: \"\",\n    thinkingLevel: \"\",\n  })\n  const [saving, setSaving] = useState(false)\n  const [setAsDefault, setSetAsDefault] = useState(false)\n  const [error, setError] = useState(\"\")\n\n  useEffect(() => {\n    if (model) {\n      setForm({\n        apiKey: \"\",\n        apiBase: model.api_base ?? \"\",\n        proxy: model.proxy ?? \"\",\n        authMethod: model.auth_method ?? \"\",\n        connectMode: model.connect_mode ?? \"\",\n        workspace: model.workspace ?? \"\",\n        rpm: model.rpm ? String(model.rpm) : \"\",\n        maxTokensField: model.max_tokens_field ?? \"\",\n        requestTimeout: model.request_timeout\n          ? String(model.request_timeout)\n          : \"\",\n        thinkingLevel: model.thinking_level ?? \"\",\n      })\n      setSetAsDefault(model.is_default)\n      setError(\"\")\n    }\n  }, [model])\n\n  const setField =\n    (key: keyof EditForm) => (e: React.ChangeEvent<HTMLInputElement>) =>\n      setForm((f) => ({ ...f, [key]: e.target.value }))\n\n  const handleSave = async () => {\n    if (!model) return\n    setSaving(true)\n    setError(\"\")\n    try {\n      await updateModel(model.index, {\n        model_name: model.model_name,\n        model: model.model,\n        api_base: form.apiBase || undefined,\n        api_key: form.apiKey || undefined,\n        proxy: form.proxy || undefined,\n        auth_method: form.authMethod || undefined,\n        connect_mode: form.connectMode || undefined,\n        workspace: form.workspace || undefined,\n        rpm: form.rpm ? Number(form.rpm) : undefined,\n        max_tokens_field: form.maxTokensField || undefined,\n        request_timeout: form.requestTimeout\n          ? Number(form.requestTimeout)\n          : undefined,\n        thinking_level: form.thinkingLevel || undefined,\n      })\n      if (setAsDefault && !model.is_default) {\n        await setDefaultModel(model.model_name)\n      }\n      onSaved()\n      onClose()\n    } catch (e) {\n      setError(e instanceof Error ? e.message : t(\"models.edit.saveError\"))\n    } finally {\n      setSaving(false)\n    }\n  }\n\n  const isOAuth = model?.auth_method === \"oauth\"\n  const apiKeyPlaceholder = model?.configured\n    ? maskedSecretPlaceholder(\n        model.api_key,\n        t(\"models.field.apiKeyPlaceholderSet\"),\n      )\n    : t(\"models.field.apiKeyPlaceholder\")\n\n  return (\n    <Sheet open={open} onOpenChange={(v) => !v && onClose()}>\n      <SheetContent\n        side=\"right\"\n        className=\"flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]\"\n      >\n        <SheetHeader className=\"border-b-muted border-b px-6 py-5\">\n          <SheetTitle className=\"text-base\">\n            {t(\"models.edit.title\", { name: model?.model_name })}\n          </SheetTitle>\n          <SheetDescription className=\"font-mono text-xs\">\n            {model?.model}\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto\">\n          <div className=\"space-y-5 px-6 py-5\">\n            {!isOAuth && (\n              <Field\n                label={t(\"models.field.apiKey\")}\n                hint={\n                  model?.configured ? t(\"models.edit.apiKeyHint\") : undefined\n                }\n              >\n                <KeyInput\n                  value={form.apiKey}\n                  onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}\n                  placeholder={apiKeyPlaceholder}\n                />\n              </Field>\n            )}\n\n            <Field\n              label={t(\"models.field.apiBase\")}\n              hint={isOAuth ? t(\"models.edit.oauthNote\") : undefined}\n            >\n              <Input\n                value={form.apiBase}\n                onChange={setField(\"apiBase\")}\n                placeholder=\"https://api.example.com/v1\"\n                disabled={isOAuth}\n              />\n            </Field>\n\n            <SwitchCardField\n              label={t(\"models.defaultOnSave.label\")}\n              hint={t(\"models.defaultOnSave.description\")}\n              checked={setAsDefault}\n              onCheckedChange={setSetAsDefault}\n            />\n\n            <AdvancedSection>\n              <Field\n                label={t(\"models.field.proxy\")}\n                hint={t(\"models.field.proxyHint\")}\n              >\n                <Input\n                  value={form.proxy}\n                  onChange={setField(\"proxy\")}\n                  placeholder=\"http://127.0.0.1:7890\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.authMethod\")}\n                hint={t(\"models.field.authMethodHint\")}\n              >\n                <Input\n                  value={form.authMethod}\n                  onChange={setField(\"authMethod\")}\n                  placeholder=\"oauth\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.connectMode\")}\n                hint={t(\"models.field.connectModeHint\")}\n              >\n                <Input\n                  value={form.connectMode}\n                  onChange={setField(\"connectMode\")}\n                  placeholder=\"stdio\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.workspace\")}\n                hint={t(\"models.field.workspaceHint\")}\n              >\n                <Input\n                  value={form.workspace}\n                  onChange={setField(\"workspace\")}\n                  placeholder=\"/path/to/workspace\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.requestTimeout\")}\n                hint={t(\"models.field.requestTimeoutHint\")}\n              >\n                <Input\n                  value={form.requestTimeout}\n                  onChange={setField(\"requestTimeout\")}\n                  placeholder=\"60\"\n                  type=\"number\"\n                  min={0}\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.rpm\")}\n                hint={t(\"models.field.rpmHint\")}\n              >\n                <Input\n                  value={form.rpm}\n                  onChange={setField(\"rpm\")}\n                  placeholder=\"60\"\n                  type=\"number\"\n                  min={0}\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.thinkingLevel\")}\n                hint={t(\"models.field.thinkingLevelHint\")}\n              >\n                <Input\n                  value={form.thinkingLevel}\n                  onChange={setField(\"thinkingLevel\")}\n                  placeholder=\"off\"\n                />\n              </Field>\n\n              <Field\n                label={t(\"models.field.maxTokensField\")}\n                hint={t(\"models.field.maxTokensFieldHint\")}\n              >\n                <Input\n                  value={form.maxTokensField}\n                  onChange={setField(\"maxTokensField\")}\n                  placeholder=\"max_completion_tokens\"\n                />\n              </Field>\n            </AdvancedSection>\n\n            {error && (\n              <p className=\"text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm\">\n                {error}\n              </p>\n            )}\n          </div>\n        </div>\n\n        <SheetFooter className=\"border-t-muted border-t px-6 py-4\">\n          <Button variant=\"ghost\" onClick={onClose} disabled={saving}>\n            {t(\"common.cancel\")}\n          </Button>\n          <Button onClick={handleSave} disabled={saving}>\n            {saving && <IconLoader2 className=\"size-4 animate-spin\" />}\n            {t(\"common.save\")}\n          </Button>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/model-card.tsx",
    "content": "import {\n  IconEdit,\n  IconKey,\n  IconLoader2,\n  IconStar,\n  IconStarFilled,\n  IconTrash,\n} from \"@tabler/icons-react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport type { ModelInfo } from \"@/api/models\"\nimport { Button } from \"@/components/ui/button\"\n\ninterface ModelCardProps {\n  model: ModelInfo\n  onEdit: (model: ModelInfo) => void\n  onSetDefault: (model: ModelInfo) => void\n  onDelete: (model: ModelInfo) => void\n  settingDefault: boolean\n}\n\nexport function ModelCard({\n  model,\n  onEdit,\n  onSetDefault,\n  onDelete,\n  settingDefault,\n}: ModelCardProps) {\n  const { t } = useTranslation()\n  const isOAuth = model.auth_method === \"oauth\"\n  const canSetDefault = model.configured && !model.is_default\n\n  return (\n    <div\n      className={[\n        \"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs\",\n        model.configured\n          ? \"border-border/60 bg-card\"\n          : \"border-border/50 bg-card/60\",\n      ].join(\" \")}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <span\n            className={[\n              \"mt-0.5 h-2 w-2 shrink-0 rounded-full\",\n              model.is_default\n                ? \"bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]\"\n                : model.configured\n                  ? \"bg-green-500\"\n                  : \"bg-muted-foreground/25\",\n            ].join(\" \")}\n            title={\n              model.configured\n                ? t(\"models.status.configured\")\n                : t(\"models.status.unconfigured\")\n            }\n          />\n          <span className=\"text-foreground truncate text-sm font-semibold\">\n            {model.model_name}\n          </span>\n          {model.is_default && (\n            <span className=\"bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] leading-none font-medium\">\n              {t(\"models.badge.default\")}\n            </span>\n          )}\n        </div>\n\n        <div className=\"flex shrink-0 items-center gap-0.5\">\n          {model.is_default ? (\n            <span\n              className=\"text-primary p-1\"\n              title={t(\"models.badge.default\")}\n            >\n              <IconStarFilled className=\"size-3.5\" />\n            </span>\n          ) : (\n            <Button\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              onClick={() => onSetDefault(model)}\n              disabled={settingDefault || !canSetDefault}\n              title={t(\"models.action.setDefault\")}\n            >\n              {settingDefault ? (\n                <IconLoader2 className=\"size-3.5 animate-spin\" />\n              ) : (\n                <IconStar className=\"size-3.5\" />\n              )}\n            </Button>\n          )}\n\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            onClick={() => onEdit(model)}\n            title={t(\"models.action.edit\")}\n          >\n            <IconEdit className=\"size-3.5\" />\n          </Button>\n\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            onClick={() => onDelete(model)}\n            disabled={model.is_default}\n            title={t(\"models.action.delete\")}\n            className=\"text-muted-foreground hover:text-destructive hover:bg-destructive/10\"\n          >\n            <IconTrash className=\"size-3.5\" />\n          </Button>\n        </div>\n      </div>\n\n      <p className=\"text-muted-foreground truncate font-mono text-xs leading-snug\">\n        {model.model}\n      </p>\n\n      <div className=\"flex items-center gap-2\">\n        {isOAuth ? (\n          <span className=\"text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium\">\n            OAuth\n          </span>\n        ) : model.configured && model.api_key ? (\n          <span className=\"text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]\">\n            <IconKey className=\"size-3\" />\n            {model.api_key}\n          </span>\n        ) : (\n          <span className=\"text-muted-foreground/50 text-[11px]\">\n            {t(\"models.status.unconfigured\")}\n          </span>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/models-page.tsx",
    "content": "import { IconLoader2, IconPlus, IconStar } from \"@tabler/icons-react\"\nimport { useCallback, useEffect, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { type ModelInfo, getModels, setDefaultModel } from \"@/api/models\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\n\nimport { AddModelSheet } from \"./add-model-sheet\"\nimport { DeleteModelDialog } from \"./delete-model-dialog\"\nimport { EditModelSheet } from \"./edit-model-sheet\"\nimport { getProviderKey, getProviderLabel } from \"./provider-label\"\nimport { ProviderSection } from \"./provider-section\"\n\nconst PROVIDER_PRIORITY: Record<string, number> = {\n  volcengine: 0,\n  openai: 1,\n  gemini: 2,\n  anthropic: 3,\n  zhipu: 4,\n  deepseek: 5,\n  openrouter: 6,\n  qwen: 7,\n  moonshot: 8,\n  groq: 9,\n  \"github-copilot\": 10,\n  antigravity: 11,\n  nvidia: 12,\n  cerebras: 13,\n  shengsuanyun: 14,\n  ollama: 15,\n  vllm: 16,\n  mistral: 17,\n  avian: 18,\n}\n\ninterface ProviderGroup {\n  key: string\n  label: string\n  models: ModelInfo[]\n  hasDefault: boolean\n  configuredCount: number\n}\n\nexport function ModelsPage() {\n  const { t } = useTranslation()\n  const [models, setModels] = useState<ModelInfo[]>([])\n  const [loading, setLoading] = useState(true)\n  const [fetchError, setFetchError] = useState(\"\")\n\n  const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)\n  const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)\n  const [addOpen, setAddOpen] = useState(false)\n  const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(\n    null,\n  )\n\n  const fetchModels = useCallback(async () => {\n    try {\n      const data = await getModels()\n      const sorted = [...data.models].sort((a, b) => {\n        if (a.is_default && !b.is_default) return -1\n        if (!a.is_default && b.is_default) return 1\n        if (a.configured && !b.configured) return -1\n        if (!a.configured && b.configured) return 1\n        return a.model_name.localeCompare(b.model_name)\n      })\n      setModels(sorted)\n      setFetchError(\"\")\n    } catch (e) {\n      setFetchError(e instanceof Error ? e.message : t(\"models.loadError\"))\n    } finally {\n      setLoading(false)\n    }\n  }, [t])\n\n  useEffect(() => {\n    fetchModels()\n  }, [fetchModels])\n\n  const handleSetDefault = async (model: ModelInfo) => {\n    if (model.is_default) return\n\n    setSettingDefaultIndex(model.index)\n    try {\n      await setDefaultModel(model.model_name)\n      await fetchModels()\n    } catch {\n      // ignore\n    } finally {\n      setSettingDefaultIndex(null)\n    }\n  }\n\n  const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}\n  for (const model of models) {\n    const providerKey = getProviderKey(model.model)\n    if (!grouped[providerKey]) {\n      grouped[providerKey] = {\n        label: getProviderLabel(model.model),\n        models: [],\n      }\n    }\n    grouped[providerKey].models.push(model)\n  }\n\n  const providerGroups: ProviderGroup[] = Object.entries(grouped)\n    .map(([key, group]) => {\n      const configuredCount = group.models.filter(\n        (model) => model.configured,\n      ).length\n      return {\n        key,\n        label: group.label,\n        models: group.models,\n        hasDefault: group.models.some((model) => model.is_default),\n        configuredCount,\n      }\n    })\n    .sort((a, b) => {\n      if (a.hasDefault && !b.hasDefault) return -1\n      if (!a.hasDefault && b.hasDefault) return 1\n\n      if (a.configuredCount !== b.configuredCount) {\n        return b.configuredCount - a.configuredCount\n      }\n\n      const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER\n      const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER\n      if (aPriority !== bPriority) {\n        return aPriority - bPriority\n      }\n\n      return a.label.localeCompare(b.label)\n    })\n\n  const defaultModel = models.find((model) => model.is_default)\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader title={t(\"navigation.models\")}>\n        <div className=\"flex items-center gap-3\">\n          <Button size=\"sm\" variant=\"outline\" onClick={() => setAddOpen(true)}>\n            <IconPlus className=\"size-4\" />\n            {t(\"models.add.button\")}\n          </Button>\n        </div>\n      </PageHeader>\n\n      <div className=\"min-h-0 flex-1 overflow-y-auto px-4 sm:px-6\">\n        <div className=\"pt-2\">\n          {!defaultModel && (\n            <div className=\"text-muted-foreground flex items-center gap-1.5 text-sm\">\n              <span>{t(\"models.noDefaultHintPrefix\")}</span>\n              <IconStar className=\"size-3.5 shrink-0\" />\n              <span>{t(\"models.noDefaultHintSuffix\")}</span>\n            </div>\n          )}\n          <p className=\"text-muted-foreground mt-1 text-sm\">\n            {t(\"models.description\")}\n          </p>\n        </div>\n\n        {loading && (\n          <div className=\"flex items-center justify-center py-20\">\n            <IconLoader2 className=\"text-muted-foreground size-6 animate-spin\" />\n          </div>\n        )}\n\n        {fetchError && (\n          <div className=\"text-destructive bg-destructive/10 rounded-lg px-4 py-3 text-sm\">\n            {fetchError}\n          </div>\n        )}\n\n        {!loading && !fetchError && (\n          <div className=\"pb-8\">\n            {providerGroups.map((providerGroup) => (\n              <ProviderSection\n                key={providerGroup.key}\n                provider={providerGroup.label}\n                providerKey={providerGroup.key}\n                models={providerGroup.models}\n                onEdit={setEditingModel}\n                onSetDefault={handleSetDefault}\n                onDelete={setDeletingModel}\n                settingDefaultIndex={settingDefaultIndex}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      <EditModelSheet\n        model={editingModel}\n        open={editingModel !== null}\n        onClose={() => setEditingModel(null)}\n        onSaved={fetchModels}\n      />\n\n      <AddModelSheet\n        open={addOpen}\n        onClose={() => setAddOpen(false)}\n        onSaved={fetchModels}\n        existingModelNames={models.map((model) => model.model_name)}\n      />\n\n      <DeleteModelDialog\n        model={deletingModel}\n        onClose={() => setDeletingModel(null)}\n        onDeleted={fetchModels}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/provider-icon.tsx",
    "content": "import { useMemo, useState } from \"react\"\n\nconst PROVIDER_ICON_SLUGS: Record<string, string> = {\n  openai: \"openai\",\n  anthropic: \"anthropic\",\n  gemini: \"googlegemini\",\n  deepseek: \"deepseek\",\n  qwen: \"alibabacloud\",\n  groq: \"groq\",\n  openrouter: \"openrouter\",\n  nvidia: \"nvidia\",\n  cerebras: \"cerebras\",\n  volcengine: \"bytedance\",\n  \"github-copilot\": \"githubcopilot\",\n  ollama: \"ollama\",\n  mistral: \"mistralai\",\n  zhipu: \"zhipu\",\n}\n\nconst PROVIDER_DOMAINS: Record<string, string> = {\n  openai: \"openai.com\",\n  anthropic: \"anthropic.com\",\n  gemini: \"gemini.google.com\",\n  deepseek: \"deepseek.com\",\n  qwen: \"qwenlm.ai\",\n  moonshot: \"moonshot.ai\",\n  groq: \"groq.com\",\n  openrouter: \"openrouter.ai\",\n  nvidia: \"nvidia.com\",\n  cerebras: \"cerebras.ai\",\n  volcengine: \"volcengine.com\",\n  shengsuanyun: \"shengsuanyun.com\",\n  antigravity: \"antigravity.google\",\n  \"github-copilot\": \"github.com\",\n  ollama: \"ollama.com\",\n  mistral: \"mistral.ai\",\n  avian: \"avian.io\",\n  vllm: \"vllm.ai\",\n  zhipu: \"zhipuai.cn\",\n}\n\ninterface ProviderIconProps {\n  providerKey: string\n  providerLabel: string\n}\n\nexport function ProviderIcon({\n  providerKey,\n  providerLabel,\n}: ProviderIconProps) {\n  const [sourceIndex, setSourceIndex] = useState(0)\n  const [loadFailed, setLoadFailed] = useState(false)\n  const initial = providerLabel.trim().charAt(0).toUpperCase() || \"?\"\n  const iconUrls = useMemo(() => {\n    const slug = PROVIDER_ICON_SLUGS[providerKey]\n    const domain = PROVIDER_DOMAINS[providerKey]\n    const urls: string[] = []\n    if (slug) {\n      urls.push(`https://cdn.simpleicons.org/${slug}`)\n    }\n    if (domain) {\n      urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`)\n    }\n    return urls\n  }, [providerKey])\n\n  const iconUrl = iconUrls[sourceIndex]\n\n  if (!iconUrl || loadFailed) {\n    return (\n      <span className=\"inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70\">\n        {initial}\n      </span>\n    )\n  }\n\n  return (\n    <span className=\"inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-black/10 bg-white p-0.5 dark:border-white/20\">\n      <img\n        src={iconUrl}\n        alt={`${providerLabel} logo`}\n        className=\"size-full object-contain\"\n        loading=\"lazy\"\n        referrerPolicy=\"no-referrer\"\n        onError={() => {\n          if (sourceIndex < iconUrls.length - 1) {\n            setSourceIndex((idx) => idx + 1)\n            return\n          }\n          setLoadFailed(true)\n        }}\n      />\n    </span>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/provider-label.ts",
    "content": "const PROVIDER_LABELS: Record<string, string> = {\n  openai: \"OpenAI\",\n  anthropic: \"Anthropic\",\n  gemini: \"Google Gemini\",\n  deepseek: \"DeepSeek\",\n  qwen: \"Qwen (阿里云)\",\n  moonshot: \"Moonshot (月之暗面)\",\n  groq: \"Groq\",\n  openrouter: \"OpenRouter\",\n  nvidia: \"NVIDIA\",\n  cerebras: \"Cerebras\",\n  volcengine: \"Volcengine (火山引擎)\",\n  shengsuanyun: \"ShengsuanYun (神算云)\",\n  antigravity: \"Google Code Assist\",\n  \"github-copilot\": \"GitHub Copilot\",\n  ollama: \"Ollama (local)\",\n  mistral: \"Mistral AI\",\n  avian: \"Avian\",\n  vllm: \"VLLM (local)\",\n  zhipu: \"Zhipu AI (智谱)\",\n}\n\nexport function getProviderKey(model: string): string {\n  return model.split(\"/\")[0]\n}\n\nexport function getProviderLabel(model: string): string {\n  const prefix = getProviderKey(model)\n  const labels: Record<string, string> = {\n    ...PROVIDER_LABELS,\n  }\n  return labels[prefix] ?? prefix\n}\n"
  },
  {
    "path": "web/frontend/src/components/models/provider-section.tsx",
    "content": "import { IconChevronDown } from \"@tabler/icons-react\"\nimport { useState } from \"react\"\n\nimport type { ModelInfo } from \"@/api/models\"\n\nimport { ModelCard } from \"./model-card\"\nimport { ProviderIcon } from \"./provider-icon\"\n\ninterface ProviderSectionProps {\n  provider: string\n  providerKey: string\n  models: ModelInfo[]\n  onEdit: (model: ModelInfo) => void\n  onSetDefault: (model: ModelInfo) => void\n  onDelete: (model: ModelInfo) => void\n  settingDefaultIndex: number | null\n}\n\nexport function ProviderSection({\n  provider,\n  providerKey,\n  models,\n  onEdit,\n  onSetDefault,\n  onDelete,\n  settingDefaultIndex,\n}: ProviderSectionProps) {\n  const [open, setOpen] = useState(true)\n\n  return (\n    <section className=\"my-8\">\n      <button\n        type=\"button\"\n        onClick={() => setOpen((v) => !v)}\n        className=\"mb-3 grid w-full grid-cols-[1fr_auto_1fr_auto] items-center gap-2 px-1 py-1.5 text-left\"\n        aria-expanded={open}\n      >\n        <div className=\"border-border/40 border-t\" />\n        <span className=\"text-foreground/80 text-center text-xs font-semibold tracking-wide uppercase\">\n          <span className=\"bg-background inline-flex items-center gap-1.5 px-2\">\n            <ProviderIcon providerKey={providerKey} providerLabel={provider} />\n            {provider}\n          </span>\n        </span>\n        <div className=\"border-border/40 border-t\" />\n        <span className=\"flex justify-end\">\n          <IconChevronDown\n            className={[\n              \"text-muted-foreground size-4 transition-transform\",\n              open ? \"rotate-180\" : \"\",\n            ].join(\" \")}\n          />\n        </span>\n      </button>\n\n      {open && (\n        <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n          {models.map((model) => (\n            <ModelCard\n              key={model.index}\n              model={model}\n              onEdit={onEdit}\n              onSetDefault={onSetDefault}\n              onDelete={onDelete}\n              settingDefault={settingDefaultIndex === model.index}\n            />\n          ))}\n        </div>\n      )}\n    </section>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/page-header.tsx",
    "content": "import { IconMenu2 } from \"@tabler/icons-react\"\nimport type { ReactNode } from \"react\"\n\nimport { SidebarTrigger } from \"@/components/ui/sidebar\"\nimport { cn } from \"@/lib/utils\"\n\ninterface PageHeaderProps {\n  title: string\n  titleExtra?: ReactNode\n  children?: ReactNode\n  className?: string\n}\n\nexport function PageHeader({\n  title,\n  titleExtra,\n  children,\n  className,\n}: PageHeaderProps) {\n  return (\n    <div\n      className={cn(\n        \"z-40 flex h-14 shrink-0 items-center justify-between px-6 pt-2\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-4\">\n        <SidebarTrigger className=\"border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5\">\n          <IconMenu2 />\n        </SidebarTrigger>\n        <h2 className=\"text-foreground/90 text-xl font-medium tracking-tight\">\n          {title}\n        </h2>\n        {titleExtra}\n      </div>\n      {children && <div className=\"flex items-center gap-2\">{children}</div>}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/secret-placeholder.ts",
    "content": "export function maskedSecretPlaceholder(value: unknown, fallback = \"\"): string {\n  const secret = typeof value === \"string\" ? value.trim() : \"\"\n  if (!secret) {\n    return fallback\n  }\n\n  if (secret.length < 7) {\n    const first = secret[0]\n    const last = secret[secret.length - 1]\n    return `${first}***${last}`\n  }\n\n  const prefix = secret.slice(0, Math.min(3, secret.length))\n  const suffix = secret.slice(-Math.min(4, secret.length))\n  return `${prefix}***${suffix}`\n}\n"
  },
  {
    "path": "web/frontend/src/components/shared-form.tsx",
    "content": "import { IconChevronDown, IconEye, IconEyeOff } from \"@tabler/icons-react\"\nimport { type ReactNode, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  FieldDescription,\n  FieldLabel,\n  Field as UiField,\n} from \"@/components/ui/field\"\nimport { Input } from \"@/components/ui/input\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { cn } from \"@/lib/utils\"\n\ntype FieldLayout = \"default\" | \"setting-row\"\n\ninterface FieldProps {\n  label: string\n  hint?: string\n  error?: string\n  required?: boolean\n  children: ReactNode\n  layout?: FieldLayout\n  controlClassName?: string\n}\n\nexport function Field({\n  label,\n  hint,\n  error,\n  required,\n  children,\n  layout = \"default\",\n  controlClassName,\n}: FieldProps) {\n  if (layout === \"setting-row\") {\n    return (\n      <div className=\"flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6\">\n        <div className=\"max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]\">\n          <FieldLabel>\n            {label}\n            {required && <span className=\"text-destructive ml-1\">*</span>}\n          </FieldLabel>\n          {hint && (\n            <FieldDescription className=\"text-xs leading-normal break-words\">\n              {hint}\n            </FieldDescription>\n          )}\n        </div>\n        <div className={cn(\"w-full md:justify-self-center\", controlClassName)}>\n          {children}\n        </div>\n        {error && (\n          <FieldDescription className=\"text-destructive text-xs leading-normal md:col-start-2\">\n            {error}\n          </FieldDescription>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <UiField className=\"gap-2.5\">\n      <div className=\"space-y-1\">\n        <FieldLabel>\n          {label}\n          {required && <span className=\"text-destructive ml-1\">*</span>}\n        </FieldLabel>\n        {hint && (\n          <FieldDescription className=\"text-xs leading-normal\">\n            {hint}\n          </FieldDescription>\n        )}\n      </div>\n      {children}\n      {error && (\n        <FieldDescription className=\"text-destructive text-xs leading-normal\">\n          {error}\n        </FieldDescription>\n      )}\n    </UiField>\n  )\n}\n\ninterface KeyInputProps {\n  value: string\n  onChange: (v: string) => void\n  placeholder?: string\n}\n\nexport function KeyInput({ value, onChange, placeholder }: KeyInputProps) {\n  const [show, setShow] = useState(false)\n\n  return (\n    <div className=\"relative\">\n      <Input\n        type={show ? \"text\" : \"password\"}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        placeholder={placeholder}\n        className=\"pr-10\"\n      />\n      <button\n        type=\"button\"\n        onClick={() => setShow((v) => !v)}\n        tabIndex={-1}\n        className=\"text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors\"\n      >\n        {show ? (\n          <IconEyeOff className=\"size-4\" />\n        ) : (\n          <IconEye className=\"size-4\" />\n        )}\n      </button>\n    </div>\n  )\n}\n\ninterface SwitchCardFieldProps {\n  label: string\n  hint?: string\n  error?: string\n  checked: boolean\n  onCheckedChange: (checked: boolean) => void\n  ariaLabel?: string\n  disabled?: boolean\n  children?: ReactNode\n  layout?: FieldLayout\n}\n\nexport function SwitchCardField({\n  label,\n  hint,\n  error,\n  checked,\n  onCheckedChange,\n  ariaLabel,\n  disabled,\n  children,\n  layout = \"default\",\n}: SwitchCardFieldProps) {\n  if (layout === \"setting-row\") {\n    return (\n      <div className=\"flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6\">\n        <div className=\"max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]\">\n          <p className=\"text-sm font-medium\">{label}</p>\n          {hint && (\n            <p className=\"text-muted-foreground mt-0.5 text-xs leading-normal break-words\">\n              {hint}\n            </p>\n          )}\n        </div>\n        <div className=\"flex items-center md:justify-self-center\">\n          <Switch\n            checked={checked}\n            onCheckedChange={onCheckedChange}\n            disabled={disabled}\n            aria-label={ariaLabel ?? label}\n          />\n        </div>\n        {children && <div className=\"md:col-start-2\">{children}</div>}\n        {error && (\n          <p className=\"text-destructive text-xs leading-normal md:col-start-2\">\n            {error}\n          </p>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"border-border/60 bg-background rounded-lg border px-4 py-3\">\n      <div className=\"flex items-start justify-between gap-3\">\n        <div className=\"min-w-0\">\n          <p className=\"text-sm font-medium\">{label}</p>\n          {hint && (\n            <p className=\"text-muted-foreground mt-0.5 text-xs leading-normal\">\n              {hint}\n            </p>\n          )}\n        </div>\n        <Switch\n          checked={checked}\n          onCheckedChange={onCheckedChange}\n          disabled={disabled}\n          aria-label={ariaLabel ?? label}\n        />\n      </div>\n      {children && <div className=\"mt-3\">{children}</div>}\n      {error && (\n        <p className=\"text-destructive mt-2 text-xs leading-normal\">{error}</p>\n      )}\n    </div>\n  )\n}\n\ninterface AdvancedSectionProps {\n  children: ReactNode\n}\n\nexport function AdvancedSection({ children }: AdvancedSectionProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n\n  return (\n    <div className=\"border-border/50 rounded-lg border\">\n      <button\n        type=\"button\"\n        onClick={() => setOpen((v) => !v)}\n        className=\"hover:bg-muted/40 flex w-full items-center justify-between rounded-lg px-4 py-3 transition-colors\"\n      >\n        <span className=\"text-muted-foreground text-sm\">\n          {t(\"models.advanced.toggle\")}\n        </span>\n        <IconChevronDown\n          className={[\n            \"text-muted-foreground size-4 transition-transform duration-200\",\n            open ? \"rotate-180\" : \"\",\n          ].join(\" \")}\n        />\n      </button>\n      {open && (\n        <div className=\"border-border/30 space-y-5 border-t px-4 pt-4 pb-4\">\n          {children}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/skills/skills-page.tsx",
    "content": "import {\n  IconFileInfo,\n  IconLoader2,\n  IconPlus,\n  IconTrash,\n} from \"@tabler/icons-react\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport { type ChangeEvent, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\nimport ReactMarkdown from \"react-markdown\"\nimport remarkGfm from \"remark-gfm\"\nimport { toast } from \"sonner\"\n\nimport {\n  type SkillSupportItem,\n  deleteSkill,\n  getSkill,\n  getSkills,\n  importSkill,\n} from \"@/api/skills\"\nimport { PageHeader } from \"@/components/page-header\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\n\nexport function SkillsPage() {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n  const importInputRef = useRef<HTMLInputElement | null>(null)\n  const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(\n    null,\n  )\n  const [skillPendingDelete, setSkillPendingDelete] =\n    useState<SkillSupportItem | null>(null)\n\n  const { data, isLoading, error } = useQuery({\n    queryKey: [\"skills\"],\n    queryFn: getSkills,\n  })\n  const {\n    data: selectedSkillDetail,\n    isLoading: isSkillDetailLoading,\n    error: skillDetailError,\n  } = useQuery({\n    queryKey: [\"skills\", selectedSkill?.name],\n    queryFn: () => getSkill(selectedSkill!.name),\n    enabled: selectedSkill !== null,\n  })\n\n  const importMutation = useMutation({\n    mutationFn: async (file: File) => importSkill(file),\n    onSuccess: () => {\n      toast.success(t(\"pages.agent.skills.import_success\"))\n      void queryClient.invalidateQueries({ queryKey: [\"skills\"] })\n    },\n    onError: (err) => {\n      toast.error(\n        err instanceof Error\n          ? err.message\n          : t(\"pages.agent.skills.import_error\"),\n      )\n    },\n  })\n\n  const deleteMutation = useMutation({\n    mutationFn: async (name: string) => deleteSkill(name),\n    onSuccess: (_, deletedName) => {\n      toast.success(t(\"pages.agent.skills.delete_success\"))\n      setSkillPendingDelete(null)\n      if (\n        selectedSkill?.name === deletedName &&\n        selectedSkill.source === \"workspace\"\n      ) {\n        setSelectedSkill(null)\n      }\n      void queryClient.invalidateQueries({ queryKey: [\"skills\"] })\n    },\n    onError: (err) => {\n      toast.error(\n        err instanceof Error\n          ? err.message\n          : t(\"pages.agent.skills.delete_error\"),\n      )\n    },\n  })\n\n  const handleImportClick = () => {\n    importInputRef.current?.click()\n  }\n\n  const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const file = event.target.files?.[0]\n    if (!file) return\n    importMutation.mutate(file)\n    event.target.value = \"\"\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader\n        title={t(\"navigation.skills\")}\n        children={\n          <>\n            <input\n              ref={importInputRef}\n              type=\"file\"\n              accept=\".md,text/markdown,text/plain\"\n              className=\"hidden\"\n              onChange={handleImportFileChange}\n            />\n            <Button\n              variant=\"outline\"\n              onClick={handleImportClick}\n              disabled={importMutation.isPending}\n            >\n              {importMutation.isPending ? (\n                <IconLoader2 className=\"size-4 animate-spin\" />\n              ) : (\n                <IconPlus className=\"size-4\" />\n              )}\n              {t(\"pages.agent.skills.import\")}\n            </Button>\n          </>\n        }\n      />\n\n      <div className=\"flex-1 overflow-auto px-6 py-3\">\n        <div className=\"w-full max-w-6xl space-y-6\">\n          {isLoading ? (\n            <div className=\"text-muted-foreground py-6 text-sm\">\n              {t(\"labels.loading\")}\n            </div>\n          ) : error ? (\n            <div className=\"text-destructive py-6 text-sm\">\n              {t(\"pages.agent.load_error\")}\n            </div>\n          ) : (\n            <section className=\"space-y-5\">\n              <p className=\"text-muted-foreground text-sm\">\n                {t(\"pages.agent.skills.description\")}\n              </p>\n\n              {data?.skills.length ? (\n                <div className=\"grid gap-4 lg:grid-cols-2\">\n                  {data.skills.map((skill) => (\n                    <Card\n                      key={`${skill.source}:${skill.name}`}\n                      className=\"border-border/60 gap-4 bg-white/80\"\n                      size=\"sm\"\n                    >\n                      <CardHeader>\n                        <div className=\"flex items-start justify-between gap-3\">\n                          <div>\n                            <CardTitle className=\"font-semibold\">\n                              {skill.name}\n                            </CardTitle>\n                            <CardDescription className=\"mt-3\">\n                              {skill.description ||\n                                t(\"pages.agent.skills.no_description\")}\n                            </CardDescription>\n                          </div>\n                          <div className=\"flex items-center gap-1\">\n                            <Button\n                              variant=\"ghost\"\n                              size=\"icon-sm\"\n                              className=\"text-muted-foreground hover:text-foreground\"\n                              onClick={() => setSelectedSkill(skill)}\n                              title={t(\"pages.agent.skills.view\")}\n                            >\n                              <IconFileInfo className=\"size-4\" />\n                            </Button>\n                            {skill.source === \"workspace\" ? (\n                              <Button\n                                variant=\"ghost\"\n                                size=\"icon-sm\"\n                                className=\"text-muted-foreground hover:text-destructive\"\n                                onClick={() => setSkillPendingDelete(skill)}\n                                title={t(\"pages.agent.skills.delete\")}\n                              >\n                                <IconTrash className=\"size-4\" />\n                              </Button>\n                            ) : null}\n                          </div>\n                        </div>\n                      </CardHeader>\n                      <CardContent className=\"space-y-2\">\n                        <div className=\"text-muted-foreground text-[11px] tracking-[0.18em] uppercase\">\n                          {t(\"pages.agent.skills.path\")}\n                        </div>\n                        <div className=\"bg-muted/60 overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed\">\n                          {skill.path}\n                        </div>\n                      </CardContent>\n                    </Card>\n                  ))}\n                </div>\n              ) : (\n                <Card className=\"border-dashed\">\n                  <CardContent className=\"text-muted-foreground py-10 text-center text-sm\">\n                    {t(\"pages.agent.skills.empty\")}\n                  </CardContent>\n                </Card>\n              )}\n            </section>\n          )}\n        </div>\n      </div>\n\n      <Sheet\n        open={selectedSkill !== null}\n        onOpenChange={(open) => {\n          if (!open) setSelectedSkill(null)\n        }}\n      >\n        <SheetContent\n          side=\"right\"\n          className=\"w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]\"\n        >\n          <SheetHeader className=\"border-b px-6 py-5\">\n            <SheetTitle>\n              {selectedSkill?.name || t(\"pages.agent.skills.viewer_title\")}\n            </SheetTitle>\n            <SheetDescription>\n              {selectedSkill?.description ||\n                t(\"pages.agent.skills.viewer_description\")}\n            </SheetDescription>\n          </SheetHeader>\n\n          <div className=\"flex-1 overflow-auto px-6 py-5\">\n            {isSkillDetailLoading ? (\n              <div className=\"text-muted-foreground text-sm\">\n                {t(\"pages.agent.skills.loading_detail\")}\n              </div>\n            ) : skillDetailError ? (\n              <div className=\"text-destructive text-sm\">\n                {t(\"pages.agent.skills.load_detail_error\")}\n              </div>\n            ) : selectedSkillDetail ? (\n              <div className=\"space-y-5\">\n                <div className=\"prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none\">\n                  <ReactMarkdown remarkPlugins={[remarkGfm]}>\n                    {selectedSkillDetail.content}\n                  </ReactMarkdown>\n                </div>\n              </div>\n            ) : null}\n          </div>\n        </SheetContent>\n      </Sheet>\n\n      <AlertDialog\n        open={skillPendingDelete !== null}\n        onOpenChange={(open) => {\n          if (!open) setSkillPendingDelete(null)\n        }}\n      >\n        <AlertDialogContent size=\"sm\">\n          <AlertDialogHeader>\n            <AlertDialogTitle>\n              {t(\"pages.agent.skills.delete_title\")}\n            </AlertDialogTitle>\n            <AlertDialogDescription>\n              {t(\"pages.agent.skills.delete_description\", {\n                name: skillPendingDelete?.name,\n              })}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={deleteMutation.isPending}>\n              {t(\"common.cancel\")}\n            </AlertDialogCancel>\n            <AlertDialogAction\n              variant=\"destructive\"\n              disabled={deleteMutation.isPending || !skillPendingDelete}\n              onClick={() => {\n                if (skillPendingDelete)\n                  deleteMutation.mutate(skillPendingDelete.name)\n              }}\n            >\n              {deleteMutation.isPending ? (\n                <IconLoader2 className=\"size-4 animate-spin\" />\n              ) : (\n                <IconTrash className=\"size-4\" />\n              )}\n              {t(\"pages.agent.skills.delete_confirm\")}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/tools/tools-page.tsx",
    "content": "import { IconLoader2 } from \"@tabler/icons-react\"\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\"\nimport { useTranslation } from \"react-i18next\"\nimport { toast } from \"sonner\"\n\nimport { type ToolSupportItem, getTools, setToolEnabled } from \"@/api/tools\"\nimport { PageHeader } from \"@/components/page-header\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { cn } from \"@/lib/utils\"\n\nexport function ToolsPage() {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n  const { data, isLoading, error } = useQuery({\n    queryKey: [\"tools\"],\n    queryFn: getTools,\n  })\n\n  const toggleMutation = useMutation({\n    mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>\n      setToolEnabled(name, enabled),\n    onSuccess: (_, variables) => {\n      toast.success(\n        variables.enabled\n          ? t(\"pages.agent.tools.enable_success\")\n          : t(\"pages.agent.tools.disable_success\"),\n      )\n      void queryClient.invalidateQueries({ queryKey: [\"tools\"] })\n    },\n    onError: (err) => {\n      toast.error(\n        err instanceof Error\n          ? err.message\n          : t(\"pages.agent.tools.toggle_error\"),\n      )\n    },\n  })\n\n  const groupedTools = (() => {\n    if (!data) return [] as Array<[string, ToolSupportItem[]]>\n    const buckets = new Map<string, ToolSupportItem[]>()\n    for (const item of data.tools) {\n      const list = buckets.get(item.category) ?? []\n      list.push(item)\n      buckets.set(item.category, list)\n    }\n    return Array.from(buckets.entries())\n  })()\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <PageHeader title={t(\"navigation.tools\")} />\n\n      <div className=\"flex-1 overflow-auto px-6 py-3\">\n        <div className=\"w-full max-w-6xl space-y-6\">\n          {isLoading ? (\n            <div className=\"text-muted-foreground py-6 text-sm\">\n              {t(\"labels.loading\")}\n            </div>\n          ) : error ? (\n            <div className=\"text-destructive py-6 text-sm\">\n              {t(\"pages.agent.load_error\")}\n            </div>\n          ) : (\n            <section className=\"space-y-5\">\n              <p className=\"text-muted-foreground mt-1 text-sm\">\n                {t(\"pages.agent.tools.description\")}\n              </p>\n\n              {data?.tools.length ? (\n                groupedTools.map(([category, items]) => (\n                  <div key={category} className=\"space-y-3\">\n                    <div className=\"text-foreground/85 text-sm font-semibold tracking-wide\">\n                      {t(`pages.agent.tools.categories.${category}`)}\n                    </div>\n                    <div className=\"grid gap-4 lg:grid-cols-2\">\n                      {items.map((tool) => {\n                        const reasonText = tool.reason_code\n                          ? t(`pages.agent.tools.reasons.${tool.reason_code}`)\n                          : \"\"\n                        const isPending =\n                          toggleMutation.isPending &&\n                          toggleMutation.variables?.name === tool.name\n                        const nextEnabled = tool.status !== \"enabled\"\n\n                        return (\n                          <Card\n                            key={tool.name}\n                            className={cn(\n                              \"gap-4 border transition-colors\",\n                              tool.status === \"enabled\" &&\n                                \"border-emerald-200/70 bg-emerald-50/50\",\n                              tool.status === \"blocked\" &&\n                                \"border-amber-200/80 bg-amber-50/60\",\n                              tool.status === \"disabled\" &&\n                                \"border-border/60 bg-card/70\",\n                            )}\n                            size=\"sm\"\n                          >\n                            <CardHeader>\n                              <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between\">\n                                <div className=\"min-w-0 flex-1\">\n                                  <CardTitle className=\"font-mono text-sm break-all\">\n                                    {tool.name}\n                                  </CardTitle>\n                                  <CardDescription className=\"mt-1 break-words\">\n                                    {tool.description}\n                                  </CardDescription>\n                                </div>\n                                <div className=\"flex shrink-0 items-center gap-2 self-start\">\n                                  <ToolStatusBadge status={tool.status} />\n                                  <Button\n                                    variant={\n                                      nextEnabled ? \"default\" : \"outline\"\n                                    }\n                                    size=\"sm\"\n                                    disabled={isPending}\n                                    onClick={() =>\n                                      toggleMutation.mutate({\n                                        name: tool.name,\n                                        enabled: nextEnabled,\n                                      })\n                                    }\n                                  >\n                                    {isPending ? (\n                                      <IconLoader2 className=\"size-4 animate-spin\" />\n                                    ) : null}\n                                    {nextEnabled\n                                      ? t(\"pages.agent.tools.enable\")\n                                      : t(\"pages.agent.tools.disable\")}\n                                  </Button>\n                                </div>\n                              </div>\n                            </CardHeader>\n                            <CardContent className=\"space-y-2\">\n                              <div className=\"text-muted-foreground text-xs\">\n                                {t(\"pages.agent.tools.config_key\", {\n                                  key: tool.config_key,\n                                })}\n                              </div>\n                              {reasonText ? (\n                                <div className=\"text-sm text-amber-800\">\n                                  {reasonText}\n                                </div>\n                              ) : null}\n                            </CardContent>\n                          </Card>\n                        )\n                      })}\n                    </div>\n                  </div>\n                ))\n              ) : (\n                <Card className=\"border-dashed\">\n                  <CardContent className=\"text-muted-foreground py-10 text-center text-sm\">\n                    {t(\"pages.agent.tools.empty\")}\n                  </CardContent>\n                </Card>\n              )}\n            </section>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction ToolStatusBadge({ status }: { status: ToolSupportItem[\"status\"] }) {\n  const { t } = useTranslation()\n\n  return (\n    <span\n      className={cn(\n        \"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold\",\n        status === \"enabled\" && \"bg-emerald-100 text-emerald-700\",\n        status === \"blocked\" && \"bg-amber-100 text-amber-700\",\n        status === \"disabled\" && \"bg-muted text-muted-foreground\",\n      )}\n    >\n      {t(`pages.agent.tools.status.${status}`)}\n    </span>\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\"\nimport { AlertDialog as AlertDialogPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  )\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  )\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {\n  size?: \"default\" | \"sm\"\n}) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        data-size={size}\n        className={cn(\n          \"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\n        \"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogMedia({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-media\"\n      className={cn(\n        \"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\n        \"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\n        \"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <Button variant={variant} size={size} asChild>\n      <AlertDialogPrimitive.Action\n        data-slot=\"alert-dialog-action\"\n        className={cn(className)}\n        {...props}\n      />\n    </Button>\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  variant = \"outline\",\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <Button variant={variant} size={size} asChild>\n      <AlertDialogPrimitive.Cancel\n        data-slot=\"alert-dialog-cancel\"\n        className={cn(className)}\n        {...props}\n      />\n    </Button>\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogMedia,\n  AlertDialogOverlay,\n  AlertDialogPortal,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/80\",\n        outline:\n          \"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground\",\n        ghost:\n          \"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50\",\n        destructive:\n          \"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default:\n          \"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2\",\n        xs: \"h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3\",\n        sm: \"h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5\",\n        lg: \"h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3\",\n        icon: \"size-9\",\n        \"icon-xs\":\n          \"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\":\n          \"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md\",\n        \"icon-lg\": \"size-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "web/frontend/src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Card({\n  className,\n  size = \"default\",\n  ...props\n}: React.ComponentProps<\"div\"> & { size?: \"default\" | \"sm\" }) {\n  return (\n    <div\n      data-slot=\"card\"\n      data-size={size}\n      className={cn(\n        \"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\n        \"text-base leading-normal font-medium group-data-[size=sm]/card:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6 group-data-[size=sm]/card:px-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\n        \"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/collapsible.tsx",
    "content": "import { Collapsible as CollapsiblePrimitive } from \"radix-ui\"\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  )\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n"
  },
  {
    "path": "web/frontend/src/components/ui/dropdown-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { IconCheck, IconChevronRight } from \"@tabler/icons-react\"\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  )\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuContent({\n  className,\n  align = \"start\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        align={align}\n        className={cn(\"z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\", className )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  )\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      data-inset={inset}\n      className={cn(\n        \"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span\n        className=\"pointer-events-none absolute right-2 flex items-center justify-center\"\n        data-slot=\"dropdown-menu-checkbox-item-indicator\"\n      >\n        <DropdownMenuPrimitive.ItemIndicator>\n          <IconCheck\n          />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      data-inset={inset}\n      className={cn(\n        \"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span\n        className=\"pointer-events-none absolute right-2 flex items-center justify-center\"\n        data-slot=\"dropdown-menu-radio-item-indicator\"\n      >\n        <DropdownMenuPrimitive.ItemIndicator>\n          <IconCheck\n          />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <IconChevronRight className=\"ml-auto\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\", className )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/field.tsx",
    "content": "import { useMemo } from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\nimport { Separator } from \"@/components/ui/separator\"\n\nfunction FieldSet({ className, ...props }: React.ComponentProps<\"fieldset\">) {\n  return (\n    <fieldset\n      data-slot=\"field-set\"\n      className={cn(\n        \"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLegend({\n  className,\n  variant = \"legend\",\n  ...props\n}: React.ComponentProps<\"legend\"> & { variant?: \"legend\" | \"label\" }) {\n  return (\n    <legend\n      data-slot=\"field-legend\"\n      data-variant={variant}\n      className={cn(\n        \"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-group\"\n      className={cn(\n        \"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst fieldVariants = cva(\n  \"group/field flex w-full gap-3 data-[invalid=true]:text-destructive\",\n  {\n    variants: {\n      orientation: {\n        vertical: \"flex-col *:w-full [&>.sr-only]:w-auto\",\n        horizontal:\n          \"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n        responsive:\n          \"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n      },\n    },\n    defaultVariants: {\n      orientation: \"vertical\",\n    },\n  }\n)\n\nfunction Field({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof fieldVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"field\"\n      data-orientation={orientation}\n      className={cn(fieldVariants({ orientation }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction FieldContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-content\"\n      className={cn(\n        \"group/field-content flex flex-1 flex-col gap-1 leading-snug\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof Label>) {\n  return (\n    <Label\n      data-slot=\"field-label\"\n      className={cn(\n        \"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10\",\n        \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"field-label\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"field-description\"\n      className={cn(\n        \"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5\",\n        \"last:mt-0 nth-last-2:-mt-1\",\n        \"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FieldSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  children?: React.ReactNode\n}) {\n  return (\n    <div\n      data-slot=\"field-separator\"\n      data-content={!!children}\n      className={cn(\n        \"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2\",\n        className\n      )}\n      {...props}\n    >\n      <Separator className=\"absolute inset-0 top-1/2\" />\n      {children && (\n        <span\n          className=\"relative mx-auto block w-fit bg-background px-2 text-muted-foreground\"\n          data-slot=\"field-separator-content\"\n        >\n          {children}\n        </span>\n      )}\n    </div>\n  )\n}\n\nfunction FieldError({\n  className,\n  children,\n  errors,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  errors?: Array<{ message?: string } | undefined>\n}) {\n  const content = useMemo(() => {\n    if (children) {\n      return children\n    }\n\n    if (!errors?.length) {\n      return null\n    }\n\n    const uniqueErrors = [\n      ...new Map(errors.map((error) => [error?.message, error])).values(),\n    ]\n\n    if (uniqueErrors?.length == 1) {\n      return uniqueErrors[0]?.message\n    }\n\n    return (\n      <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n        {uniqueErrors.map(\n          (error, index) =>\n            error?.message && <li key={index}>{error.message}</li>\n        )}\n      </ul>\n    )\n  }, [children, errors])\n\n  if (!content) {\n    return null\n  }\n\n  return (\n    <div\n      role=\"alert\"\n      data-slot=\"field-error\"\n      className={cn(\"text-sm font-normal text-destructive\", className)}\n      {...props}\n    >\n      {content}\n    </div>\n  )\n}\n\nexport {\n  Field,\n  FieldLabel,\n  FieldDescription,\n  FieldError,\n  FieldGroup,\n  FieldLegend,\n  FieldSeparator,\n  FieldSet,\n  FieldContent,\n  FieldTitle,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "web/frontend/src/components/ui/label.tsx",
    "content": "import * as React from \"react\"\nimport { Label as LabelPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n"
  },
  {
    "path": "web/frontend/src/components/ui/scroll-area.tsx",
    "content": "import * as React from \"react\"\nimport { ScrollArea as ScrollAreaPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn(\"relative\", className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  )\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      data-orientation={orientation}\n      orientation={orientation}\n      className={cn(\n        \"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent\",\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"relative flex-1 rounded-full bg-border\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  )\n}\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "web/frontend/src/components/ui/select.tsx",
    "content": "import * as React from \"react\"\nimport { Select as SelectPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from \"@tabler/icons-react\"\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return (\n    <SelectPrimitive.Group\n      data-slot=\"select-group\"\n      className={cn(\"scroll-my-1 p-1\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <IconSelector className=\"pointer-events-none size-4 text-muted-foreground\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  )\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"item-aligned\",\n  align = \"center\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        data-align-trigger={position === \"item-aligned\"}\n        className={cn(\"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\", position ===\"popper\"&&\"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\", className )}\n        position={position}\n        align={align}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          data-position={position}\n          className={cn(\n            \"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)\",\n            position === \"popper\" && \"\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  )\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"px-2 py-1.5 text-xs text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute right-2 flex size-4 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <IconCheck className=\"pointer-events-none\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  )\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"pointer-events-none -mx-1 my-1 h-px bg-border\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <IconChevronUp\n      />\n    </SelectPrimitive.ScrollUpButton>\n  )\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <IconChevronDown\n      />\n    </SelectPrimitive.ScrollDownButton>\n  )\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Separator as SeparatorPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "web/frontend/src/components/ui/sheet.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Dialog as SheetPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { IconX } from \"@tabler/icons-react\"\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />\n}\n\nfunction SheetTrigger({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />\n}\n\nfunction SheetClose({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />\n}\n\nfunction SheetPortal({\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />\n}\n\nfunction SheetOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        \"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\"\n  showCloseButton?: boolean\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot=\"sheet-content\"\n        data-side={side}\n        className={cn(\n          \"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <SheetPrimitive.Close data-slot=\"sheet-close\" asChild>\n            <Button\n              variant=\"ghost\"\n              className=\"absolute top-4 right-4\"\n              size=\"icon-sm\"\n            >\n              <IconX\n              />\n              <span className=\"sr-only\">Close</span>\n            </Button>\n          </SheetPrimitive.Close>\n        )}\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  )\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn(\"flex flex-col gap-1.5 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-footer\"\n      className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn(\"font-medium text-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn(\"text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/sidebar.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Slot } from \"radix-ui\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Separator } from \"@/components/ui/separator\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconLayoutSidebar } from \"@tabler/icons-react\"\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\"\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = \"16rem\"\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\"\nconst SIDEBAR_WIDTH_ICON = \"3rem\"\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\ntype SidebarContextProps = {\n  state: \"expanded\" | \"collapsed\"\n  open: boolean\n  setOpen: (open: boolean) => void\n  openMobile: boolean\n  setOpenMobile: (open: boolean) => void\n  isMobile: boolean\n  toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null)\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext)\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\")\n  }\n\n  return context\n}\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  defaultOpen?: boolean\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n}) {\n  const isMobile = useIsMobile()\n  const [openMobile, setOpenMobile] = React.useState(false)\n\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen)\n  const open = openProp ?? _open\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === \"function\" ? value(open) : value\n      if (setOpenProp) {\n        setOpenProp(openState)\n      } else {\n        _setOpen(openState)\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n    },\n    [setOpenProp, open]\n  )\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(() => {\n    return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)\n  }, [isMobile, setOpen, setOpenMobile])\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n        (event.metaKey || event.ctrlKey)\n      ) {\n        event.preventDefault()\n        toggleSidebar()\n      }\n    }\n\n    window.addEventListener(\"keydown\", handleKeyDown)\n    return () => window.removeEventListener(\"keydown\", handleKeyDown)\n  }, [toggleSidebar])\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? \"expanded\" : \"collapsed\"\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n    }),\n    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n  )\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <div\n        data-slot=\"sidebar-wrapper\"\n        style={\n          {\n            \"--sidebar-width\": SIDEBAR_WIDTH,\n            \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n            ...style,\n          } as React.CSSProperties\n        }\n        className={cn(\n          \"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    </SidebarContext.Provider>\n  )\n}\n\nfunction Sidebar({\n  side = \"left\",\n  variant = \"sidebar\",\n  collapsible = \"offcanvas\",\n  className,\n  children,\n  dir,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  side?: \"left\" | \"right\"\n  variant?: \"sidebar\" | \"floating\" | \"inset\"\n  collapsible?: \"offcanvas\" | \"icon\" | \"none\"\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n  if (collapsible === \"none\") {\n    return (\n      <div\n        data-slot=\"sidebar\"\n        className={cn(\n          \"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    )\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n        <SheetContent\n          dir={dir}\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar\"\n          data-mobile=\"true\"\n          className=\"w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n          side={side}\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    )\n  }\n\n  return (\n    <div\n      className=\"group peer hidden text-sidebar-foreground md:block\"\n      data-state={state}\n      data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n      data-variant={variant}\n      data-side={side}\n      data-slot=\"sidebar\"\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot=\"sidebar-gap\"\n        className={cn(\n          \"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          \"group-data-[side=right]:rotate-180\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\"\n        )}\n      />\n      <div\n        data-slot=\"sidebar-container\"\n        data-side={side}\n        className={cn(\n          \"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex\",\n          // Adjust the padding for floating and inset variants.\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\",\n          className\n        )}\n        {...props}\n      >\n        <div\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n          className=\"flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction SidebarTrigger({\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <Button\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      className={cn(className)}\n      onClick={(event) => {\n        onClick?.(event)\n        toggleSidebar()\n      }}\n      {...props}\n    >\n      <IconLayoutSidebar />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  )\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<\"button\">) {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <button\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        \"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2\",\n        \"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        \"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      data-slot=\"sidebar-input\"\n      data-sidebar=\"input\"\n      className={cn(\"h-8 w-full bg-background shadow-none\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"sidebar-separator\"\n      data-sidebar=\"separator\"\n      className={cn(\"mx-2 w-auto bg-sidebar-border\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\n      data-sidebar=\"content\"\n      className={cn(\n        \"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot.Root : \"div\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-action\"\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarGroupContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group-content\"\n      data-sidebar=\"group-content\"\n      className={cn(\"w-full text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu\"\n      data-sidebar=\"menu\"\n      className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-item\"\n      data-sidebar=\"menu-item\"\n      className={cn(\"group/menu-item relative\", className)}\n      {...props}\n    />\n  )\n}\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = \"default\",\n  size = \"default\",\n  tooltip,\n  className,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean\n  isActive?: boolean\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const Comp = asChild ? Slot.Root : \"button\"\n  const { isMobile, state } = useSidebar()\n\n  const button = (\n    <Comp\n      data-slot=\"sidebar-menu-button\"\n      data-sidebar=\"menu-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n      {...props}\n    />\n  )\n\n  if (!tooltip) {\n    return button\n  }\n\n  if (typeof tooltip === \"string\") {\n    tooltip = {\n      children: tooltip,\n    }\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{button}</TooltipTrigger>\n      <TooltipContent\n        side=\"right\"\n        align=\"center\"\n        hidden={state !== \"collapsed\" || isMobile}\n        {...tooltip}\n      />\n    </Tooltip>\n  )\n}\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean\n  showOnHover?: boolean\n}) {\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-action\"\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0\",\n        showOnHover &&\n        \"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuBadge({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      data-sidebar=\"menu-badge\"\n      className={cn(\n        \"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showIcon?: boolean\n}) {\n  // Random width between 50 to 90%.\n  const [width] = React.useState(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`\n  })\n\n  return (\n    <div\n      data-slot=\"sidebar-menu-skeleton\"\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  )\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      data-sidebar=\"menu-sub\"\n      className={cn(\n        \"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubItem({\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      data-sidebar=\"menu-sub-item\"\n      className={cn(\"group/menu-sub-item relative\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = \"md\",\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean\n  size?: \"sm\" | \"md\"\n  isActive?: boolean\n}) {\n  const Comp = asChild ? Slot.Root : \"a\"\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n}\n"
  },
  {
    "path": "web/frontend/src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"skeleton\"\n      className={cn(\"animate-pulse rounded-md bg-muted\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "web/frontend/src/components/ui/switch.tsx",
    "content": "import * as React from \"react\"\nimport { Switch as SwitchPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Switch({\n  className,\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof SwitchPrimitive.Root> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      data-size={size}\n      className={cn(\n        \"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className=\"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground\"\n      />\n    </SwitchPrimitive.Root>\n  )\n}\n\nexport { Switch }\n"
  },
  {
    "path": "web/frontend/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "web/frontend/src/components/ui/tooltip.tsx",
    "content": "import * as React from \"react\"\nimport { Tooltip as TooltipPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }\n"
  },
  {
    "path": "web/frontend/src/features/chat/controller.ts",
    "content": "import { getDefaultStore } from \"jotai\"\nimport { toast } from \"sonner\"\n\nimport { getPicoToken } from \"@/api/pico\"\nimport {\n  loadSessionMessages,\n  mergeHistoryMessages,\n} from \"@/features/chat/history\"\nimport { type PicoMessage, handlePicoMessage } from \"@/features/chat/protocol\"\nimport {\n  clearStoredSessionId,\n  generateSessionId,\n  readStoredSessionId,\n} from \"@/features/chat/state\"\nimport {\n  invalidateSocket,\n  isCurrentSocket,\n  normalizeWsUrlForBrowser,\n} from \"@/features/chat/websocket\"\nimport i18n from \"@/i18n\"\nimport { getChatState, updateChatStore } from \"@/store/chat\"\nimport { type GatewayState, gatewayAtom } from \"@/store/gateway\"\n\nconst store = getDefaultStore()\n\nlet wsRef: WebSocket | null = null\nlet isConnecting = false\nlet msgIdCounter = 0\nlet activeSessionIdRef = getChatState().activeSessionId\nlet initialized = false\nlet unsubscribeGateway: (() => void) | null = null\nlet hydratePromise: Promise<void> | null = null\nlet connectionGeneration = 0\nlet reconnectTimer: number | null = null\nlet reconnectAttempts = 0\nlet shouldMaintainConnection = false\n\nfunction clearReconnectTimer() {\n  if (reconnectTimer !== null) {\n    window.clearTimeout(reconnectTimer)\n    reconnectTimer = null\n  }\n}\n\nfunction shouldReconnectFor(generation: number, sessionId: string): boolean {\n  return (\n    shouldMaintainConnection &&\n    generation === connectionGeneration &&\n    sessionId === activeSessionIdRef &&\n    store.get(gatewayAtom).status === \"running\"\n  )\n}\n\nfunction scheduleReconnect(generation: number, sessionId: string) {\n  if (!shouldReconnectFor(generation, sessionId) || reconnectTimer !== null) {\n    return\n  }\n\n  const delay = Math.min(1000 * 2 ** reconnectAttempts, 5000)\n  reconnectAttempts += 1\n  reconnectTimer = window.setTimeout(() => {\n    reconnectTimer = null\n    if (!shouldReconnectFor(generation, sessionId)) {\n      return\n    }\n    void connectChat()\n  }, delay)\n}\n\nfunction needsActiveSessionHydration(): boolean {\n  const state = getChatState()\n  const storedSessionId = readStoredSessionId()\n\n  return Boolean(\n    storedSessionId &&\n    storedSessionId === state.activeSessionId &&\n    !state.hasHydratedActiveSession,\n  )\n}\n\nfunction setActiveSessionId(sessionId: string) {\n  activeSessionIdRef = sessionId\n  updateChatStore({ activeSessionId: sessionId })\n}\n\nfunction disconnectChatInternal({\n  clearDesiredConnection,\n}: {\n  clearDesiredConnection: boolean\n}) {\n  connectionGeneration += 1\n  clearReconnectTimer()\n\n  if (clearDesiredConnection) {\n    shouldMaintainConnection = false\n  }\n\n  const socket = wsRef\n  wsRef = null\n  isConnecting = false\n\n  invalidateSocket(socket)\n\n  updateChatStore({\n    connectionState: \"disconnected\",\n    isTyping: false,\n  })\n}\n\nexport async function connectChat() {\n  if (\n    store.get(gatewayAtom).status !== \"running\" ||\n    needsActiveSessionHydration()\n  ) {\n    return\n  }\n\n  if (\n    isConnecting ||\n    (wsRef &&\n      (wsRef.readyState === WebSocket.OPEN ||\n        wsRef.readyState === WebSocket.CONNECTING))\n  ) {\n    return\n  }\n\n  const generation = connectionGeneration + 1\n  connectionGeneration = generation\n  isConnecting = true\n  clearReconnectTimer()\n  updateChatStore({ connectionState: \"connecting\" })\n\n  try {\n    const { token, ws_url } = await getPicoToken()\n    const sessionId = activeSessionIdRef\n\n    if (generation !== connectionGeneration) {\n      isConnecting = false\n      return\n    }\n\n    if (!token) {\n      console.error(\"No pico token available\")\n      updateChatStore({ connectionState: \"error\" })\n      isConnecting = false\n      scheduleReconnect(generation, sessionId)\n      return\n    }\n\n    const finalWsUrl = normalizeWsUrlForBrowser(ws_url)\n    const url = `${finalWsUrl}?session_id=${encodeURIComponent(sessionId)}`\n    const socket = new WebSocket(url, [`token.${token}`])\n\n    if (generation !== connectionGeneration) {\n      isConnecting = false\n      invalidateSocket(socket)\n      return\n    }\n\n    socket.onopen = () => {\n      if (\n        !isCurrentSocket({\n          socket,\n          currentSocket: wsRef,\n          generation,\n          currentGeneration: connectionGeneration,\n          sessionId,\n          currentSessionId: activeSessionIdRef,\n        })\n      ) {\n        return\n      }\n      updateChatStore({ connectionState: \"connected\" })\n      isConnecting = false\n      reconnectAttempts = 0\n    }\n\n    socket.onmessage = (event) => {\n      if (\n        !isCurrentSocket({\n          socket,\n          currentSocket: wsRef,\n          generation,\n          currentGeneration: connectionGeneration,\n          sessionId,\n          currentSessionId: activeSessionIdRef,\n        })\n      ) {\n        return\n      }\n\n      try {\n        const message = JSON.parse(event.data) as PicoMessage\n        handlePicoMessage(message, sessionId)\n      } catch {\n        console.warn(\"Non-JSON message from pico:\", event.data)\n      }\n    }\n\n    socket.onclose = () => {\n      if (\n        !isCurrentSocket({\n          socket,\n          currentSocket: wsRef,\n          generation,\n          currentGeneration: connectionGeneration,\n          sessionId,\n          currentSessionId: activeSessionIdRef,\n        })\n      ) {\n        return\n      }\n      wsRef = null\n      isConnecting = false\n      updateChatStore({\n        connectionState: \"disconnected\",\n        isTyping: false,\n      })\n      scheduleReconnect(generation, sessionId)\n    }\n\n    socket.onerror = () => {\n      if (\n        !isCurrentSocket({\n          socket,\n          currentSocket: wsRef,\n          generation,\n          currentGeneration: connectionGeneration,\n          sessionId,\n          currentSessionId: activeSessionIdRef,\n        })\n      ) {\n        return\n      }\n      isConnecting = false\n      updateChatStore({ connectionState: \"error\" })\n      scheduleReconnect(generation, sessionId)\n    }\n\n    wsRef = socket\n  } catch (error) {\n    if (generation !== connectionGeneration) {\n      isConnecting = false\n      return\n    }\n    console.error(\"Failed to connect to pico:\", error)\n    updateChatStore({ connectionState: \"error\" })\n    isConnecting = false\n    scheduleReconnect(generation, activeSessionIdRef)\n  }\n}\n\nexport function disconnectChat() {\n  disconnectChatInternal({ clearDesiredConnection: true })\n}\n\nexport async function hydrateActiveSession() {\n  if (hydratePromise) {\n    return hydratePromise\n  }\n\n  const state = getChatState()\n  const storedSessionId = readStoredSessionId()\n\n  if (\n    !storedSessionId ||\n    state.hasHydratedActiveSession ||\n    storedSessionId !== state.activeSessionId\n  ) {\n    if (!state.hasHydratedActiveSession) {\n      updateChatStore({ hasHydratedActiveSession: true })\n    }\n    return\n  }\n\n  hydratePromise = loadSessionMessages(storedSessionId)\n    .then((historyMessages) => {\n      const currentState = getChatState()\n      if (currentState.activeSessionId !== storedSessionId) {\n        return\n      }\n\n      if (currentState.messages.length > 0) {\n        updateChatStore({\n          messages: mergeHistoryMessages(\n            historyMessages,\n            currentState.messages,\n          ),\n          hasHydratedActiveSession: true,\n        })\n        return\n      }\n\n      updateChatStore({\n        messages: historyMessages,\n        isTyping: false,\n        hasHydratedActiveSession: true,\n      })\n    })\n    .catch((error) => {\n      console.error(\"Failed to restore last session history:\", error)\n\n      const currentState = getChatState()\n      if (currentState.activeSessionId !== storedSessionId) {\n        return\n      }\n\n      if (currentState.messages.length > 0) {\n        updateChatStore({ hasHydratedActiveSession: true })\n        return\n      }\n\n      clearStoredSessionId()\n      updateChatStore({\n        messages: [],\n        isTyping: false,\n        hasHydratedActiveSession: true,\n      })\n    })\n    .finally(() => {\n      hydratePromise = null\n    })\n\n  return hydratePromise\n}\n\nexport function sendChatMessage(content: string) {\n  if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {\n    console.warn(\"WebSocket not connected\")\n    return false\n  }\n\n  const socket = wsRef\n  const id = `msg-${++msgIdCounter}-${Date.now()}`\n\n  updateChatStore((prev) => ({\n    messages: [\n      ...prev.messages,\n      { id, role: \"user\", content, timestamp: Date.now() },\n    ],\n    isTyping: true,\n  }))\n\n  try {\n    socket.send(\n      JSON.stringify({\n        type: \"message.send\",\n        id,\n        payload: { content },\n      }),\n    )\n    return true\n  } catch (error) {\n    console.error(\"Failed to send pico message:\", error)\n    updateChatStore((prev) => ({\n      messages: prev.messages.filter((message) => message.id !== id),\n      isTyping: false,\n    }))\n    return false\n  }\n}\n\nexport async function switchChatSession(sessionId: string) {\n  if (sessionId === activeSessionIdRef) {\n    return\n  }\n\n  try {\n    const historyMessages = await loadSessionMessages(sessionId)\n\n    disconnectChatInternal({ clearDesiredConnection: false })\n    setActiveSessionId(sessionId)\n    updateChatStore({\n      messages: historyMessages,\n      isTyping: false,\n      hasHydratedActiveSession: true,\n    })\n\n    if (store.get(gatewayAtom).status === \"running\") {\n      shouldMaintainConnection = true\n      await connectChat()\n    }\n  } catch (error) {\n    console.error(\"Failed to load session history:\", error)\n    toast.error(i18n.t(\"chat.historyOpenFailed\"))\n  }\n}\n\nexport async function newChatSession() {\n  if (getChatState().messages.length === 0) {\n    return\n  }\n\n  disconnectChatInternal({ clearDesiredConnection: false })\n  setActiveSessionId(generateSessionId())\n  updateChatStore({\n    messages: [],\n    isTyping: false,\n    hasHydratedActiveSession: true,\n  })\n\n  if (store.get(gatewayAtom).status === \"running\") {\n    shouldMaintainConnection = true\n    await connectChat()\n  }\n}\n\nexport function initializeChatStore() {\n  if (initialized) {\n    return\n  }\n\n  initialized = true\n  activeSessionIdRef = getChatState().activeSessionId\n  let lastGatewayStatus: GatewayState | null = null\n\n  const syncConnectionWithGateway = (force: boolean = false) => {\n    const gatewayStatus = store.get(gatewayAtom).status\n    if (!force && gatewayStatus === lastGatewayStatus) {\n      return\n    }\n    lastGatewayStatus = gatewayStatus\n\n    if (gatewayStatus === \"running\") {\n      shouldMaintainConnection = true\n      if (needsActiveSessionHydration()) {\n        return\n      }\n      void connectChat()\n      return\n    }\n\n    if (gatewayStatus === \"stopped\" || gatewayStatus === \"error\") {\n      disconnectChatInternal({ clearDesiredConnection: true })\n    }\n  }\n\n  unsubscribeGateway = store.sub(gatewayAtom, syncConnectionWithGateway)\n\n  if (!readStoredSessionId()) {\n    updateChatStore({ hasHydratedActiveSession: true })\n    syncConnectionWithGateway(true)\n    return\n  }\n\n  void hydrateActiveSession().finally(() => {\n    if (!initialized) {\n      return\n    }\n    syncConnectionWithGateway(true)\n  })\n}\n\nexport function teardownChatStore() {\n  unsubscribeGateway?.()\n  unsubscribeGateway = null\n  initialized = false\n  disconnectChat()\n}\n"
  },
  {
    "path": "web/frontend/src/features/chat/history.ts",
    "content": "import { getSessionHistory } from \"@/api/sessions\"\nimport { normalizeUnixTimestamp } from \"@/features/chat/state\"\nimport type { ChatMessage } from \"@/store/chat\"\n\nexport async function loadSessionMessages(\n  sessionId: string,\n): Promise<ChatMessage[]> {\n  const detail = await getSessionHistory(sessionId)\n  const fallbackTime = detail.updated\n\n  return detail.messages.map((message, index) => ({\n    id: `hist-${index}-${Date.now()}`,\n    role: message.role,\n    content: message.content,\n    timestamp: fallbackTime,\n  }))\n}\n\nfunction normalizeMessageTimestamp(timestamp: number | string): string {\n  if (typeof timestamp === \"number\") {\n    return String(normalizeUnixTimestamp(timestamp))\n  }\n\n  const trimmed = timestamp.trim()\n  if (/^-?\\d+(\\.\\d+)?$/.test(trimmed)) {\n    return String(normalizeUnixTimestamp(Number(trimmed)))\n  }\n\n  const parsed = Date.parse(trimmed)\n  return Number.isNaN(parsed) ? trimmed : String(parsed)\n}\n\nfunction messageSignature(message: ChatMessage): string {\n  return `${message.role}\\u0000${message.content}\\u0000${normalizeMessageTimestamp(\n    message.timestamp,\n  )}`\n}\n\nfunction comparableTimestamp(timestamp: number | string): number {\n  const normalized = normalizeMessageTimestamp(timestamp)\n  const numeric = Number(normalized)\n  return Number.isFinite(numeric) ? numeric : 0\n}\n\nexport function mergeHistoryMessages(\n  historyMessages: ChatMessage[],\n  currentMessages: ChatMessage[],\n): ChatMessage[] {\n  const currentIds = new Set(currentMessages.map((message) => message.id))\n  const currentSignatures = new Set(\n    currentMessages.map((message) => messageSignature(message)),\n  )\n\n  const merged = [\n    ...historyMessages.filter(\n      (message) =>\n        !currentIds.has(message.id) &&\n        !currentSignatures.has(messageSignature(message)),\n    ),\n    ...currentMessages,\n  ]\n\n  return merged.sort(\n    (left, right) =>\n      comparableTimestamp(left.timestamp) -\n      comparableTimestamp(right.timestamp),\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/features/chat/protocol.ts",
    "content": "import { normalizeUnixTimestamp } from \"@/features/chat/state\"\nimport { updateChatStore } from \"@/store/chat\"\n\nexport interface PicoMessage {\n  type: string\n  id?: string\n  session_id?: string\n  timestamp?: number | string\n  payload?: Record<string, unknown>\n}\n\nexport function handlePicoMessage(\n  message: PicoMessage,\n  expectedSessionId: string,\n) {\n  if (message.session_id && message.session_id !== expectedSessionId) {\n    return\n  }\n\n  const payload = message.payload || {}\n\n  switch (message.type) {\n    case \"message.create\": {\n      const content = (payload.content as string) || \"\"\n      const messageId = (payload.message_id as string) || `pico-${Date.now()}`\n      const timestamp =\n        message.timestamp !== undefined &&\n        Number.isFinite(Number(message.timestamp))\n          ? normalizeUnixTimestamp(Number(message.timestamp))\n          : Date.now()\n\n      updateChatStore((prev) => ({\n        messages: [\n          ...prev.messages,\n          {\n            id: messageId,\n            role: \"assistant\",\n            content,\n            timestamp,\n          },\n        ],\n        isTyping: false,\n      }))\n      break\n    }\n\n    case \"message.update\": {\n      const content = (payload.content as string) || \"\"\n      const messageId = payload.message_id as string\n      if (!messageId) {\n        break\n      }\n\n      updateChatStore((prev) => ({\n        messages: prev.messages.map((msg) =>\n          msg.id === messageId ? { ...msg, content } : msg,\n        ),\n      }))\n      break\n    }\n\n    case \"typing.start\":\n      updateChatStore({ isTyping: true })\n      break\n\n    case \"typing.stop\":\n      updateChatStore({ isTyping: false })\n      break\n\n    case \"error\":\n      console.error(\"Pico error:\", payload)\n      updateChatStore({ isTyping: false })\n      break\n\n    case \"pong\":\n      break\n\n    default:\n      console.log(\"Unknown pico message type:\", message.type)\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/features/chat/state.ts",
    "content": "const LAST_SESSION_STORAGE_KEY = \"picoclaw:last-session-id\"\nconst UNIX_MS_THRESHOLD = 1e12\n\nfunction readStorageValue() {\n  return (\n    globalThis.localStorage?.getItem(LAST_SESSION_STORAGE_KEY)?.trim() || \"\"\n  )\n}\n\nexport function readStoredSessionId(): string {\n  return readStorageValue()\n}\n\nexport function writeStoredSessionId(sessionId: string) {\n  if (sessionId) {\n    globalThis.localStorage?.setItem(LAST_SESSION_STORAGE_KEY, sessionId)\n    return\n  }\n\n  globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)\n}\n\nexport function clearStoredSessionId() {\n  globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)\n}\n\nexport function generateSessionId(): string {\n  const webCrypto = globalThis.crypto\n  if (webCrypto && typeof webCrypto.randomUUID === \"function\") {\n    return webCrypto.randomUUID()\n  }\n\n  if (webCrypto && typeof webCrypto.getRandomValues === \"function\") {\n    const bytes = new Uint8Array(16)\n    webCrypto.getRandomValues(bytes)\n\n    bytes[6] = (bytes[6] & 0x0f) | 0x40\n    bytes[8] = (bytes[8] & 0x3f) | 0x80\n\n    const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\"))\n    return (\n      `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +\n      `${hex[4]}${hex[5]}-` +\n      `${hex[6]}${hex[7]}-` +\n      `${hex[8]}${hex[9]}-` +\n      `${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`\n    )\n  }\n\n  return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`\n}\n\nexport function getInitialActiveSessionId(): string {\n  return readStorageValue() || generateSessionId()\n}\n\nexport function normalizeUnixTimestamp(timestamp: number): number {\n  return timestamp < UNIX_MS_THRESHOLD ? timestamp * 1000 : timestamp\n}\n"
  },
  {
    "path": "web/frontend/src/features/chat/websocket.ts",
    "content": "export function normalizeWsUrlForBrowser(wsUrl: string): string {\n  let finalWsUrl = wsUrl\n\n  try {\n    const parsedUrl = new URL(wsUrl)\n    const isLocalHost =\n      parsedUrl.hostname === \"localhost\" ||\n      parsedUrl.hostname === \"127.0.0.1\" ||\n      parsedUrl.hostname === \"0.0.0.0\"\n    const isBrowserLocal =\n      window.location.hostname === \"localhost\" ||\n      window.location.hostname === \"127.0.0.1\"\n\n    if (isLocalHost && !isBrowserLocal) {\n      parsedUrl.hostname = window.location.hostname\n      finalWsUrl = parsedUrl.toString()\n    }\n  } catch (error) {\n    console.warn(\"Could not parse ws_url:\", error)\n  }\n\n  return finalWsUrl\n}\n\nexport function invalidateSocket(socket: WebSocket | null) {\n  if (!socket) {\n    return\n  }\n\n  socket.onopen = null\n  socket.onmessage = null\n  socket.onclose = null\n  socket.onerror = null\n  socket.close()\n}\n\nexport function isCurrentSocket({\n  socket,\n  currentSocket,\n  generation,\n  currentGeneration,\n  sessionId,\n  currentSessionId,\n}: {\n  socket: WebSocket\n  currentSocket: WebSocket | null\n  generation: number\n  currentGeneration: number\n  sessionId: string\n  currentSessionId: string\n}): boolean {\n  return (\n    currentSocket === socket &&\n    generation === currentGeneration &&\n    sessionId === currentSessionId\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-chat-models.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { type ModelInfo, getModels, setDefaultModel } from \"@/api/models\"\n\ninterface UseChatModelsOptions {\n  isConnected: boolean\n}\n\nfunction isLocalModel(model: ModelInfo): boolean {\n  const isLocalHostBase = Boolean(\n    model.api_base?.includes(\"localhost\") ||\n    model.api_base?.includes(\"127.0.0.1\"),\n  )\n\n  return (\n    model.auth_method === \"local\" || (!model.auth_method && isLocalHostBase)\n  )\n}\n\nexport function useChatModels({ isConnected }: UseChatModelsOptions) {\n  const [modelList, setModelList] = useState<ModelInfo[]>([])\n  const [defaultModelName, setDefaultModelName] = useState(\"\")\n  const setDefaultRequestIdRef = useRef(0)\n\n  const loadModels = useCallback(async () => {\n    try {\n      const data = await getModels()\n      setModelList(data.models)\n      if (data.models.some((m) => m.model_name === data.default_model)) {\n        setDefaultModelName(data.default_model)\n      }\n    } catch {\n      // silently fail\n    }\n  }, [])\n\n  useEffect(() => {\n    const timerId = setTimeout(() => {\n      void loadModels()\n    }, 0)\n\n    return () => clearTimeout(timerId)\n  }, [isConnected, loadModels])\n\n  const handleSetDefault = useCallback(\n    async (modelName: string) => {\n      if (modelName === defaultModelName) return\n      const requestId = ++setDefaultRequestIdRef.current\n\n      try {\n        await setDefaultModel(modelName)\n        const data = await getModels()\n        if (requestId !== setDefaultRequestIdRef.current) {\n          return\n        }\n\n        setModelList(data.models)\n        if (data.models.some((m) => m.model_name === data.default_model)) {\n          setDefaultModelName(data.default_model)\n        }\n      } catch (err) {\n        console.error(\"Failed to set default model:\", err)\n      }\n    },\n    [defaultModelName],\n  )\n\n  const hasConfiguredModels = useMemo(\n    () => modelList.some((m) => m.configured),\n    [modelList],\n  )\n\n  const oauthModels = useMemo(\n    () => modelList.filter((m) => m.configured && m.auth_method === \"oauth\"),\n    [modelList],\n  )\n\n  const localModels = useMemo(\n    () => modelList.filter((m) => m.configured && isLocalModel(m)),\n    [modelList],\n  )\n\n  const apiKeyModels = useMemo(\n    () =>\n      modelList.filter(\n        (m) => m.configured && m.auth_method !== \"oauth\" && !isLocalModel(m),\n      ),\n    [modelList],\n  )\n\n  return {\n    defaultModelName,\n    hasConfiguredModels,\n    apiKeyModels,\n    oauthModels,\n    localModels,\n    handleSetDefault,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-credentials-page.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport {\n  type OAuthFlowState,\n  type OAuthProvider,\n  type OAuthProviderStatus,\n  getOAuthFlow,\n  getOAuthProviders,\n  loginOAuth,\n  logoutOAuth,\n  pollOAuthFlow,\n} from \"@/api/oauth\"\n\ntype FlowWatchMode = \"\" | \"status\" | \"poll\"\n\nfunction getProviderLabel(provider: OAuthProvider | \"\"): string {\n  if (provider === \"openai\") return \"OpenAI\"\n  if (provider === \"anthropic\") return \"Anthropic\"\n  if (provider === \"google-antigravity\") return \"Google Antigravity\"\n  return \"\"\n}\n\nexport function useCredentialsPage() {\n  const { t } = useTranslation()\n  const [providers, setProviders] = useState<OAuthProviderStatus[]>([])\n  const [loading, setLoading] = useState(true)\n  const [error, setError] = useState(\"\")\n\n  const [activeAction, setActiveAction] = useState(\"\")\n  const [activeFlow, setActiveFlow] = useState<OAuthFlowState | null>(null)\n  const actionTokenRef = useRef(0)\n\n  const [watchFlowID, setWatchFlowID] = useState(\"\")\n  const [watchMode, setWatchMode] = useState<FlowWatchMode>(\"\")\n  const [pollIntervalMs, setPollIntervalMs] = useState(2000)\n\n  const [openAIToken, setOpenAIToken] = useState(\"\")\n  const [anthropicToken, setAnthropicToken] = useState(\"\")\n\n  const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)\n  const [logoutConfirmProvider, setLogoutConfirmProvider] = useState<\n    OAuthProvider | \"\"\n  >(\"\")\n\n  const [deviceSheetOpen, setDeviceSheetOpen] = useState(false)\n  const [deviceFlow, setDeviceFlow] = useState<OAuthFlowState | null>(null)\n\n  const loadProviders = useCallback(async () => {\n    try {\n      const data = await getOAuthProviders()\n      setProviders(data.providers)\n      setError(\"\")\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : t(\"credentials.errors.loadFailed\"),\n      )\n    } finally {\n      setLoading(false)\n    }\n  }, [t])\n\n  useEffect(() => {\n    void loadProviders()\n  }, [loadProviders])\n\n  useEffect(() => {\n    if (!watchFlowID || !watchMode) {\n      return\n    }\n\n    let canceled = false\n    let timer: ReturnType<typeof setTimeout> | null = null\n\n    const step = async () => {\n      try {\n        const flow =\n          watchMode === \"poll\"\n            ? await pollOAuthFlow(watchFlowID)\n            : await getOAuthFlow(watchFlowID)\n\n        if (canceled) {\n          return\n        }\n\n        setActiveFlow(flow)\n        setDeviceFlow((prev) =>\n          prev?.flow_id === flow.flow_id ? { ...prev, ...flow } : prev,\n        )\n\n        if (flow.status === \"pending\") {\n          timer = setTimeout(step, pollIntervalMs)\n          return\n        }\n\n        if (watchMode === \"poll\") {\n          setDeviceSheetOpen(false)\n        }\n\n        setWatchFlowID(\"\")\n        setWatchMode(\"\")\n        setActiveAction(\"\")\n        await loadProviders()\n      } catch (err) {\n        if (canceled) {\n          return\n        }\n        setWatchFlowID(\"\")\n        setWatchMode(\"\")\n        setActiveAction(\"\")\n        setError(\n          err instanceof Error\n            ? err.message\n            : t(\"credentials.errors.flowFailed\"),\n        )\n      }\n    }\n\n    void step()\n\n    return () => {\n      canceled = true\n      if (timer) {\n        clearTimeout(timer)\n      }\n    }\n  }, [loadProviders, pollIntervalMs, t, watchFlowID, watchMode])\n\n  useEffect(() => {\n    const params = new URLSearchParams(window.location.search)\n    const flowID = params.get(\"oauth_flow_id\")\n    if (!flowID) {\n      return\n    }\n\n    setWatchFlowID(flowID)\n    setWatchMode(\"status\")\n    setPollIntervalMs(700)\n\n    window.history.replaceState({}, \"\", window.location.pathname)\n  }, [])\n\n  useEffect(() => {\n    const onMessage = (event: MessageEvent) => {\n      const data = event.data as\n        | { type?: string; flowId?: string; status?: string }\n        | undefined\n      if (!data || data.type !== \"picoclaw-oauth-result\" || !data.flowId) {\n        return\n      }\n\n      setWatchFlowID(data.flowId)\n      setWatchMode(\"status\")\n      setPollIntervalMs(700)\n    }\n\n    window.addEventListener(\"message\", onMessage)\n    return () => window.removeEventListener(\"message\", onMessage)\n  }, [])\n\n  const providersMap = useMemo(() => {\n    const map = new Map<OAuthProvider, OAuthProviderStatus>()\n    for (const item of providers) {\n      map.set(item.provider, item)\n    }\n    return map\n  }, [providers])\n\n  const openaiStatus = providersMap.get(\"openai\")\n  const anthropicStatus = providersMap.get(\"anthropic\")\n  const antigravityStatus = providersMap.get(\"google-antigravity\")\n\n  const bumpActionToken = useCallback(() => {\n    actionTokenRef.current += 1\n    return actionTokenRef.current\n  }, [])\n\n  const isActionTokenCurrent = useCallback((token: number) => {\n    return actionTokenRef.current === token\n  }, [])\n\n  const startBrowserOAuth = useCallback(\n    async (provider: OAuthProvider) => {\n      const actionToken = bumpActionToken()\n      setActiveAction(`${provider}:browser`)\n      setError(\"\")\n\n      const authTab = window.open(\"\", \"_blank\")\n      if (!authTab) {\n        if (!isActionTokenCurrent(actionToken)) {\n          return\n        }\n        setActiveAction(\"\")\n        setError(t(\"credentials.errors.popupBlocked\"))\n        return\n      }\n\n      try {\n        const resp = await loginOAuth({ provider, method: \"browser\" })\n        if (!isActionTokenCurrent(actionToken)) {\n          authTab.close()\n          return\n        }\n        if (!resp.auth_url || !resp.flow_id) {\n          throw new Error(t(\"credentials.errors.invalidBrowserResponse\"))\n        }\n\n        authTab.location.href = resp.auth_url\n\n        setActiveFlow({\n          flow_id: resp.flow_id,\n          provider,\n          method: \"browser\",\n          status: \"pending\",\n          expires_at: resp.expires_at,\n        })\n        setWatchFlowID(resp.flow_id)\n        setWatchMode(\"status\")\n        setPollIntervalMs(2000)\n      } catch (err) {\n        if (!isActionTokenCurrent(actionToken)) {\n          authTab.close()\n          return\n        }\n        authTab.close()\n        setActiveAction(\"\")\n        setError(\n          err instanceof Error\n            ? err.message\n            : t(\"credentials.errors.loginFailed\"),\n        )\n      }\n    },\n    [bumpActionToken, isActionTokenCurrent, t],\n  )\n\n  const startOpenAIDeviceCode = useCallback(async () => {\n    const actionToken = bumpActionToken()\n    setActiveAction(\"openai:device\")\n    setError(\"\")\n\n    try {\n      const resp = await loginOAuth({\n        provider: \"openai\",\n        method: \"device_code\",\n      })\n      if (!isActionTokenCurrent(actionToken)) {\n        return\n      }\n      if (!resp.flow_id || !resp.user_code || !resp.verify_url) {\n        throw new Error(t(\"credentials.errors.invalidDeviceResponse\"))\n      }\n\n      const flow: OAuthFlowState = {\n        flow_id: resp.flow_id,\n        provider: \"openai\",\n        method: \"device_code\",\n        status: \"pending\",\n        user_code: resp.user_code,\n        verify_url: resp.verify_url,\n        interval: resp.interval,\n        expires_at: resp.expires_at,\n      }\n\n      setDeviceFlow(flow)\n      setDeviceSheetOpen(true)\n      setActiveFlow(flow)\n      setWatchFlowID(resp.flow_id)\n      setWatchMode(\"poll\")\n      setPollIntervalMs(Math.max(1000, (resp.interval ?? 5) * 1000))\n    } catch (err) {\n      if (!isActionTokenCurrent(actionToken)) {\n        return\n      }\n      setActiveAction(\"\")\n      setError(\n        err instanceof Error\n          ? err.message\n          : t(\"credentials.errors.loginFailed\"),\n      )\n    }\n  }, [bumpActionToken, isActionTokenCurrent, t])\n\n  const saveToken = useCallback(\n    async (provider: OAuthProvider, token: string) => {\n      const actionID = `${provider}:token`\n      setActiveAction(actionID)\n      setError(\"\")\n\n      try {\n        await loginOAuth({ provider, method: \"token\", token })\n        if (provider === \"openai\") {\n          setOpenAIToken(\"\")\n        }\n        if (provider === \"anthropic\") {\n          setAnthropicToken(\"\")\n        }\n        await loadProviders()\n      } catch (err) {\n        setError(\n          err instanceof Error\n            ? err.message\n            : t(\"credentials.errors.loginFailed\"),\n        )\n      } finally {\n        setActiveAction(\"\")\n      }\n    },\n    [loadProviders, t],\n  )\n\n  const doLogout = useCallback(\n    async (provider: OAuthProvider) => {\n      const actionID = `${provider}:logout`\n      setActiveAction(actionID)\n      setError(\"\")\n\n      try {\n        await logoutOAuth(provider)\n        await loadProviders()\n      } catch (err) {\n        setError(\n          err instanceof Error\n            ? err.message\n            : t(\"credentials.errors.logoutFailed\"),\n        )\n      } finally {\n        setActiveAction(\"\")\n      }\n    },\n    [loadProviders, t],\n  )\n\n  const askLogout = useCallback((provider: OAuthProvider) => {\n    setLogoutConfirmProvider(provider)\n    setLogoutDialogOpen(true)\n  }, [])\n\n  const handleConfirmLogout = useCallback(async () => {\n    if (!logoutConfirmProvider) {\n      return\n    }\n    await doLogout(logoutConfirmProvider)\n    setLogoutDialogOpen(false)\n    setLogoutConfirmProvider(\"\")\n  }, [doLogout, logoutConfirmProvider])\n\n  const handleLogoutDialogOpenChange = useCallback((open: boolean) => {\n    setLogoutDialogOpen(open)\n    if (!open) {\n      setLogoutConfirmProvider(\"\")\n    }\n  }, [])\n\n  const handleDeviceSheetOpenChange = useCallback(\n    (open: boolean) => {\n      setDeviceSheetOpen(open)\n      if (open) {\n        return\n      }\n\n      if (watchMode === \"poll\") {\n        setWatchFlowID(\"\")\n        setWatchMode(\"\")\n        if (activeAction === \"openai:device\") {\n          setActiveAction(\"\")\n        }\n      }\n\n      setDeviceFlow(null)\n      if (\n        activeFlow?.method === \"device_code\" &&\n        activeFlow.status === \"pending\"\n      ) {\n        setActiveFlow(null)\n      }\n    },\n    [activeAction, activeFlow, watchMode],\n  )\n\n  const stopLoading = useCallback(() => {\n    bumpActionToken()\n    setWatchFlowID(\"\")\n    setWatchMode(\"\")\n    setActiveAction(\"\")\n    setDeviceSheetOpen(false)\n    setDeviceFlow(null)\n    setActiveFlow((prev) => (prev?.status === \"pending\" ? null : prev))\n  }, [bumpActionToken])\n\n  const logoutProviderLabel = getProviderLabel(logoutConfirmProvider)\n\n  const flowHint = useMemo(() => {\n    if (!activeFlow) {\n      return \"\"\n    }\n    if (activeFlow.status === \"pending\") {\n      return t(\"credentials.flow.pending\")\n    }\n    if (activeFlow.status === \"success\") {\n      return t(\"credentials.flow.success\")\n    }\n    if (activeFlow.status === \"expired\") {\n      return t(\"credentials.flow.expired\")\n    }\n    return activeFlow.error || t(\"credentials.flow.error\")\n  }, [activeFlow, t])\n\n  return {\n    loading,\n    error,\n    activeAction,\n    activeFlow,\n    flowHint,\n    openAIToken,\n    anthropicToken,\n    openaiStatus,\n    anthropicStatus,\n    antigravityStatus,\n    logoutDialogOpen,\n    logoutConfirmProvider,\n    logoutProviderLabel,\n    deviceSheetOpen,\n    deviceFlow,\n    setOpenAIToken,\n    setAnthropicToken,\n    startBrowserOAuth,\n    startOpenAIDeviceCode,\n    stopLoading,\n    saveToken,\n    askLogout,\n    handleConfirmLogout,\n    handleLogoutDialogOpenChange,\n    handleDeviceSheetOpenChange,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-gateway-logs.ts",
    "content": "import { useAtomValue } from \"jotai\"\nimport { useEffect, useRef, useState } from \"react\"\n\nimport { clearGatewayLogs, getGatewayLogs } from \"@/api/gateway\"\nimport { gatewayAtom } from \"@/store/gateway\"\n\nexport function useGatewayLogs() {\n  const [logs, setLogs] = useState<string[]>([])\n  const [clearing, setClearing] = useState(false)\n  const logOffsetRef = useRef(0)\n  const logRunIdRef = useRef(-1)\n  const syncTokenRef = useRef(0)\n\n  const gateway = useAtomValue(gatewayAtom)\n\n  const clearLogs = async () => {\n    setClearing(true)\n    try {\n      const data = await clearGatewayLogs()\n      syncTokenRef.current += 1\n      setLogs([])\n      logOffsetRef.current = data.log_total ?? 0\n      if (data.log_run_id !== undefined) {\n        logRunIdRef.current = data.log_run_id\n      }\n    } catch {\n      // Ignore clear failures silently to avoid noisy transient errors.\n    } finally {\n      setClearing(false)\n    }\n  }\n\n  useEffect(() => {\n    let mounted = true\n    let timeout: ReturnType<typeof setTimeout>\n\n    const fetchLogs = async () => {\n      if (\n        !mounted ||\n        ![\"running\", \"starting\", \"restarting\", \"stopping\"].includes(\n          gateway.status,\n        )\n      ) {\n        if (mounted) {\n          timeout = setTimeout(fetchLogs, 1000)\n        }\n        return\n      }\n\n      try {\n        const requestToken = syncTokenRef.current\n        const requestOffset = logOffsetRef.current\n        const requestRunId = logRunIdRef.current\n        const data = await getGatewayLogs({\n          log_offset: requestOffset,\n          log_run_id: requestRunId,\n        })\n\n        if (!mounted || requestToken !== syncTokenRef.current) {\n          return\n        }\n\n        if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {\n          logRunIdRef.current = data.log_run_id\n          logOffsetRef.current = 0\n          if (data.logs) {\n            setLogs(data.logs)\n            logOffsetRef.current = data.log_total || data.logs.length\n          }\n        } else if (data.logs && data.logs.length > 0) {\n          const nextLogs = data.logs\n          setLogs((prev) => [...prev, ...nextLogs])\n          logOffsetRef.current =\n            data.log_total || logOffsetRef.current + nextLogs.length\n        }\n      } catch {\n        // Ignore simple fetch errors during polling.\n      } finally {\n        if (mounted) {\n          timeout = setTimeout(fetchLogs, 1000)\n        }\n      }\n    }\n\n    fetchLogs()\n\n    return () => {\n      mounted = false\n      clearTimeout(timeout)\n    }\n  }, [gateway.status])\n\n  return {\n    clearLogs,\n    clearing,\n    logs,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-gateway.ts",
    "content": "import { useAtomValue } from \"jotai\"\nimport { useCallback, useEffect, useState } from \"react\"\n\nimport { restartGateway, startGateway, stopGateway } from \"@/api/gateway\"\nimport {\n  beginGatewayStoppingTransition,\n  cancelGatewayStoppingTransition,\n  gatewayAtom,\n  refreshGatewayState,\n  subscribeGatewayPolling,\n  updateGatewayStore,\n} from \"@/store\"\n\nexport function useGateway() {\n  const gateway = useAtomValue(gatewayAtom)\n  const { status: state, canStart, restartRequired } = gateway\n  const [loading, setLoading] = useState(false)\n\n  useEffect(() => {\n    return subscribeGatewayPolling()\n  }, [])\n\n  const start = useCallback(async () => {\n    if (!canStart) return\n\n    setLoading(true)\n    try {\n      await startGateway()\n      updateGatewayStore({\n        status: \"starting\",\n        restartRequired: false,\n      })\n    } catch (err) {\n      console.error(\"Failed to start gateway:\", err)\n    } finally {\n      await refreshGatewayState({ force: true })\n      setLoading(false)\n    }\n  }, [canStart])\n\n  const stop = useCallback(async () => {\n    setLoading(true)\n    beginGatewayStoppingTransition()\n    try {\n      await stopGateway()\n    } catch (err) {\n      console.error(\"Failed to stop gateway:\", err)\n      cancelGatewayStoppingTransition()\n    } finally {\n      await refreshGatewayState({ force: true })\n      setLoading(false)\n    }\n  }, [])\n\n  const restart = useCallback(async () => {\n    if (state !== \"running\") return\n\n    setLoading(true)\n    try {\n      await restartGateway()\n      updateGatewayStore({\n        status: \"restarting\",\n        restartRequired: false,\n      })\n    } catch (err) {\n      console.error(\"Failed to restart gateway:\", err)\n    } finally {\n      await refreshGatewayState({ force: true })\n      setLoading(false)\n    }\n  }, [state])\n\n  return { state, loading, canStart, restartRequired, start, stop, restart }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-log-wrap-columns.ts",
    "content": "import { useEffect, useRef, useState } from \"react\"\n\nconst DEFAULT_WRAP_COLUMNS = 120\nconst MIN_WRAP_COLUMNS = 20\n\nexport function useLogWrapColumns() {\n  const [wrapColumns, setWrapColumns] = useState(DEFAULT_WRAP_COLUMNS)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const measureRef = useRef<HTMLSpanElement>(null)\n\n  useEffect(() => {\n    const content = contentRef.current\n    const measure = measureRef.current\n\n    if (!content || !measure) {\n      return\n    }\n\n    const updateWrapColumns = () => {\n      const contentWidth = content.clientWidth\n      const charWidth = measure.getBoundingClientRect().width\n\n      if (!contentWidth || !charWidth) {\n        return\n      }\n\n      const nextColumns = Math.max(\n        Math.floor(contentWidth / charWidth) - 1,\n        MIN_WRAP_COLUMNS,\n      )\n\n      setWrapColumns((current) =>\n        current === nextColumns ? current : nextColumns,\n      )\n    }\n\n    updateWrapColumns()\n\n    const observer = new ResizeObserver(updateWrapColumns)\n    observer.observe(content)\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [])\n\n  return {\n    contentRef,\n    measureRef,\n    wrapColumns,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-mobile.ts",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-pico-chat.ts",
    "content": "import dayjs from \"dayjs\"\nimport { useAtomValue } from \"jotai\"\n\nimport {\n  newChatSession,\n  sendChatMessage,\n  switchChatSession,\n} from \"@/features/chat/controller\"\nimport { chatAtom } from \"@/store/chat\"\n\nconst UNIX_MS_THRESHOLD = 1e12\n\nfunction normalizeUnixTimestamp(timestamp: number): number {\n  return timestamp < UNIX_MS_THRESHOLD ? timestamp * 1000 : timestamp\n}\n\nfunction parseTimestamp(dateRaw: number | string | Date) {\n  if (typeof dateRaw === \"number\") {\n    return dayjs(normalizeUnixTimestamp(dateRaw))\n  }\n\n  if (typeof dateRaw === \"string\") {\n    const trimmed = dateRaw.trim()\n    if (/^-?\\d+(\\.\\d+)?$/.test(trimmed)) {\n      const numeric = Number(trimmed)\n      if (Number.isFinite(numeric)) {\n        return dayjs(normalizeUnixTimestamp(numeric))\n      }\n    }\n    return dayjs(trimmed)\n  }\n\n  return dayjs(dateRaw)\n}\n\nexport function formatMessageTime(dateRaw: number | string | Date): string {\n  const date = parseTimestamp(dateRaw)\n  if (!date.isValid()) {\n    return \"\"\n  }\n  const now = dayjs()\n\n  const isToday = date.isSame(now, \"day\")\n  const isThisYear = date.isSame(now, \"year\")\n\n  if (isToday) {\n    return date.format(\"LT\")\n  }\n\n  if (isThisYear) {\n    return date.format(\"MMM D LT\")\n  }\n\n  return date.format(\"ll LT\")\n}\n\nexport function usePicoChat() {\n  const { messages, connectionState, isTyping, activeSessionId } =\n    useAtomValue(chatAtom)\n\n  return {\n    messages,\n    connectionState,\n    isTyping,\n    activeSessionId,\n    sendMessage: sendChatMessage,\n    switchSession: switchChatSession,\n    newChat: newChatSession,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-session-history.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\"\nimport { useTranslation } from \"react-i18next\"\n\nimport { type SessionSummary, deleteSession, getSessions } from \"@/api/sessions\"\n\nconst LIMIT = 20\n\ninterface UseSessionHistoryOptions {\n  activeSessionId: string\n  onDeletedActiveSession: () => void\n}\n\nexport function useSessionHistory({\n  activeSessionId,\n  onDeletedActiveSession,\n}: UseSessionHistoryOptions) {\n  const { t } = useTranslation()\n  const observerRef = useRef<HTMLDivElement>(null)\n  const [sessions, setSessions] = useState<SessionSummary[]>([])\n  const [offset, setOffset] = useState(0)\n  const [hasMore, setHasMore] = useState(true)\n  const [isLoadingMore, setIsLoadingMore] = useState(false)\n  const [loadError, setLoadError] = useState(false)\n\n  const loadSessions = useCallback(\n    async (reset = true) => {\n      try {\n        const currentOffset = reset ? 0 : offset\n        if (reset) {\n          setLoadError(false)\n          setHasMore(true)\n          setOffset(0)\n        }\n\n        const data = await getSessions(currentOffset, LIMIT)\n        setLoadError(false)\n\n        if (data.length < LIMIT) {\n          setHasMore(false)\n        }\n\n        if (reset) {\n          setSessions(data)\n        } else {\n          setSessions((prev) => {\n            const existingIds = new Set(prev.map((s) => s.id))\n            const newItems = data.filter((s) => !existingIds.has(s.id))\n            return [...prev, ...newItems]\n          })\n        }\n\n        setOffset(currentOffset + data.length)\n      } catch (err) {\n        console.error(\"Failed to fetch session history:\", err)\n        setLoadError(true)\n        if (!reset) {\n          setHasMore(false)\n        }\n      } finally {\n        setIsLoadingMore(false)\n      }\n    },\n    [offset],\n  )\n\n  useEffect(() => {\n    if (!observerRef.current || !hasMore || isLoadingMore || loadError) return\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (\n          entries[0].isIntersecting &&\n          hasMore &&\n          !isLoadingMore &&\n          !loadError\n        ) {\n          setIsLoadingMore(true)\n          void loadSessions(false)\n        }\n      },\n      { threshold: 0.1 },\n    )\n\n    observer.observe(observerRef.current)\n    return () => observer.disconnect()\n  }, [hasMore, isLoadingMore, loadError, loadSessions])\n\n  const handleDeleteSession = useCallback(\n    async (id: string) => {\n      try {\n        await deleteSession(id)\n        setSessions((prev) => prev.filter((s) => s.id !== id))\n        if (id === activeSessionId) {\n          onDeletedActiveSession()\n        }\n      } catch (err) {\n        console.error(\"Failed to delete session:\", err)\n      }\n    },\n    [activeSessionId, onDeletedActiveSession],\n  )\n\n  return {\n    sessions,\n    hasMore,\n    loadError,\n    loadErrorMessage: t(\"chat.historyLoadFailed\"),\n    observerRef,\n    loadSessions,\n    handleDeleteSession,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-sidebar-channels.ts",
    "content": "import {\n  IconBrandChrome,\n  IconBrandDingtalk,\n  IconBrandDiscord,\n  IconBrandLine,\n  IconBrandMatrix,\n  IconBrandQq,\n  IconBrandSlack,\n  IconBrandTelegram,\n  IconBrandWechat,\n  IconBrandWhatsapp,\n  IconCamera,\n  IconMessages,\n  IconPlug,\n  IconRobot,\n} from \"@tabler/icons-react\"\nimport type { TFunction } from \"i18next\"\nimport { useAtomValue } from \"jotai\"\nimport * as React from \"react\"\n\nimport {\n  type AppConfig,\n  type SupportedChannel,\n  getAppConfig,\n  getChannelsCatalog,\n} from \"@/api/channels\"\nimport { getChannelDisplayName } from \"@/components/channels/channel-display-name\"\nimport { gatewayAtom } from \"@/store/gateway\"\n\nconst DEFAULT_VISIBLE_CHANNELS = 4\nconst CHANNEL_IMPORTANCE_ORDER = [\n  \"discord\",\n  \"feishu\",\n  \"telegram\",\n  \"slack\",\n  \"line\",\n  \"wecom\",\n  \"wecom_app\",\n  \"wecom_aibot\",\n  \"dingtalk\",\n  \"qq\",\n  \"onebot\",\n  \"matrix\",\n  \"pico\",\n  \"maixcam\",\n  \"irc\",\n  \"whatsapp\",\n  \"whatsapp_native\",\n]\nconst CHANNEL_IMPORTANCE_INDEX = new Map(\n  CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]),\n)\n\nfunction IconLark({ className }: { className?: string }) {\n  return React.createElement(\"span\", {\n    className,\n    \"aria-hidden\": \"true\",\n    style: {\n      display: \"inline-block\",\n      backgroundColor: \"currentColor\",\n      mask: \"url(/lark.svg) center / contain no-repeat\",\n      WebkitMask: \"url(/lark.svg) center / contain no-repeat\",\n    } as React.CSSProperties,\n  })\n}\n\nconst CHANNEL_ICON_MAP: Record<\n  string,\n  React.ComponentType<{ className?: string }>\n> = {\n  telegram: IconBrandTelegram,\n  discord: IconBrandDiscord,\n  slack: IconBrandSlack,\n  feishu: IconLark,\n  dingtalk: IconBrandDingtalk,\n  line: IconBrandLine,\n  qq: IconBrandQq,\n  wecom: IconBrandWechat,\n  wecom_app: IconBrandWechat,\n  wecom_aibot: IconBrandWechat,\n  whatsapp: IconBrandWhatsapp,\n  whatsapp_native: IconBrandWhatsapp,\n  matrix: IconBrandMatrix,\n  maixcam: IconCamera,\n  onebot: IconRobot,\n  pico: IconBrandChrome,\n  irc: IconMessages,\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return {}\n}\n\nfunction isChannelEnabled(\n  channel: SupportedChannel,\n  channelsConfig: Record<string, unknown>,\n): boolean {\n  const channelConfig = asRecord(channelsConfig[channel.config_key])\n  if (channelConfig.enabled !== true) {\n    return false\n  }\n\n  // whatsapp / whatsapp_native share one config block and are split by use_native.\n  if (channel.name === \"whatsapp_native\") {\n    return channelConfig.use_native === true\n  }\n  if (channel.name === \"whatsapp\") {\n    return channelConfig.use_native !== true\n  }\n\n  return true\n}\n\nfunction buildChannelEnabledMap(\n  channels: SupportedChannel[],\n  appConfig: AppConfig,\n): Record<string, boolean> {\n  const channelsConfig = asRecord(asRecord(appConfig).channels)\n  const result: Record<string, boolean> = {}\n  for (const channel of channels) {\n    result[channel.name] = isChannelEnabled(channel, channelsConfig)\n  }\n  return result\n}\n\nexport interface SidebarChannelNavItem {\n  key: string\n  title: string\n  url: string\n  icon: React.ComponentType<{ className?: string }>\n}\n\ninterface UseSidebarChannelsOptions {\n  t: TFunction\n}\n\nexport function useSidebarChannels({ t }: UseSidebarChannelsOptions) {\n  const gateway = useAtomValue(gatewayAtom)\n  const [channels, setChannels] = React.useState<SupportedChannel[]>([])\n  const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(\n    {},\n  )\n  const [showAllChannels, setShowAllChannels] = React.useState(false)\n\n  const reloadChannels = React.useCallback((shouldApply?: () => boolean) => {\n    Promise.all([\n      getChannelsCatalog(),\n      getAppConfig().catch(() => ({}) as AppConfig),\n    ])\n      .then(([catalog, appConfig]) => {\n        if (shouldApply && !shouldApply()) {\n          return\n        }\n        setChannels(catalog.channels)\n        setEnabledMap(buildChannelEnabledMap(catalog.channels, appConfig))\n      })\n      .catch(() => {\n        if (shouldApply && !shouldApply()) {\n          return\n        }\n        setChannels([])\n        setEnabledMap({})\n      })\n  }, [])\n\n  React.useEffect(() => {\n    let active = true\n    reloadChannels(() => active)\n    return () => {\n      active = false\n    }\n  }, [reloadChannels])\n\n  const previousGatewayStatusRef = React.useRef(gateway.status)\n  React.useEffect(() => {\n    const previousStatus = previousGatewayStatusRef.current\n    if (previousStatus !== \"running\" && gateway.status === \"running\") {\n      reloadChannels()\n    }\n    previousGatewayStatusRef.current = gateway.status\n  }, [gateway.status, reloadChannels])\n\n  const sortedChannels = React.useMemo(() => {\n    const list = [...channels]\n    list.sort((a, b) => {\n      const aEnabled = enabledMap[a.name] === true\n      const bEnabled = enabledMap[b.name] === true\n      if (aEnabled !== bEnabled) {\n        return aEnabled ? -1 : 1\n      }\n\n      const aImportance =\n        CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER\n      const bImportance =\n        CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER\n      if (aImportance !== bImportance) {\n        return aImportance - bImportance\n      }\n\n      return getChannelDisplayName(a, t).localeCompare(\n        getChannelDisplayName(b, t),\n      )\n    })\n    return list\n  }, [channels, enabledMap, t])\n\n  const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS\n  const visibleChannels = showAllChannels\n    ? sortedChannels\n    : sortedChannels.slice(0, DEFAULT_VISIBLE_CHANNELS)\n\n  const channelItems = React.useMemo<SidebarChannelNavItem[]>(\n    () =>\n      visibleChannels.map((channel) => ({\n        key: channel.name,\n        title: getChannelDisplayName(channel, t),\n        url: `/channels/${channel.name}`,\n        icon: CHANNEL_ICON_MAP[channel.name] ?? IconPlug,\n      })),\n    [t, visibleChannels],\n  )\n\n  const toggleShowAllChannels = React.useCallback(() => {\n    setShowAllChannels((prev) => !prev)\n  }, [])\n\n  return {\n    channelItems,\n    hasMoreChannels,\n    showAllChannels,\n    toggleShowAllChannels,\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/hooks/use-theme.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\"\n\ntype Theme = \"light\" | \"dark\"\n\nfunction getStoredTheme(): Theme {\n  if (typeof window === \"undefined\") return \"dark\"\n  return (localStorage.getItem(\"theme\") as Theme) || \"dark\"\n}\n\nexport function useTheme() {\n  const [theme, setThemeState] = useState<Theme>(getStoredTheme)\n\n  useEffect(() => {\n    const root = document.documentElement\n    if (theme === \"dark\") {\n      root.classList.add(\"dark\")\n    } else {\n      root.classList.remove(\"dark\")\n    }\n    localStorage.setItem(\"theme\", theme)\n  }, [theme])\n\n  const toggleTheme = useCallback(() => {\n    setThemeState((prev) => (prev === \"dark\" ? \"light\" : \"dark\"))\n  }, [])\n\n  return { theme, toggleTheme }\n}\n"
  },
  {
    "path": "web/frontend/src/i18n/index.ts",
    "content": "import dayjs from \"dayjs\"\nimport \"dayjs/locale/en\"\nimport \"dayjs/locale/zh-cn\"\nimport localizedFormat from \"dayjs/plugin/localizedFormat\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\nimport i18n from \"i18next\"\nimport LanguageDetector from \"i18next-browser-languagedetector\"\nimport { initReactI18next } from \"react-i18next\"\n\nimport en from \"./locales/en.json\"\nimport zh from \"./locales/zh.json\"\n\ndayjs.extend(relativeTime)\ndayjs.extend(localizedFormat)\n\ni18n\n  // detect user language\n  // learn more: https://github.com/i18next/i18next-browser-languageDetector\n  .use(LanguageDetector)\n  // pass the i18n instance to react-i18next.\n  .use(initReactI18next)\n  // init i18next\n  // for all options read: https://www.i18next.com/overview/configuration-options\n  .init({\n    resources: {\n      en: {\n        translation: en,\n      },\n      zh: {\n        translation: zh,\n      },\n    },\n    fallbackLng: \"en\",\n    debug: false,\n\n    interpolation: {\n      escapeValue: false, // not needed for react as it escapes by default\n    },\n  })\n\ni18n.on(\"languageChanged\", (lng) => {\n  if (lng.startsWith(\"zh\")) {\n    dayjs.locale(\"zh-cn\")\n  } else {\n    dayjs.locale(\"en\")\n  }\n})\n\nexport default i18n\n"
  },
  {
    "path": "web/frontend/src/i18n/locales/en.json",
    "content": "{\n  \"navigation\": {\n    \"chat\": \"Chat\",\n    \"model_group\": \"Models\",\n    \"models\": \"Models\",\n    \"credentials\": \"Credentials\",\n    \"agent_group\": \"Agent\",\n    \"skills\": \"Skills\",\n    \"tools\": \"Tools\",\n    \"services\": \"Services\",\n    \"channels_group\": \"Channels\",\n    \"show_more_channels\": \"More\",\n    \"show_less_channels\": \"Less\",\n    \"config\": \"Config\",\n    \"logs\": \"Logs\"\n  },\n  \"chat\": {\n    \"welcome\": \"How can I help you today?\",\n    \"welcomeDesc\": \"Ask me about weather, settings, or any other tasks. I'm here to assist you.\",\n    \"placeholder\": \"Start a new message...\",\n    \"newChat\": \"New Chat\",\n    \"notConnected\": \"Gateway is not running. Start it to chat.\",\n    \"thinking\": {\n      \"step1\": \"Thinking...\",\n      \"step2\": \"Analyzing your request...\",\n      \"step3\": \"Preparing response...\",\n      \"step4\": \"Almost there...\"\n    },\n    \"history\": \"History\",\n    \"noHistory\": \"No chat history yet\",\n    \"historyLoadFailed\": \"Failed to load chat history\",\n    \"historyOpenFailed\": \"Failed to open this chat history\",\n    \"loadingMore\": \"Loading more...\",\n    \"deleteSession\": \"Delete session\",\n    \"messagesCount\": \"{{count}} messages\",\n    \"noModel\": \"Select model\",\n    \"empty\": {\n      \"noConfiguredModel\": \"No Model Configured\",\n      \"noConfiguredModelDescription\": \"You need to configure at least one AI model with an API key before you can start chatting.\",\n      \"goToModels\": \"Go to Models\",\n      \"noSelectedModel\": \"No Model Selected\",\n      \"noSelectedModelDescription\": \"You have configured models, but none is set as default. Select a model before starting chat.\",\n      \"notRunning\": \"Gateway Not Running\",\n      \"notRunningDescription\": \"Start the gateway service to begin chatting. Use the Start Gateway button in the top bar.\"\n    },\n    \"modelGroup\": {\n      \"apikey\": \"API Key\",\n      \"oauth\": \"OAuth\",\n      \"local\": \"Local\"\n    }\n  },\n  \"header\": {\n    \"gateway\": {\n      \"stopDialog\": {\n        \"title\": \"Stop Gateway Service?\",\n        \"description\": \"Are you sure you want to stop the gateway? This will disconnect your active chat sessions and halt inference.\",\n        \"confirm\": \"Stop Gateway\"\n      },\n      \"action\": {\n        \"start\": \"Start Gateway\",\n        \"stop\": \"Stop Gateway\",\n        \"restart\": \"Restart Gateway\"\n      },\n      \"status\": {\n        \"starting\": \"Starting Gateway...\",\n        \"restarting\": \"Restarting Gateway...\",\n        \"stopping\": \"Stopping Gateway...\"\n      },\n      \"restartRequired\": \"Model changes require a gateway restart to take effect.\"\n    }\n  },\n  \"common\": {\n    \"cancel\": \"Cancel\",\n    \"save\": \"Save\",\n    \"saving\": \"Saving...\",\n    \"reset\": \"Reset\",\n    \"confirm\": \"Confirm\"\n  },\n  \"labels\": {\n    \"loading\": \"Loading...\"\n  },\n  \"credentials\": {\n    \"description\": \"Manage OAuth and token-based credentials for supported providers.\",\n    \"loading\": \"Loading credentials...\",\n    \"providers\": {\n      \"openai\": {\n        \"description\": \"Supports browser OAuth, device code, and token login.\"\n      },\n      \"anthropic\": {\n        \"description\": \"Uses token login for Claude access.\"\n      },\n      \"antigravity\": {\n        \"description\": \"Uses browser OAuth for Google Cloud Code Assist.\"\n      }\n    },\n    \"status\": {\n      \"connected\": \"Connected\",\n      \"needsRefresh\": \"Needs refresh\",\n      \"expired\": \"Expired\",\n      \"notLoggedIn\": \"Not logged in\"\n    },\n    \"actions\": {\n      \"browser\": \"Browser OAuth\",\n      \"deviceCode\": \"Device Code\",\n      \"stopLoading\": \"Stop Loading\",\n      \"saveToken\": \"Save\",\n      \"logout\": \"Logout\"\n    },\n    \"logoutDialog\": {\n      \"title\": \"Logout provider?\",\n      \"description\": \"This will remove your saved credential for {{provider}}.\"\n    },\n    \"fields\": {\n      \"openaiToken\": \"OpenAI token\",\n      \"anthropicToken\": \"Anthropic token\"\n    },\n    \"labels\": {\n      \"account\": \"Account\",\n      \"email\": \"Email\",\n      \"project\": \"Project\"\n    },\n    \"errors\": {\n      \"loadFailed\": \"Failed to load credentials\",\n      \"flowFailed\": \"Failed to check authentication flow\",\n      \"loginFailed\": \"Login failed\",\n      \"logoutFailed\": \"Logout failed\",\n      \"invalidBrowserResponse\": \"Invalid browser login response\",\n      \"invalidDeviceResponse\": \"Invalid device code response\",\n      \"popupBlocked\": \"Unable to open a new tab. Please allow popups and try again.\"\n    },\n    \"flow\": {\n      \"current\": \"Current authentication status\",\n      \"pending\": \"Waiting for authorization...\",\n      \"success\": \"Authentication successful\",\n      \"error\": \"Authentication failed\",\n      \"expired\": \"Authentication session expired\"\n    },\n    \"device\": {\n      \"title\": \"OpenAI Device Login\",\n      \"description\": \"Open the verification page and enter the code below. This page will refresh automatically.\",\n      \"code\": \"User Code\",\n      \"url\": \"Verification URL\",\n      \"polling\": \"Polling login status...\",\n      \"open\": \"Open Verification Page\"\n    }\n  },\n  \"models\": {\n    \"description\": \"Configure API keys for AI providers. Only configured models are available for chat.\",\n    \"loadError\": \"Failed to load models\",\n    \"noDefaultHintPrefix\": \"No default model set yet. Click\",\n    \"noDefaultHintSuffix\": \"to set one.\",\n    \"status\": {\n      \"configured\": \"Configured\",\n      \"unconfigured\": \"Not configured\"\n    },\n    \"badge\": {\n      \"default\": \"Default\"\n    },\n    \"action\": {\n      \"edit\": \"Edit API key\",\n      \"setDefault\": \"Set as default\",\n      \"delete\": \"Delete model\"\n    },\n    \"defaultOnSave\": {\n      \"label\": \"Default Model\",\n      \"description\": \"Automatically set this model as default after saving.\"\n    },\n    \"add\": {\n      \"button\": \"Add Model\",\n      \"title\": \"Add Custom Model\",\n      \"description\": \"Add an OpenAI-compatible or native model endpoint.\",\n      \"modelName\": \"Model Alias\",\n      \"modelNamePlaceholder\": \"e.g. my-gpt4\",\n      \"modelNameHint\": \"A short name used to identify this model in conversations.\",\n      \"modelId\": \"Model Identifier\",\n      \"modelIdPlaceholder\": \"e.g. openai/gpt-4o\",\n      \"modelIdHint\": \"Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …\",\n      \"errorRequired\": \"This field is required.\",\n      \"errorDuplicateModelName\": \"Model alias already exists. Please use a different name.\",\n      \"saveError\": \"Failed to add model\",\n      \"confirm\": \"Add Model\"\n    },\n    \"delete\": {\n      \"title\": \"Delete Model?\",\n      \"description\": \"\\\"{{name}}\\\" will be permanently removed from your model list. This cannot be undone.\",\n      \"confirm\": \"Delete\"\n    },\n    \"advanced\": {\n      \"toggle\": \"Advanced options\"\n    },\n    \"field\": {\n      \"apiBase\": \"API Base URL\",\n      \"apiKey\": \"API Key\",\n      \"apiKeyPlaceholder\": \"Enter your API key\",\n      \"apiKeyPlaceholderSet\": \"Leave blank to keep existing key\",\n      \"proxy\": \"HTTP Proxy\",\n      \"proxyHint\": \"Optional. e.g. http://127.0.0.1:7890\",\n      \"authMethod\": \"Auth Method\",\n      \"authMethodHint\": \"Authentication method: oauth, token. Leave blank for API key auth.\",\n      \"connectMode\": \"Connect Mode\",\n      \"connectModeHint\": \"Connection mode for CLI-based providers: stdio or grpc.\",\n      \"workspace\": \"Workspace Path\",\n      \"workspaceHint\": \"Working directory for CLI-based providers (e.g. GitHub Copilot).\",\n      \"requestTimeout\": \"Request Timeout (s)\",\n      \"requestTimeoutHint\": \"Maximum seconds to wait for a response. 0 = use default.\",\n      \"rpm\": \"Rate Limit (RPM)\",\n      \"rpmHint\": \"Maximum requests per minute. 0 = no limit.\",\n      \"thinkingLevel\": \"Thinking Level\",\n      \"thinkingLevelHint\": \"Extended thinking budget: off, low, medium, high, xhigh, adaptive.\",\n      \"maxTokensField\": \"Max Tokens Field\",\n      \"maxTokensFieldHint\": \"Override the request field name for max tokens, e.g. max_completion_tokens.\"\n    },\n    \"edit\": {\n      \"title\": \"Configure {{name}}\",\n      \"apiKeyHint\": \"A key is already set. Leave blank to keep it unchanged.\",\n      \"oauthNote\": \"This provider uses OAuth — no API key required.\",\n      \"saveError\": \"Failed to save\"\n    }\n  },\n  \"channels\": {\n    \"loadError\": \"Failed to load channels\",\n    \"edit\": \"Configure {{name}}\",\n    \"status\": {\n      \"configured\": \"Configured\"\n    },\n    \"name\": {\n      \"telegram\": \"Telegram\",\n      \"discord\": \"Discord\",\n      \"slack\": \"Slack\",\n      \"feishu\": \"Feishu\",\n      \"dingtalk\": \"DingTalk\",\n      \"line\": \"LINE\",\n      \"qq\": \"QQ\",\n      \"onebot\": \"OneBot\",\n      \"wecom\": \"WeCom\",\n      \"wecom_app\": \"WeCom App\",\n      \"wecom_aibot\": \"WeCom AI Bot\",\n      \"whatsapp\": \"WhatsApp\",\n      \"whatsapp_native\": \"WhatsApp Native\",\n      \"pico\": \"Web\",\n      \"maixcam\": \"MaixCam\",\n      \"matrix\": \"Matrix\",\n      \"irc\": \"IRC\"\n    },\n    \"field\": {\n      \"token\": \"Bot Token\",\n      \"tokenPlaceholder\": \"Enter bot token\",\n      \"botToken\": \"Bot Token\",\n      \"appToken\": \"App Token\",\n      \"appId\": \"App ID\",\n      \"appSecret\": \"App Secret\",\n      \"verificationToken\": \"Verification Token\",\n      \"encryptKey\": \"Encrypt Key\",\n      \"baseUrl\": \"API Base URL\",\n      \"proxy\": \"HTTP Proxy\",\n      \"mentionOnly\": \"Mention Only\",\n      \"typingEnabled\": \"Typing Indicator\",\n      \"placeholderEnabled\": \"Placeholder Message\",\n      \"placeholderText\": \"Placeholder Text\",\n      \"groupTriggerMentionOnly\": \"Group Mention Only\",\n      \"groupTriggerPrefixes\": \"Group Trigger Prefixes\",\n      \"isLark\": \"Lark (International)\",\n      \"allowFrom\": \"Allow From\",\n      \"allowFromPlaceholder\": \"e.g. 123456, 789012\",\n      \"allowOrigins\": \"Allow Origins\",\n      \"allowOriginsPlaceholder\": \"e.g. https://example.com, http://localhost:5173\",\n      \"secretPlaceholder\": \"Enter secret\",\n      \"secretHintSet\": \"A value is already set. Leave blank to keep it unchanged.\"\n    },\n    \"page\": {\n      \"notFound\": \"Channel \\\"{{name}}\\\" is not supported.\",\n      \"saveSuccess\": \"Channel configuration saved.\",\n      \"saveError\": \"Failed to save channel configuration\",\n      \"enabled\": \"enabled\",\n      \"docLink\": \"Documentation\",\n      \"enableLabel\": \"Enable channel\"\n    },\n    \"form\": {\n      \"desc\": {\n        \"token\": \"Bot access token used to connect to the platform API.\",\n        \"botToken\": \"Bot token used to send and receive messages.\",\n        \"appToken\": \"App token used for Socket Mode connections.\",\n        \"appId\": \"Unique application ID used for authentication.\",\n        \"appSecret\": \"Application secret used for signing and authentication.\",\n        \"verificationToken\": \"Verification token for event callbacks.\",\n        \"encryptKey\": \"Encryption key used to decrypt callback payloads.\",\n        \"baseUrl\": \"Platform API base URL. Official endpoint is used by default.\",\n        \"proxy\": \"HTTP proxy address for outbound network access.\",\n        \"mentionOnly\": \"Only respond when the bot is explicitly mentioned in group chats.\",\n        \"typingEnabled\": \"Display typing status while the assistant is generating a response.\",\n        \"placeholderEnabled\": \"Enable temporary placeholder messages before the final reply is sent.\",\n        \"groupTriggerMentionOnly\": \"In group chats, respond only when the bot is mentioned.\",\n        \"groupTriggerPrefixes\": \"Custom group-chat trigger prefixes, separated by commas.\",\n        \"isLark\": \"Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).\",\n        \"allowFrom\": \"Allowed user or group IDs, separated by commas.\",\n        \"allowOrigins\": \"Allowed origin domains, separated by commas.\",\n        \"wsUrl\": \"WebSocket service URL.\",\n        \"reconnectInterval\": \"Reconnect interval after disconnection (seconds).\",\n        \"bridgeUrl\": \"Bridge service URL.\",\n        \"sessionStorePath\": \"Local path for session storage.\",\n        \"useNative\": \"Whether to use native client mode.\",\n        \"host\": \"Service host address.\",\n        \"port\": \"Service port.\",\n        \"homeserver\": \"Matrix homeserver URL.\",\n        \"userId\": \"Account user ID.\",\n        \"deviceId\": \"Device ID.\",\n        \"joinOnInvite\": \"Automatically join rooms when invited.\",\n        \"clientId\": \"Client ID used for platform authentication.\",\n        \"corpId\": \"Enterprise Corp ID.\",\n        \"agentId\": \"Enterprise application Agent ID.\",\n        \"webhookUrl\": \"Full webhook URL.\",\n        \"webhookHost\": \"Webhook listening host.\",\n        \"webhookPort\": \"Webhook listening port.\",\n        \"webhookPath\": \"Webhook route path.\",\n        \"replyTimeout\": \"Reply timeout in seconds.\",\n        \"maxSteps\": \"Maximum number of processing steps.\",\n        \"welcomeMessage\": \"Welcome message content for new sessions.\",\n        \"allowTokenQuery\": \"Allow token in URL query parameters.\",\n        \"pingInterval\": \"Connection heartbeat interval in seconds.\",\n        \"readTimeout\": \"Read timeout in seconds.\",\n        \"writeTimeout\": \"Write timeout in seconds.\",\n        \"maxConnections\": \"Maximum number of concurrent connections.\",\n        \"server\": \"IRC server address.\",\n        \"tls\": \"Whether to enable TLS.\",\n        \"nick\": \"Bot nickname.\",\n        \"user\": \"IRC username.\",\n        \"realName\": \"Displayed real name.\",\n        \"channels\": \"IRC channels to join.\",\n        \"requestCaps\": \"IRC capability list requested on connect.\",\n        \"maxBase64FileSizeMiB\": \"Maximum size in MiB for converting local files to base64 before upload. 0 means unlimited. Applies only to local files, not URL uploads.\",\n        \"genericField\": \"Used to configure {{field}}.\"\n      }\n    },\n    \"validation\": {\n      \"requiredField\": \"This field is required.\"\n    }\n  },\n  \"pages\": {\n    \"agent\": {\n      \"load_error\": \"Failed to load agent support information.\",\n      \"skills\": {\n        \"description\": \"Skills are loaded from the workspace, global PicoClaw home, and builtin directories.\",\n        \"empty\": \"No skills are currently available.\",\n        \"import\": \"Import Skill\",\n        \"import_success\": \"Skill imported.\",\n        \"import_error\": \"Failed to import skill.\",\n        \"view\": \"View\",\n        \"delete\": \"Delete\",\n        \"delete_title\": \"Delete Skill?\",\n        \"delete_description\": \"\\\"{{name}}\\\" will be removed from workspace skills.\",\n        \"delete_confirm\": \"Delete\",\n        \"delete_success\": \"Skill deleted.\",\n        \"delete_error\": \"Failed to delete skill.\",\n        \"viewer_title\": \"Skill Content\",\n        \"viewer_description\": \"Read the current effective SKILL.md content here.\",\n        \"loading_detail\": \"Loading skill content...\",\n        \"load_detail_error\": \"Failed to load skill content.\",\n        \"path\": \"Skill Path\",\n        \"no_description\": \"No description provided.\"\n      },\n      \"tools\": {\n        \"description\": \"This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.\",\n        \"empty\": \"No tools are available.\",\n        \"enable\": \"Enable\",\n        \"disable\": \"Disable\",\n        \"enable_success\": \"Tool enabled.\",\n        \"disable_success\": \"Tool disabled.\",\n        \"toggle_error\": \"Failed to update tool state.\",\n        \"config_key\": \"Controlled by tools.{{key}}\",\n        \"status\": {\n          \"enabled\": \"Enabled\",\n          \"disabled\": \"Disabled\",\n          \"blocked\": \"Blocked\"\n        },\n        \"categories\": {\n          \"automation\": \"Automation\",\n          \"filesystem\": \"Filesystem\",\n          \"web\": \"Web\",\n          \"communication\": \"Communication\",\n          \"skills\": \"Skills\",\n          \"agents\": \"Agents\",\n          \"hardware\": \"Hardware\",\n          \"discovery\": \"Discovery\"\n        },\n        \"reasons\": {\n          \"requires_linux\": \"This tool only works on Linux hosts with the required device files exposed.\",\n          \"requires_skills\": \"Enable `tools.skills` before this skill-registry tool can be used.\",\n          \"requires_subagent\": \"Enable `tools.subagent` before the spawn tool can delegate work.\",\n          \"requires_mcp_discovery\": \"Enable `tools.mcp.discovery` before MCP discovery tools become available.\"\n        }\n      }\n    },\n    \"config\": {\n      \"load_error\": \"Failed to load configuration. Please refresh and try again.\",\n      \"workspace\": \"Workspace Directory\",\n      \"workspace_hint\": \"Base directory for agent file operations.\",\n      \"restrict_workspace\": \"Restrict to Workspace\",\n      \"restrict_workspace_hint\": \"Only allow file operations inside workspace.\",\n      \"exec_enabled\": \"Allow Commands\",\n      \"exec_enabled_hint\": \"Enable or disable command execution for the app. When disabled, no command requests will run.\",\n      \"allow_remote\": \"Allow Remote Commands\",\n      \"allow_remote_hint\": \"When enabled, remote sessions or non-local contexts can also run commands. When disabled, command execution stays limited to local safe contexts.\",\n      \"enable_deny_patterns\": \"Enable Blacklist\",\n      \"enable_deny_patterns_hint\": \"When enabled, the app blocks commands that match its built-in dangerous patterns and the custom command blacklist below.\",\n      \"exec_timeout_seconds\": \"Command Timeout (seconds)\",\n      \"exec_timeout_seconds_hint\": \"Maximum runtime for command requests. Set to 0 to use the default timeout.\",\n      \"custom_deny_patterns\": \"Command Blacklist\",\n      \"custom_deny_patterns_hint\": \"Add extra command-blocking rules, one regular expression per line. A command matching any rule here will be blocked.\",\n      \"custom_allow_patterns\": \"Command Whitelist\",\n      \"custom_allow_patterns_hint\": \"Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.\",\n      \"custom_patterns_placeholder\": \"^rm\\\\s+-rf\\\\b\\n^git\\\\s+push\\\\b\",\n      \"allow_shell_execution\": \"Allow Scheduled Commands\",\n      \"allow_shell_execution_hint\": \"Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.\",\n      \"cron_exec_timeout\": \"Scheduled Command Timeout (minutes)\",\n      \"cron_exec_timeout_hint\": \"Maximum runtime for scheduled commands. Set to 0 to disable the timeout.\",\n      \"max_tokens\": \"Max Tokens\",\n      \"max_tokens_hint\": \"Upper token limit per model response.\",\n      \"max_tool_iterations\": \"Max Tool Iterations\",\n      \"max_tool_iterations_hint\": \"Maximum tool-call loops in a single task.\",\n      \"summarize_threshold\": \"Summarize Message Threshold\",\n      \"summarize_threshold_hint\": \"Start summarization after this many messages.\",\n      \"summarize_token_percent\": \"Summarize Token Percent\",\n      \"summarize_token_percent_hint\": \"Used when conversation summary is triggered.\",\n      \"session_scope\": \"Session Scope\",\n      \"session_scope_hint\": \"How chat context is isolated across peers/channels.\",\n      \"session_scope_per_channel_peer\": \"Per Channel + Peer\",\n      \"session_scope_per_channel_peer_desc\": \"Separate context for each user in each channel.\",\n      \"session_scope_per_channel\": \"Per Channel\",\n      \"session_scope_per_channel_desc\": \"One shared context per channel.\",\n      \"session_scope_per_peer\": \"Per Peer\",\n      \"session_scope_per_peer_desc\": \"One context per user across channels.\",\n      \"session_scope_global\": \"Global\",\n      \"session_scope_global_desc\": \"All messages share one global context.\",\n      \"heartbeat_enabled\": \"Heartbeat\",\n      \"heartbeat_enabled_hint\": \"Send periodic heartbeat messages.\",\n      \"heartbeat_interval\": \"Heartbeat Interval (minutes)\",\n      \"heartbeat_interval_hint\": \"Interval in minutes between heartbeat signals.\",\n      \"devices_enabled\": \"Enable Devices\",\n      \"devices_enabled_hint\": \"Enable hardware-device integrations.\",\n      \"monitor_usb\": \"Monitor USB\",\n      \"monitor_usb_hint\": \"Watch USB plug/unplug events when devices are enabled.\",\n      \"autostart_label\": \"Launch at Login\",\n      \"autostart_hint\": \"Start PicoClaw Web automatically when you log in.\",\n      \"autostart_unsupported\": \"Launch at login is not supported on this platform.\",\n      \"autostart_load_error\": \"Failed to load launch-at-login status.\",\n      \"server_port\": \"Service Port\",\n      \"server_port_hint\": \"HTTP port used by PicoClaw Web.\",\n      \"lan_access\": \"Enable LAN Access\",\n      \"lan_access_hint\": \"Allow access from other devices on your local network.\",\n      \"allowed_cidrs\": \"Allowed Network CIDRs\",\n      \"allowed_cidrs_hint\": \"Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.\",\n      \"allowed_cidrs_placeholder\": \"192.168.1.0/24\\n10.0.0.0/8\",\n      \"sections\": {\n        \"agent\": \"Agent\",\n        \"runtime\": \"Runtime\",\n        \"exec\": \"Run Commands\",\n        \"cron\": \"Cron Tasks\",\n        \"launcher\": \"Service\",\n        \"devices\": \"Devices\"\n      },\n      \"open_raw\": \"Raw Config\",\n      \"back_to_visual\": \"Visual Config\",\n      \"raw_json_title\": \"Raw JSON Configuration\",\n      \"json_placeholder\": \"Enter valid JSON configuration...\",\n      \"save_success\": \"Configuration saved successfully.\",\n      \"save_error\": \"Failed to save configuration.\",\n      \"reset_confirm_title\": \"Reset Changes\",\n      \"reset_confirm_desc\": \"Are you sure you want to reset your unsaved changes back to the last saved state?\",\n      \"reset_success\": \"Changes have been reset to the last saved state.\",\n      \"invalid_json\": \"Invalid JSON format.\",\n      \"format_success\": \"JSON formatted successfully.\",\n      \"format_error\": \"Invalid JSON format.\",\n      \"format\": \"Format\",\n      \"unsaved_changes\": \"You have unsaved changes.\"\n    },\n    \"logs\": {\n      \"clear\": \"Clear logs\",\n      \"empty\": \"Waiting for logs...\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/i18n/locales/zh.json",
    "content": "{\n  \"navigation\": {\n    \"chat\": \"对话\",\n    \"model_group\": \"模型\",\n    \"models\": \"模型\",\n    \"credentials\": \"凭据\",\n    \"agent_group\": \"智能体\",\n    \"skills\": \"技能\",\n    \"tools\": \"工具\",\n    \"services\": \"服务\",\n    \"channels_group\": \"频道\",\n    \"show_more_channels\": \"更多\",\n    \"show_less_channels\": \"收起\",\n    \"config\": \"配置\",\n    \"logs\": \"日志\"\n  },\n  \"chat\": {\n    \"welcome\": \"今天我能为您做些什么？\",\n    \"welcomeDesc\": \"您可以询问我天气、设置或其他任何任务，我随时为您效劳。\",\n    \"placeholder\": \"输入新消息...\",\n    \"newChat\": \"新建对话\",\n    \"notConnected\": \"服务未运行，请先启动以进行对话。\",\n    \"thinking\": {\n      \"step1\": \"思考中...\",\n      \"step2\": \"分析您的请求...\",\n      \"step3\": \"准备回复...\",\n      \"step4\": \"马上就好...\"\n    },\n    \"history\": \"历史记录\",\n    \"noHistory\": \"暂无对话历史\",\n    \"historyLoadFailed\": \"加载历史记录失败\",\n    \"historyOpenFailed\": \"打开该历史会话失败\",\n    \"loadingMore\": \"加载更多...\",\n    \"deleteSession\": \"删除会话\",\n    \"messagesCount\": \"{{count}} 条消息\",\n    \"noModel\": \"选择模型\",\n    \"empty\": {\n      \"noConfiguredModel\": \"尚未配置模型\",\n      \"noConfiguredModelDescription\": \"请先配置至少一个带有 API Key 的 AI 模型，才能开始对话。\",\n      \"goToModels\": \"去模型页配置\",\n      \"noSelectedModel\": \"尚未设置模型\",\n      \"noSelectedModelDescription\": \"您已配置模型，但尚未设置默认模型。请选择一个模型后开始对话\",\n      \"notRunning\": \"服务尚未运行\",\n      \"notRunningDescription\": \"请先启动网关服务后再开始对话，可点击顶部栏中的「启动服务」按钮。\"\n    },\n    \"modelGroup\": {\n      \"apikey\": \"API Key\",\n      \"oauth\": \"OAuth\",\n      \"local\": \"本地模型\"\n    }\n  },\n  \"header\": {\n    \"gateway\": {\n      \"stopDialog\": {\n        \"title\": \"停止服务？\",\n        \"description\": \"您确定要停止服务吗？这将断开您当前活动的聊天会话并停止推理。\",\n        \"confirm\": \"停止服务\"\n      },\n      \"action\": {\n        \"start\": \"启动服务\",\n        \"stop\": \"停止服务\",\n        \"restart\": \"重启服务\"\n      },\n      \"status\": {\n        \"starting\": \"服务启动中...\",\n        \"restarting\": \"服务重启中...\",\n        \"stopping\": \"服务停止中...\"\n      },\n      \"restartRequired\": \"切换默认模型后需要重启服务才能生效。\"\n    }\n  },\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"reset\": \"重置\",\n    \"confirm\": \"确认\"\n  },\n  \"labels\": {\n    \"loading\": \"加载中...\"\n  },\n  \"credentials\": {\n    \"description\": \"管理已支持服务商的 OAuth 与 Token 凭据。\",\n    \"loading\": \"正在加载凭据...\",\n    \"providers\": {\n      \"openai\": {\n        \"description\": \"支持浏览器 OAuth、设备码和 Token 登录。\"\n      },\n      \"anthropic\": {\n        \"description\": \"使用 Token 登录 Claude。\"\n      },\n      \"antigravity\": {\n        \"description\": \"使用浏览器 OAuth 登录 Google Cloud Code Assist。\"\n      }\n    },\n    \"status\": {\n      \"connected\": \"已连接\",\n      \"needsRefresh\": \"即将过期\",\n      \"expired\": \"已过期\",\n      \"notLoggedIn\": \"未登录\"\n    },\n    \"actions\": {\n      \"browser\": \"浏览器 OAuth\",\n      \"deviceCode\": \"设备码\",\n      \"stopLoading\": \"停止加载\",\n      \"saveToken\": \"保存\",\n      \"logout\": \"退出登录\"\n    },\n    \"logoutDialog\": {\n      \"title\": \"确认退出登录？\",\n      \"description\": \"这将删除 {{provider}} 的已保存凭据。\"\n    },\n    \"fields\": {\n      \"openaiToken\": \"OpenAI Token\",\n      \"anthropicToken\": \"Anthropic Token\"\n    },\n    \"labels\": {\n      \"account\": \"账号\",\n      \"email\": \"邮箱\",\n      \"project\": \"项目\"\n    },\n    \"errors\": {\n      \"loadFailed\": \"加载凭据失败\",\n      \"flowFailed\": \"查询授权流程失败\",\n      \"loginFailed\": \"登录失败\",\n      \"logoutFailed\": \"退出登录失败\",\n      \"invalidBrowserResponse\": \"浏览器登录响应无效\",\n      \"invalidDeviceResponse\": \"设备码响应无效\",\n      \"popupBlocked\": \"无法打开新标签页，请允许弹窗后重试。\"\n    },\n    \"flow\": {\n      \"current\": \"当前授权状态\",\n      \"pending\": \"等待授权中...\",\n      \"success\": \"认证成功\",\n      \"error\": \"认证失败\",\n      \"expired\": \"授权会话已过期\"\n    },\n    \"device\": {\n      \"title\": \"OpenAI 设备码登录\",\n      \"description\": \"请打开验证页面并输入下方代码，此页面会自动刷新授权状态。\",\n      \"code\": \"用户代码\",\n      \"url\": \"验证地址\",\n      \"polling\": \"正在轮询登录状态...\",\n      \"open\": \"打开验证页面\"\n    }\n  },\n  \"models\": {\n    \"description\": \"为 AI 服务商配置 API Key。只有已配置的模型可用于对话。\",\n    \"loadError\": \"加载模型列表失败\",\n    \"noDefaultHintPrefix\": \"尚未设置默认模型，点击\",\n    \"noDefaultHintSuffix\": \"设为默认。\",\n    \"status\": {\n      \"configured\": \"已配置\",\n      \"unconfigured\": \"未配置\"\n    },\n    \"badge\": {\n      \"default\": \"默认\"\n    },\n    \"action\": {\n      \"edit\": \"编辑 API Key\",\n      \"setDefault\": \"设为默认\",\n      \"delete\": \"删除模型\"\n    },\n    \"defaultOnSave\": {\n      \"label\": \"默认模型\",\n      \"description\": \"保存后自动将该模型设置为默认模型。\"\n    },\n    \"add\": {\n      \"button\": \"添加模型\",\n      \"title\": \"添加自定义模型\",\n      \"description\": \"添加兼容 OpenAI 或原生协议的模型端点。\",\n      \"modelName\": \"模型别名\",\n      \"modelNamePlaceholder\": \"例如 my-gpt4\",\n      \"modelNameHint\": \"用于在对话中识别此模型的简短名称。\",\n      \"modelId\": \"模型标识符\",\n      \"modelIdPlaceholder\": \"例如 openai/gpt-4o\",\n      \"modelIdHint\": \"格式：协议/模型ID。支持：openai、anthropic、gemini、groq 等。\",\n      \"errorRequired\": \"此字段为必填项。\",\n      \"errorDuplicateModelName\": \"模型别名已存在，请使用其他名称。\",\n      \"saveError\": \"添加模型失败\",\n      \"confirm\": \"添加模型\"\n    },\n    \"delete\": {\n      \"title\": \"确认删除模型？\",\n      \"description\": \"「{{name}}」将从模型列表中永久移除，此操作不可撤销。\",\n      \"confirm\": \"删除\"\n    },\n    \"advanced\": {\n      \"toggle\": \"高级选项\"\n    },\n    \"field\": {\n      \"apiBase\": \"API Base URL\",\n      \"apiKey\": \"API Key\",\n      \"apiKeyPlaceholder\": \"请输入 API Key\",\n      \"apiKeyPlaceholderSet\": \"留空保持原有 Key 不变\",\n      \"proxy\": \"HTTP 代理\",\n      \"proxyHint\": \"可选。例如 http://127.0.0.1:7890\",\n      \"authMethod\": \"认证方式\",\n      \"authMethodHint\": \"认证方式：oauth、token。留空表示使用 API Key 认证。\",\n      \"connectMode\": \"连接模式\",\n      \"connectModeHint\": \"CLI 型服务商的连接模式：stdio 或 grpc。\",\n      \"workspace\": \"工作目录\",\n      \"workspaceHint\": \"CLI 型服务商的工作目录路径（如 GitHub Copilot）。\",\n      \"requestTimeout\": \"请求超时（秒）\",\n      \"requestTimeoutHint\": \"等待响应的最大秒数，0 表示使用默认值。\",\n      \"rpm\": \"速率限制（RPM）\",\n      \"rpmHint\": \"每分钟最大请求数，0 表示不限制。\",\n      \"thinkingLevel\": \"思考级别\",\n      \"thinkingLevelHint\": \"扩展思考预算：off、low、medium、high、xhigh、adaptive。\",\n      \"maxTokensField\": \"Max Tokens 字段名\",\n      \"maxTokensFieldHint\": \"覆盖请求中 max_tokens 的字段名，例如 max_completion_tokens。\"\n    },\n    \"edit\": {\n      \"title\": \"配置 {{name}}\",\n      \"apiKeyHint\": \"已设置 API Key，留空表示不修改。\",\n      \"oauthNote\": \"该服务商使用 OAuth 认证，无需 API Key。\",\n      \"saveError\": \"保存失败\"\n    }\n  },\n  \"channels\": {\n    \"loadError\": \"加载频道列表失败\",\n    \"edit\": \"配置 {{name}}\",\n    \"status\": {\n      \"configured\": \"已配置\"\n    },\n    \"name\": {\n      \"telegram\": \"Telegram\",\n      \"discord\": \"Discord\",\n      \"slack\": \"Slack\",\n      \"feishu\": \"飞书\",\n      \"dingtalk\": \"钉钉\",\n      \"line\": \"LINE\",\n      \"qq\": \"QQ\",\n      \"onebot\": \"OneBot\",\n      \"wecom\": \"企业微信\",\n      \"wecom_app\": \"企业微信应用\",\n      \"wecom_aibot\": \"企业微信 AI 机器人\",\n      \"whatsapp\": \"WhatsApp\",\n      \"whatsapp_native\": \"WhatsApp Native\",\n      \"pico\": \"Web\",\n      \"maixcam\": \"MaixCam\",\n      \"matrix\": \"Matrix\",\n      \"irc\": \"IRC\"\n    },\n    \"field\": {\n      \"token\": \"Bot Token\",\n      \"tokenPlaceholder\": \"输入 Bot Token\",\n      \"botToken\": \"Bot Token\",\n      \"appToken\": \"App Token\",\n      \"appId\": \"App ID\",\n      \"appSecret\": \"App Secret\",\n      \"verificationToken\": \"Verification Token\",\n      \"encryptKey\": \"Encrypt Key\",\n      \"baseUrl\": \"API Base URL\",\n      \"proxy\": \"HTTP 代理\",\n      \"mentionOnly\": \"仅提及时响应\",\n      \"typingEnabled\": \"输入中提示\",\n      \"placeholderEnabled\": \"占位消息\",\n      \"placeholderText\": \"占位文案\",\n      \"groupTriggerMentionOnly\": \"群聊仅提及时响应\",\n      \"groupTriggerPrefixes\": \"群聊触发前缀\",\n      \"isLark\": \"Lark（国际版）\",\n      \"allowFrom\": \"允许来源\",\n      \"allowFromPlaceholder\": \"例如 123456, 789012\",\n      \"allowOrigins\": \"允许来源域名\",\n      \"allowOriginsPlaceholder\": \"例如 https://example.com, http://localhost:5173\",\n      \"secretPlaceholder\": \"输入密钥\",\n      \"secretHintSet\": \"已设置密钥，留空表示不修改。\"\n    },\n    \"page\": {\n      \"notFound\": \"不支持频道“{{name}}”。\",\n      \"saveSuccess\": \"频道配置已保存。\",\n      \"saveError\": \"保存频道配置失败\",\n      \"enabled\": \"已启用\",\n      \"docLink\": \"配置文档\",\n      \"enableLabel\": \"启用频道\"\n    },\n    \"form\": {\n      \"desc\": {\n        \"token\": \"机器人访问令牌，用于连接平台 API。\",\n        \"botToken\": \"Bot Token，用于发送与接收消息。\",\n        \"appToken\": \"App Token，用于 Socket 模式连接。\",\n        \"appId\": \"应用唯一标识，用于平台鉴权。\",\n        \"appSecret\": \"应用密钥，用于请求签名和鉴权。\",\n        \"verificationToken\": \"事件回调验证令牌。\",\n        \"encryptKey\": \"消息加密密钥，用于解密回调内容。\",\n        \"baseUrl\": \"平台 API 地址，默认使用官方地址。\",\n        \"proxy\": \"HTTP 代理地址，用于网络访问。\",\n        \"mentionOnly\": \"在群聊中仅当明确提及时才响应。\",\n        \"typingEnabled\": \"在生成回复时显示“正在输入”状态。\",\n        \"placeholderEnabled\": \"在最终回复发送前，先发送临时占位消息。\",\n        \"groupTriggerMentionOnly\": \"在群聊中仅当提及机器人时才响应。\",\n        \"groupTriggerPrefixes\": \"群聊触发前缀，多个值用逗号分隔。\",\n        \"isLark\": \"使用 Lark 国际版域名（open.larksuite.com）替代飞书域名（open.feishu.cn）。\",\n        \"allowFrom\": \"允许访问的用户或群组 ID，多个值用逗号分隔。\",\n        \"allowOrigins\": \"允许访问的来源域名，多个值用逗号分隔。\",\n        \"wsUrl\": \"WebSocket 服务地址。\",\n        \"reconnectInterval\": \"断线后的重连间隔（秒）。\",\n        \"bridgeUrl\": \"桥接服务地址。\",\n        \"sessionStorePath\": \"本地会话存储目录路径。\",\n        \"useNative\": \"是否使用原生客户端模式连接。\",\n        \"host\": \"服务监听主机地址。\",\n        \"port\": \"服务监听端口。\",\n        \"homeserver\": \"Matrix homeserver 地址。\",\n        \"userId\": \"账号 ID。\",\n        \"deviceId\": \"设备 ID。\",\n        \"joinOnInvite\": \"收到邀请时是否自动加入房间。\",\n        \"clientId\": \"应用客户端 ID，用于平台鉴权。\",\n        \"corpId\": \"企业 ID。\",\n        \"agentId\": \"企业应用 Agent ID。\",\n        \"webhookUrl\": \"Webhook 完整地址。\",\n        \"webhookHost\": \"Webhook 监听主机。\",\n        \"webhookPort\": \"Webhook 监听端口。\",\n        \"webhookPath\": \"Webhook 路径。\",\n        \"replyTimeout\": \"回复超时时间（秒）。\",\n        \"maxSteps\": \"最大步骤数。\",\n        \"welcomeMessage\": \"新会话欢迎语内容。\",\n        \"allowTokenQuery\": \"是否允许 URL Query 方式传递 Token。\",\n        \"pingInterval\": \"连接心跳间隔（秒）。\",\n        \"readTimeout\": \"读取超时时间（秒）。\",\n        \"writeTimeout\": \"写入超时时间（秒）。\",\n        \"maxConnections\": \"最大并发连接数。\",\n        \"server\": \"IRC 服务器地址。\",\n        \"tls\": \"是否启用 TLS 连接。\",\n        \"nick\": \"机器人昵称。\",\n        \"user\": \"IRC 用户名。\",\n        \"realName\": \"显示名称。\",\n        \"channels\": \"要加入的 IRC 频道列表。\",\n        \"requestCaps\": \"连接时请求的 IRC 扩展能力列表。\",\n        \"maxBase64FileSizeMiB\": \"本地文件转为 base64 上传的最大体积，单位 MiB；0 表示不限制，仅影响本地文件，不影响 URL 直传。\",\n        \"genericField\": \"用于配置{{field}}。\"\n      }\n    },\n    \"validation\": {\n      \"requiredField\": \"请填写该字段\"\n    }\n  },\n  \"pages\": {\n    \"agent\": {\n      \"load_error\": \"加载 Agent 支持信息失败。\",\n      \"skills\": {\n        \"description\": \"技能会从工作区、PicoClaw 全局目录和内置目录中加载。\",\n        \"empty\": \"当前没有可用技能。\",\n        \"import\": \"导入技能\",\n        \"import_success\": \"技能导入成功。\",\n        \"import_error\": \"导入技能失败。\",\n        \"view\": \"查看\",\n        \"delete\": \"删除\",\n        \"delete_title\": \"删除技能？\",\n        \"delete_description\": \"将从工作区技能中移除「{{name}}」。\",\n        \"delete_confirm\": \"删除\",\n        \"delete_success\": \"技能已删除。\",\n        \"delete_error\": \"删除技能失败。\",\n        \"viewer_title\": \"技能内容\",\n        \"viewer_description\": \"这里展示当前生效的 SKILL.md 内容。\",\n        \"loading_detail\": \"正在加载技能内容...\",\n        \"load_detail_error\": \"加载技能内容失败。\",\n        \"path\": \"技能路径\",\n        \"no_description\": \"未提供描述。\"\n      },\n      \"tools\": {\n        \"description\": \"这里展示每个 Agent 工具当前是已启用、已禁用，还是被依赖条件阻塞。\",\n        \"empty\": \"当前没有可用工具。\",\n        \"enable\": \"启用\",\n        \"disable\": \"禁用\",\n        \"enable_success\": \"工具已启用。\",\n        \"disable_success\": \"工具已禁用。\",\n        \"toggle_error\": \"更新工具状态失败。\",\n        \"config_key\": \"由 tools.{{key}} 控制\",\n        \"status\": {\n          \"enabled\": \"已启用\",\n          \"disabled\": \"已禁用\",\n          \"blocked\": \"被阻塞\"\n        },\n        \"categories\": {\n          \"automation\": \"自动化\",\n          \"filesystem\": \"文件系统\",\n          \"web\": \"网页\",\n          \"communication\": \"通信\",\n          \"skills\": \"技能\",\n          \"agents\": \"Agent\",\n          \"hardware\": \"硬件\",\n          \"discovery\": \"发现\"\n        },\n        \"reasons\": {\n          \"requires_linux\": \"该工具仅在 Linux 主机上可用，并且需要暴露对应的设备文件。\",\n          \"requires_skills\": \"需要先启用 `tools.skills`，该技能注册表工具才能使用。\",\n          \"requires_subagent\": \"需要先启用 `tools.subagent`，`spawn` 才能委派任务。\",\n          \"requires_mcp_discovery\": \"需要先启用 `tools.mcp.discovery`，MCP 发现工具才会可用。\"\n        }\n      }\n    },\n    \"config\": {\n      \"load_error\": \"加载配置失败，请刷新后重试。\",\n      \"workspace\": \"工作目录\",\n      \"workspace_hint\": \"智能体执行文件读写操作时使用的基础目录。\",\n      \"restrict_workspace\": \"限制工作目录访问\",\n      \"restrict_workspace_hint\": \"仅允许在工作目录内执行文件操作。\",\n      \"exec_enabled\": \"允许命令执行\",\n      \"exec_enabled_hint\": \"控制应用是否允许执行命令。关闭后，所有命令请求都不会执行。\",\n      \"allow_remote\": \"允许远程命令执行\",\n      \"allow_remote_hint\": \"开启后，来自远程会话或非本地上下文的请求也可以执行命令；关闭后，仅允许本地安全上下文执行命令。\",\n      \"enable_deny_patterns\": \"启用黑名单\",\n      \"enable_deny_patterns_hint\": \"开启后，应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令。\",\n      \"exec_timeout_seconds\": \"命令超时（秒）\",\n      \"exec_timeout_seconds_hint\": \"命令请求的最长运行时间。设置为 0 表示使用默认超时。\",\n      \"custom_deny_patterns\": \"命令黑名单\",\n      \"custom_deny_patterns_hint\": \"用于补充额外的命令拦截规则，每行一个正则表达式。命中任意一条规则的命令都会被阻止。\",\n      \"custom_allow_patterns\": \"命令白名单\",\n      \"custom_allow_patterns_hint\": \"用于补充额外的命令放行规则，每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查，但仍受其他安全限制约束。\",\n      \"custom_patterns_placeholder\": \"^rm\\\\s+-rf\\\\b\\n^git\\\\s+push\\\\b\",\n      \"allow_shell_execution\": \"允许定时任务运行命令\",\n      \"allow_shell_execution_hint\": \"开启后，定时任务默认允许运行命令。关闭后，必须显式传入 command_confirm=true 才能创建运行命令的定时任务。\",\n      \"cron_exec_timeout\": \"定时命令超时（分钟）\",\n      \"cron_exec_timeout_hint\": \"定时任务中命令的最长运行时间。设置为 0 表示不限制超时。\",\n      \"max_tokens\": \"最大 Token 数\",\n      \"max_tokens_hint\": \"单次模型响应允许的最大 Token 数。\",\n      \"max_tool_iterations\": \"最大工具迭代次数\",\n      \"max_tool_iterations_hint\": \"单个任务中允许的工具调用循环上限。\",\n      \"summarize_threshold\": \"触发摘要的消息阈值\",\n      \"summarize_threshold_hint\": \"消息数量达到该值后开始触发摘要。\",\n      \"summarize_token_percent\": \"摘要目标 Token 百分比\",\n      \"summarize_token_percent_hint\": \"在触发会话摘要时使用。\",\n      \"session_scope\": \"会话隔离范围\",\n      \"session_scope_hint\": \"定义不同用户/频道之间如何隔离会话上下文。\",\n      \"session_scope_per_channel_peer\": \"按频道+用户隔离\",\n      \"session_scope_per_channel_peer_desc\": \"同一频道内不同用户使用独立上下文。\",\n      \"session_scope_per_channel\": \"按频道隔离\",\n      \"session_scope_per_channel_desc\": \"同一频道内共享一个上下文。\",\n      \"session_scope_per_peer\": \"按用户隔离\",\n      \"session_scope_per_peer_desc\": \"同一用户跨频道共享一个上下文。\",\n      \"session_scope_global\": \"全局共享\",\n      \"session_scope_global_desc\": \"所有消息共用一个全局上下文。\",\n      \"heartbeat_enabled\": \"心跳开关\",\n      \"heartbeat_enabled_hint\": \"按间隔发送系统心跳。\",\n      \"heartbeat_interval\": \"心跳间隔（分钟）\",\n      \"heartbeat_interval_hint\": \"两次心跳发送之间的分钟间隔。\",\n      \"devices_enabled\": \"启用设备功能\",\n      \"devices_enabled_hint\": \"启用与本机硬件设备相关的能力。\",\n      \"monitor_usb\": \"监听 USB\",\n      \"monitor_usb_hint\": \"在启用设备功能时，监听 USB 插拔事件。\",\n      \"autostart_label\": \"开机自启\",\n      \"autostart_hint\": \"登录系统后自动启动 PicoClaw Web。\",\n      \"autostart_unsupported\": \"当前平台不支持开机自启。\",\n      \"autostart_load_error\": \"加载开机自启状态失败。\",\n      \"server_port\": \"服务端口\",\n      \"server_port_hint\": \"PicoClaw Web 的 HTTP 监听端口。\",\n      \"lan_access\": \"启用局域网访问\",\n      \"lan_access_hint\": \"允许局域网中的其他设备访问当前服务。\",\n      \"allowed_cidrs\": \"允许访问网段\",\n      \"allowed_cidrs_hint\": \"仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔；留空表示允许所有来源。\",\n      \"allowed_cidrs_placeholder\": \"192.168.1.0/24\\n10.0.0.0/8\",\n      \"sections\": {\n        \"agent\": \"智能体\",\n        \"runtime\": \"运行时\",\n        \"exec\": \"运行命令\",\n        \"cron\": \"定时任务\",\n        \"launcher\": \"服务参数\",\n        \"devices\": \"设备\"\n      },\n      \"open_raw\": \"原始配置\",\n      \"back_to_visual\": \"可视化配置\",\n      \"raw_json_title\": \"原始 JSON 配置\",\n      \"json_placeholder\": \"请输入有效的 JSON 配置...\",\n      \"save_success\": \"配置保存成功。\",\n      \"save_error\": \"配置保存失败。\",\n      \"reset_confirm_title\": \"重置更改\",\n      \"reset_confirm_desc\": \"您确定要重置回上次保存的状态吗？\",\n      \"reset_success\": \"更改已重置为上次保存的状态。\",\n      \"invalid_json\": \"JSON 格式无效。\",\n      \"format_success\": \"JSON 格式化成功。\",\n      \"format_error\": \"JSON 格式无效。\",\n      \"format\": \"格式化\",\n      \"unsaved_changes\": \"您有未保存的更改。\"\n    },\n    \"logs\": {\n      \"clear\": \"清空日志\",\n      \"empty\": \"等待日志中...\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n@import \"shadcn/tailwind.css\";\n@import \"@fontsource-variable/inter\";\n@plugin \"@tailwindcss/typography\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --radius-2xl: calc(var(--radius) + 8px);\n  --radius-3xl: calc(var(--radius) + 12px);\n  --radius-4xl: calc(var(--radius) + 16px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n:root {\n  --radius: 0.625rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.145 0 0);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.145 0 0);\n  --primary: oklch(0.205 0 0);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.97 0 0);\n  --secondary-foreground: oklch(0.205 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --accent: oklch(0.97 0 0);\n  --accent-foreground: oklch(0.205 0 0);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.922 0 0);\n  --input: oklch(0.922 0 0);\n  --ring: oklch(0.708 0 0);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n    scrollbar-width: thin;\n    scrollbar-color: var(--border) transparent;\n  }\n\n  body {\n    @apply bg-background text-foreground;\n  }\n\n  /* WebKit Custom Scrollbar */\n  ::-webkit-scrollbar {\n    width: 6px;\n    height: 6px;\n  }\n\n  ::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    background: var(--border);\n    border-radius: 4px;\n  }\n\n  ::-webkit-scrollbar-thumb:hover {\n    background: var(--muted-foreground);\n  }\n}\n\n/* Offset sidebar below the full-width header */\n[data-slot=\"sidebar-container\"] {\n  top: 3.5rem !important;\n  height: calc(100svh - 3.5rem) !important;\n}\n\n[data-slot=\"sidebar-gap\"] {\n  height: calc(100svh - 3.5rem);\n}\n\n/* Typing indicator animations */\n@keyframes shimmer {\n  0% {\n    background-position: 200% 0;\n  }\n\n  100% {\n    background-position: -200% 0;\n  }\n}\n\n@keyframes fadeSlideIn {\n  0% {\n    opacity: 0;\n    transform: translateY(4px);\n  }\n\n  100% {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/lib/ansi-log.ts",
    "content": "import type { CSSProperties } from \"react\"\nimport wrapAnsi from \"wrap-ansi\"\n\nexport type AnsiSegment = {\n  style: CSSProperties\n  text: string\n}\n\ntype AnsiState = {\n  background?: string\n  bold?: boolean\n  dim?: boolean\n  foreground?: string\n  italic?: boolean\n  strikethrough?: boolean\n  underline?: boolean\n  underlineColor?: string\n}\n\nconst ANSI_PATTERN = new RegExp(String.raw`\\u001B\\[([0-9;]*)m`, \"g\")\n\nconst ANSI_COLORS = [\n  \"#4b5563\",\n  \"#f87171\",\n  \"#4ade80\",\n  \"#facc15\",\n  \"#60a5fa\",\n  \"#c084fc\",\n  \"#22d3ee\",\n  \"#f3f4f6\",\n]\n\nconst ANSI_BRIGHT_COLORS = [\n  \"#6b7280\",\n  \"#fb7185\",\n  \"#86efac\",\n  \"#fde047\",\n  \"#93c5fd\",\n  \"#e879f9\",\n  \"#67e8f9\",\n  \"#ffffff\",\n]\n\nfunction cloneAnsiState(state: AnsiState): AnsiState {\n  return { ...state }\n}\n\nfunction ansi256ToHex(code: number): string {\n  if (code < 0 || code > 255) {\n    return \"inherit\"\n  }\n\n  if (code < 8) {\n    return ANSI_COLORS[code]\n  }\n\n  if (code < 16) {\n    return ANSI_BRIGHT_COLORS[code - 8]\n  }\n\n  if (code < 232) {\n    const index = code - 16\n    const red = Math.floor(index / 36)\n    const green = Math.floor((index % 36) / 6)\n    const blue = index % 6\n    const scale = [0, 95, 135, 175, 215, 255]\n    return `rgb(${scale[red]}, ${scale[green]}, ${scale[blue]})`\n  }\n\n  const gray = 8 + (code - 232) * 10\n  return `rgb(${gray}, ${gray}, ${gray})`\n}\n\nfunction codeToColor(code: number): string | undefined {\n  if (code >= 30 && code <= 37) {\n    return ANSI_COLORS[code - 30]\n  }\n\n  if (code >= 40 && code <= 47) {\n    return ANSI_COLORS[code - 40]\n  }\n\n  if (code >= 90 && code <= 97) {\n    return ANSI_BRIGHT_COLORS[code - 90]\n  }\n\n  if (code >= 100 && code <= 107) {\n    return ANSI_BRIGHT_COLORS[code - 100]\n  }\n\n  if (code === 39 || code === 49) {\n    return undefined\n  }\n}\n\nfunction applyExtendedColor(\n  state: AnsiState,\n  codes: number[],\n  index: number,\n  target: \"foreground\" | \"background\" | \"underlineColor\",\n): number {\n  const mode = codes[index + 1]\n\n  if (mode === 5) {\n    const colorCode = codes[index + 2]\n    if (colorCode !== undefined) {\n      state[target] = ansi256ToHex(colorCode)\n      return index + 2\n    }\n  }\n\n  if (mode === 2) {\n    const red = codes[index + 2]\n    const green = codes[index + 3]\n    const blue = codes[index + 4]\n    if (red !== undefined && green !== undefined && blue !== undefined) {\n      state[target] = `rgb(${red}, ${green}, ${blue})`\n      return index + 4\n    }\n  }\n\n  return index\n}\n\nfunction styleToCss(style: AnsiState): CSSProperties {\n  return {\n    backgroundColor: style.background,\n    color: style.foreground,\n    fontStyle: style.italic ? \"italic\" : undefined,\n    fontWeight: style.bold ? 700 : undefined,\n    opacity: style.dim ? 0.7 : undefined,\n    textDecorationColor: style.underlineColor,\n    textDecorationLine:\n      [\n        style.underline ? \"underline\" : \"\",\n        style.strikethrough ? \"line-through\" : \"\",\n      ]\n        .filter(Boolean)\n        .join(\" \") || undefined,\n  }\n}\n\nexport function parseAnsiSegments(input: string): AnsiSegment[] {\n  const segments: AnsiSegment[] = []\n  const state: AnsiState = {}\n  let lastIndex = 0\n  let match: RegExpExecArray | null\n\n  const pushText = (text: string) => {\n    if (!text) {\n      return\n    }\n\n    segments.push({\n      style: styleToCss(cloneAnsiState(state)),\n      text,\n    })\n  }\n\n  ANSI_PATTERN.lastIndex = 0\n\n  while ((match = ANSI_PATTERN.exec(input)) !== null) {\n    pushText(input.slice(lastIndex, match.index))\n\n    const codes = (match[1] || \"0\")\n      .split(\";\")\n      .map((value) => (value === \"\" ? 0 : Number.parseInt(value, 10)))\n      .filter((value) => Number.isFinite(value))\n\n    for (let index = 0; index < codes.length; index += 1) {\n      const code = codes[index]\n\n      if (code === 0) {\n        Object.keys(state).forEach((key) => {\n          delete state[key as keyof AnsiState]\n        })\n        continue\n      }\n\n      if (code === 1) {\n        state.bold = true\n        continue\n      }\n\n      if (code === 2) {\n        state.dim = true\n        continue\n      }\n\n      if (code === 3) {\n        state.italic = true\n        continue\n      }\n\n      if (code === 4) {\n        state.underline = true\n        continue\n      }\n\n      if (code === 9) {\n        state.strikethrough = true\n        continue\n      }\n\n      if (code === 21 || code === 22) {\n        delete state.bold\n        delete state.dim\n        continue\n      }\n\n      if (code === 23) {\n        delete state.italic\n        continue\n      }\n\n      if (code === 24) {\n        delete state.underline\n        continue\n      }\n\n      if (code === 29) {\n        delete state.strikethrough\n        continue\n      }\n\n      if (code === 39) {\n        delete state.foreground\n        continue\n      }\n\n      if (code === 49) {\n        delete state.background\n        continue\n      }\n\n      if (code === 59) {\n        delete state.underlineColor\n        continue\n      }\n\n      if (code === 38) {\n        index = applyExtendedColor(state, codes, index, \"foreground\")\n        continue\n      }\n\n      if (code === 48) {\n        index = applyExtendedColor(state, codes, index, \"background\")\n        continue\n      }\n\n      if (code === 58) {\n        index = applyExtendedColor(state, codes, index, \"underlineColor\")\n        continue\n      }\n\n      if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {\n        state.foreground = codeToColor(code)\n        continue\n      }\n\n      if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {\n        state.background = codeToColor(code)\n      }\n    }\n\n    lastIndex = ANSI_PATTERN.lastIndex\n  }\n\n  pushText(input.slice(lastIndex))\n\n  if (segments.length === 0) {\n    return [{ style: {}, text: input }]\n  }\n\n  return segments\n}\n\nexport function wrapLogLine(line: string, columns: number): string {\n  const normalized = line.replaceAll(\"\\r\\n\", \"\\n\").replaceAll(\"\\r\", \"\\n\")\n\n  if (columns < 20) {\n    return normalized\n  }\n\n  return wrapAnsi(normalized, columns, {\n    hard: true,\n    trim: false,\n    wordWrap: false,\n  })\n}\n"
  },
  {
    "path": "web/frontend/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "web/frontend/src/main.tsx",
    "content": "import { QueryClient, QueryClientProvider } from \"@tanstack/react-query\"\nimport { RouterProvider, createRouter } from \"@tanstack/react-router\"\nimport { StrictMode } from \"react\"\nimport ReactDOM from \"react-dom/client\"\n\nimport \"./i18n\"\nimport \"./index.css\"\nimport { routeTree } from \"./routeTree.gen\"\n\nconst queryClient = new QueryClient()\n\nconst router = createRouter({\n  routeTree,\n  context: {\n    queryClient,\n  },\n})\n\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router\n  }\n}\n\nconst rootElement = document.getElementById(\"root\")!\nif (!rootElement.innerHTML) {\n  const root = ReactDOM.createRoot(rootElement)\n  root.render(\n    <StrictMode>\n      <QueryClientProvider client={queryClient}>\n        <RouterProvider router={router} />\n      </QueryClientProvider>\n    </StrictMode>,\n  )\n}\n"
  },
  {
    "path": "web/frontend/src/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from './routes/__root'\nimport { Route as ModelsRouteImport } from './routes/models'\nimport { Route as LogsRouteImport } from './routes/logs'\nimport { Route as CredentialsRouteImport } from './routes/credentials'\nimport { Route as ConfigRouteImport } from './routes/config'\nimport { Route as AgentRouteImport } from './routes/agent'\nimport { Route as ChannelsRouteRouteImport } from './routes/channels/route'\nimport { Route as IndexRouteImport } from './routes/index'\nimport { Route as ConfigRawRouteImport } from './routes/config.raw'\nimport { Route as ChannelsNameRouteImport } from './routes/channels/$name'\nimport { Route as AgentToolsRouteImport } from './routes/agent/tools'\nimport { Route as AgentSkillsRouteImport } from './routes/agent/skills'\n\nconst ModelsRoute = ModelsRouteImport.update({\n  id: '/models',\n  path: '/models',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst LogsRoute = LogsRouteImport.update({\n  id: '/logs',\n  path: '/logs',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst CredentialsRoute = CredentialsRouteImport.update({\n  id: '/credentials',\n  path: '/credentials',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst ConfigRoute = ConfigRouteImport.update({\n  id: '/config',\n  path: '/config',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst AgentRoute = AgentRouteImport.update({\n  id: '/agent',\n  path: '/agent',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst ChannelsRouteRoute = ChannelsRouteRouteImport.update({\n  id: '/channels',\n  path: '/channels',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst IndexRoute = IndexRouteImport.update({\n  id: '/',\n  path: '/',\n  getParentRoute: () => rootRouteImport,\n} as any)\nconst ConfigRawRoute = ConfigRawRouteImport.update({\n  id: '/raw',\n  path: '/raw',\n  getParentRoute: () => ConfigRoute,\n} as any)\nconst ChannelsNameRoute = ChannelsNameRouteImport.update({\n  id: '/$name',\n  path: '/$name',\n  getParentRoute: () => ChannelsRouteRoute,\n} as any)\nconst AgentToolsRoute = AgentToolsRouteImport.update({\n  id: '/tools',\n  path: '/tools',\n  getParentRoute: () => AgentRoute,\n} as any)\nconst AgentSkillsRoute = AgentSkillsRouteImport.update({\n  id: '/skills',\n  path: '/skills',\n  getParentRoute: () => AgentRoute,\n} as any)\n\nexport interface FileRoutesByFullPath {\n  '/': typeof IndexRoute\n  '/channels': typeof ChannelsRouteRouteWithChildren\n  '/agent': typeof AgentRouteWithChildren\n  '/config': typeof ConfigRouteWithChildren\n  '/credentials': typeof CredentialsRoute\n  '/logs': typeof LogsRoute\n  '/models': typeof ModelsRoute\n  '/agent/skills': typeof AgentSkillsRoute\n  '/agent/tools': typeof AgentToolsRoute\n  '/channels/$name': typeof ChannelsNameRoute\n  '/config/raw': typeof ConfigRawRoute\n}\nexport interface FileRoutesByTo {\n  '/': typeof IndexRoute\n  '/channels': typeof ChannelsRouteRouteWithChildren\n  '/agent': typeof AgentRouteWithChildren\n  '/config': typeof ConfigRouteWithChildren\n  '/credentials': typeof CredentialsRoute\n  '/logs': typeof LogsRoute\n  '/models': typeof ModelsRoute\n  '/agent/skills': typeof AgentSkillsRoute\n  '/agent/tools': typeof AgentToolsRoute\n  '/channels/$name': typeof ChannelsNameRoute\n  '/config/raw': typeof ConfigRawRoute\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport\n  '/': typeof IndexRoute\n  '/channels': typeof ChannelsRouteRouteWithChildren\n  '/agent': typeof AgentRouteWithChildren\n  '/config': typeof ConfigRouteWithChildren\n  '/credentials': typeof CredentialsRoute\n  '/logs': typeof LogsRoute\n  '/models': typeof ModelsRoute\n  '/agent/skills': typeof AgentSkillsRoute\n  '/agent/tools': typeof AgentToolsRoute\n  '/channels/$name': typeof ChannelsNameRoute\n  '/config/raw': typeof ConfigRawRoute\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath\n  fullPaths:\n    | '/'\n    | '/channels'\n    | '/agent'\n    | '/config'\n    | '/credentials'\n    | '/logs'\n    | '/models'\n    | '/agent/skills'\n    | '/agent/tools'\n    | '/channels/$name'\n    | '/config/raw'\n  fileRoutesByTo: FileRoutesByTo\n  to:\n    | '/'\n    | '/channels'\n    | '/agent'\n    | '/config'\n    | '/credentials'\n    | '/logs'\n    | '/models'\n    | '/agent/skills'\n    | '/agent/tools'\n    | '/channels/$name'\n    | '/config/raw'\n  id:\n    | '__root__'\n    | '/'\n    | '/channels'\n    | '/agent'\n    | '/config'\n    | '/credentials'\n    | '/logs'\n    | '/models'\n    | '/agent/skills'\n    | '/agent/tools'\n    | '/channels/$name'\n    | '/config/raw'\n  fileRoutesById: FileRoutesById\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute\n  ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren\n  AgentRoute: typeof AgentRouteWithChildren\n  ConfigRoute: typeof ConfigRouteWithChildren\n  CredentialsRoute: typeof CredentialsRoute\n  LogsRoute: typeof LogsRoute\n  ModelsRoute: typeof ModelsRoute\n}\n\ndeclare module '@tanstack/react-router' {\n  interface FileRoutesByPath {\n    '/models': {\n      id: '/models'\n      path: '/models'\n      fullPath: '/models'\n      preLoaderRoute: typeof ModelsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/logs': {\n      id: '/logs'\n      path: '/logs'\n      fullPath: '/logs'\n      preLoaderRoute: typeof LogsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/credentials': {\n      id: '/credentials'\n      path: '/credentials'\n      fullPath: '/credentials'\n      preLoaderRoute: typeof CredentialsRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/config': {\n      id: '/config'\n      path: '/config'\n      fullPath: '/config'\n      preLoaderRoute: typeof ConfigRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/agent': {\n      id: '/agent'\n      path: '/agent'\n      fullPath: '/agent'\n      preLoaderRoute: typeof AgentRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/channels': {\n      id: '/channels'\n      path: '/channels'\n      fullPath: '/channels'\n      preLoaderRoute: typeof ChannelsRouteRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/': {\n      id: '/'\n      path: '/'\n      fullPath: '/'\n      preLoaderRoute: typeof IndexRouteImport\n      parentRoute: typeof rootRouteImport\n    }\n    '/config/raw': {\n      id: '/config/raw'\n      path: '/raw'\n      fullPath: '/config/raw'\n      preLoaderRoute: typeof ConfigRawRouteImport\n      parentRoute: typeof ConfigRoute\n    }\n    '/channels/$name': {\n      id: '/channels/$name'\n      path: '/$name'\n      fullPath: '/channels/$name'\n      preLoaderRoute: typeof ChannelsNameRouteImport\n      parentRoute: typeof ChannelsRouteRoute\n    }\n    '/agent/tools': {\n      id: '/agent/tools'\n      path: '/tools'\n      fullPath: '/agent/tools'\n      preLoaderRoute: typeof AgentToolsRouteImport\n      parentRoute: typeof AgentRoute\n    }\n    '/agent/skills': {\n      id: '/agent/skills'\n      path: '/skills'\n      fullPath: '/agent/skills'\n      preLoaderRoute: typeof AgentSkillsRouteImport\n      parentRoute: typeof AgentRoute\n    }\n  }\n}\n\ninterface ChannelsRouteRouteChildren {\n  ChannelsNameRoute: typeof ChannelsNameRoute\n}\n\nconst ChannelsRouteRouteChildren: ChannelsRouteRouteChildren = {\n  ChannelsNameRoute: ChannelsNameRoute,\n}\n\nconst ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(\n  ChannelsRouteRouteChildren,\n)\n\ninterface AgentRouteChildren {\n  AgentSkillsRoute: typeof AgentSkillsRoute\n  AgentToolsRoute: typeof AgentToolsRoute\n}\n\nconst AgentRouteChildren: AgentRouteChildren = {\n  AgentSkillsRoute: AgentSkillsRoute,\n  AgentToolsRoute: AgentToolsRoute,\n}\n\nconst AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)\n\ninterface ConfigRouteChildren {\n  ConfigRawRoute: typeof ConfigRawRoute\n}\n\nconst ConfigRouteChildren: ConfigRouteChildren = {\n  ConfigRawRoute: ConfigRawRoute,\n}\n\nconst ConfigRouteWithChildren =\n  ConfigRoute._addFileChildren(ConfigRouteChildren)\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  ChannelsRouteRoute: ChannelsRouteRouteWithChildren,\n  AgentRoute: AgentRouteWithChildren,\n  ConfigRoute: ConfigRouteWithChildren,\n  CredentialsRoute: CredentialsRoute,\n  LogsRoute: LogsRoute,\n  ModelsRoute: ModelsRoute,\n}\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>()\n"
  },
  {
    "path": "web/frontend/src/routes/__root.tsx",
    "content": "import { Outlet, createRootRoute } from \"@tanstack/react-router\"\nimport { TanStackRouterDevtools } from \"@tanstack/react-router-devtools\"\nimport { useEffect } from \"react\"\n\nimport { AppLayout } from \"@/components/app-layout\"\nimport { initializeChatStore } from \"@/features/chat/controller\"\n\nconst RootLayout = () => {\n  useEffect(() => {\n    initializeChatStore()\n  }, [])\n\n  return (\n    <AppLayout>\n      <Outlet />\n      <TanStackRouterDevtools />\n    </AppLayout>\n  )\n}\n\nexport const Route = createRootRoute({ component: RootLayout })\n"
  },
  {
    "path": "web/frontend/src/routes/agent/skills.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { SkillsPage } from \"@/components/skills/skills-page\"\n\nexport const Route = createFileRoute(\"/agent/skills\")({\n  component: AgentSkillsRoute,\n})\n\nfunction AgentSkillsRoute() {\n  return <SkillsPage />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/agent/tools.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { ToolsPage } from \"@/components/tools/tools-page\"\n\nexport const Route = createFileRoute(\"/agent/tools\")({\n  component: AgentToolsRoute,\n})\n\nfunction AgentToolsRoute() {\n  return <ToolsPage />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/agent.tsx",
    "content": "import {\n  Navigate,\n  Outlet,\n  createFileRoute,\n  useRouterState,\n} from \"@tanstack/react-router\"\n\nexport const Route = createFileRoute(\"/agent\")({\n  component: AgentLayout,\n})\n\nfunction AgentLayout() {\n  const pathname = useRouterState({\n    select: (state) => state.location.pathname,\n  })\n\n  if (pathname === \"/agent\") {\n    return <Navigate to=\"/agent/skills\" />\n  }\n\n  return <Outlet />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/channels/$name.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { ChannelConfigPage } from \"@/components/channels/channel-config-page\"\n\nexport const Route = createFileRoute(\"/channels/$name\")({\n  component: ChannelsByNameRoute,\n})\n\nfunction ChannelsByNameRoute() {\n  const { name } = Route.useParams()\n\n  return <ChannelConfigPage channelName={name} />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/channels/route.tsx",
    "content": "import {\n  Navigate,\n  Outlet,\n  createFileRoute,\n  useRouterState,\n} from \"@tanstack/react-router\"\n\nexport const Route = createFileRoute(\"/channels\")({\n  component: ChannelsLayout,\n})\n\nfunction ChannelsLayout() {\n  const pathname = useRouterState({\n    select: (state) => state.location.pathname,\n  })\n\n  if (pathname === \"/channels\") {\n    return <Navigate to=\"/channels/$name\" params={{ name: \"pico\" }} />\n  }\n\n  return <Outlet />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/config.raw.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { RawConfigPage } from \"@/components/config/raw-config-page\"\n\nexport const Route = createFileRoute(\"/config/raw\")({\n  component: RawConfigPage,\n})\n"
  },
  {
    "path": "web/frontend/src/routes/config.tsx",
    "content": "import { Outlet, createFileRoute, useRouterState } from \"@tanstack/react-router\"\n\nimport { ConfigPage } from \"@/components/config/config-page\"\n\nexport const Route = createFileRoute(\"/config\")({\n  component: ConfigRouteLayout,\n})\n\nfunction ConfigRouteLayout() {\n  const pathname = useRouterState({\n    select: (state) => state.location.pathname,\n  })\n\n  if (pathname === \"/config\") {\n    return <ConfigPage />\n  }\n\n  return <Outlet />\n}\n"
  },
  {
    "path": "web/frontend/src/routes/credentials.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { CredentialsPage } from \"@/components/credentials/credentials-page\"\n\nexport const Route = createFileRoute(\"/credentials\")({\n  component: CredentialsPage,\n})\n"
  },
  {
    "path": "web/frontend/src/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { ChatPage } from \"@/components/chat/chat-page\"\n\nexport const Route = createFileRoute(\"/\")({\n  component: ChatPage,\n})\n"
  },
  {
    "path": "web/frontend/src/routes/logs.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { LogsPage } from \"@/components/logs/logs-page\"\n\nexport const Route = createFileRoute(\"/logs\")({\n  component: LogsPage,\n})\n"
  },
  {
    "path": "web/frontend/src/routes/models.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\"\n\nimport { ModelsPage } from \"@/components/models/models-page\"\n\nexport const Route = createFileRoute(\"/models\")({\n  component: ModelsPage,\n})\n"
  },
  {
    "path": "web/frontend/src/store/chat.ts",
    "content": "import { atom, getDefaultStore } from \"jotai\"\n\nimport {\n  getInitialActiveSessionId,\n  writeStoredSessionId,\n} from \"@/features/chat/state\"\n\nexport interface ChatMessage {\n  id: string\n  role: \"user\" | \"assistant\"\n  content: string\n  timestamp: number | string\n}\n\nexport type ConnectionState =\n  | \"disconnected\"\n  | \"connecting\"\n  | \"connected\"\n  | \"error\"\n\nexport interface ChatStoreState {\n  messages: ChatMessage[]\n  connectionState: ConnectionState\n  isTyping: boolean\n  activeSessionId: string\n  hasHydratedActiveSession: boolean\n}\n\ntype ChatStorePatch = Partial<ChatStoreState>\n\nconst DEFAULT_CHAT_STATE: ChatStoreState = {\n  messages: [],\n  connectionState: \"disconnected\",\n  isTyping: false,\n  activeSessionId: getInitialActiveSessionId(),\n  hasHydratedActiveSession: false,\n}\n\nexport const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)\n\nconst store = getDefaultStore()\n\nexport function getChatState() {\n  return store.get(chatAtom)\n}\n\nexport function updateChatStore(\n  patch:\n    | ChatStorePatch\n    | ((prev: ChatStoreState) => ChatStorePatch | ChatStoreState),\n) {\n  store.set(chatAtom, (prev) => {\n    const nextPatch = typeof patch === \"function\" ? patch(prev) : patch\n    const next = { ...prev, ...nextPatch }\n\n    if (next.activeSessionId !== prev.activeSessionId) {\n      writeStoredSessionId(next.activeSessionId)\n    }\n\n    return next\n  })\n}\n"
  },
  {
    "path": "web/frontend/src/store/gateway.ts",
    "content": "import { atom, getDefaultStore } from \"jotai\"\n\nimport { type GatewayStatusResponse, getGatewayStatus } from \"@/api/gateway\"\n\nexport type GatewayState =\n  | \"running\"\n  | \"starting\"\n  | \"restarting\"\n  | \"stopping\"\n  | \"stopped\"\n  | \"error\"\n  | \"unknown\"\n\nexport interface GatewayStoreState {\n  status: GatewayState\n  canStart: boolean\n  restartRequired: boolean\n}\n\ntype GatewayStorePatch = Partial<GatewayStoreState>\n\nconst DEFAULT_GATEWAY_STATE: GatewayStoreState = {\n  status: \"unknown\",\n  canStart: true,\n  restartRequired: false,\n}\n\nconst GATEWAY_POLL_INTERVAL_MS = 2000\nconst GATEWAY_TRANSIENT_POLL_INTERVAL_MS = 1000\nconst GATEWAY_STOPPING_TIMEOUT_MS = 5000\n\ninterface RefreshGatewayStateOptions {\n  force?: boolean\n}\n\n// Global atom for gateway state\nexport const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)\n\nlet gatewayPollingSubscribers = 0\nlet gatewayPollingTimer: ReturnType<typeof setTimeout> | null = null\nlet gatewayPollingRequest: Promise<void> | null = null\nlet gatewayStoppingTimer: ReturnType<typeof setTimeout> | null = null\n\nfunction clearGatewayStoppingTimeout() {\n  if (gatewayStoppingTimer !== null) {\n    clearTimeout(gatewayStoppingTimer)\n    gatewayStoppingTimer = null\n  }\n}\n\nfunction normalizeGatewayStoreState(\n  prev: GatewayStoreState,\n  patch: GatewayStorePatch,\n) {\n  const next = { ...prev, ...patch }\n\n  if (\n    next.status === prev.status &&\n    next.canStart === prev.canStart &&\n    next.restartRequired === prev.restartRequired\n  ) {\n    return prev\n  }\n\n  return next\n}\n\nexport function updateGatewayStore(\n  patch:\n    | GatewayStorePatch\n    | ((prev: GatewayStoreState) => GatewayStorePatch | GatewayStoreState),\n) {\n  const store = getDefaultStore()\n  store.set(gatewayAtom, (prev) => {\n    const nextPatch = typeof patch === \"function\" ? patch(prev) : patch\n    return normalizeGatewayStoreState(prev, nextPatch)\n  })\n  const nextState = store.get(gatewayAtom)\n  if (nextState?.status !== \"stopping\") {\n    clearGatewayStoppingTimeout()\n  }\n}\n\nexport function beginGatewayStoppingTransition() {\n  clearGatewayStoppingTimeout()\n  updateGatewayStore({\n    status: \"stopping\",\n    canStart: false,\n    restartRequired: false,\n  })\n  gatewayStoppingTimer = setTimeout(() => {\n    gatewayStoppingTimer = null\n    updateGatewayStore((prev) =>\n      prev.status === \"stopping\" ? { status: \"running\" } : prev,\n    )\n    void refreshGatewayState({ force: true })\n  }, GATEWAY_STOPPING_TIMEOUT_MS)\n}\n\nexport function cancelGatewayStoppingTransition() {\n  clearGatewayStoppingTimeout()\n  updateGatewayStore((prev) =>\n    prev.status === \"stopping\" ? { status: \"running\" } : prev,\n  )\n}\n\nexport function applyGatewayStatusToStore(\n  data: Partial<\n    Pick<\n      GatewayStatusResponse,\n      \"gateway_status\" | \"gateway_start_allowed\" | \"gateway_restart_required\"\n    >\n  >,\n) {\n  updateGatewayStore((prev) => ({\n    status:\n      prev.status === \"stopping\" && data.gateway_status === \"running\"\n        ? \"stopping\"\n        : (data.gateway_status ?? prev.status),\n    canStart:\n      prev.status === \"stopping\" && data.gateway_status === \"running\"\n        ? false\n        : (data.gateway_start_allowed ?? prev.canStart),\n    restartRequired:\n      prev.status === \"stopping\" && data.gateway_status === \"running\"\n        ? false\n        : (data.gateway_restart_required ?? prev.restartRequired),\n  }))\n}\n\nfunction nextGatewayPollInterval() {\n  const status = getDefaultStore().get(gatewayAtom).status\n  if (\n    status === \"starting\" ||\n    status === \"restarting\" ||\n    status === \"stopping\"\n  ) {\n    return GATEWAY_TRANSIENT_POLL_INTERVAL_MS\n  }\n  return GATEWAY_POLL_INTERVAL_MS\n}\n\nfunction scheduleGatewayPoll(delay = nextGatewayPollInterval()) {\n  if (gatewayPollingSubscribers === 0) {\n    return\n  }\n\n  if (gatewayPollingTimer !== null) {\n    clearTimeout(gatewayPollingTimer)\n  }\n\n  gatewayPollingTimer = setTimeout(() => {\n    gatewayPollingTimer = null\n    void refreshGatewayState()\n  }, delay)\n}\n\nexport async function refreshGatewayState(\n  options: RefreshGatewayStateOptions = {},\n) {\n  if (gatewayPollingRequest) {\n    await gatewayPollingRequest\n    if (options.force) {\n      return refreshGatewayState()\n    }\n    return\n  }\n\n  gatewayPollingRequest = (async () => {\n    try {\n      const status = await getGatewayStatus()\n      applyGatewayStatusToStore(status)\n    } catch {\n      // Preserve the last known state when a poll fails.\n    } finally {\n      gatewayPollingRequest = null\n      scheduleGatewayPoll()\n    }\n  })()\n\n  try {\n    await gatewayPollingRequest\n  } finally {\n    if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {\n      clearTimeout(gatewayPollingTimer)\n      gatewayPollingTimer = null\n    }\n  }\n}\n\nexport function subscribeGatewayPolling() {\n  gatewayPollingSubscribers += 1\n  if (gatewayPollingSubscribers === 1) {\n    void refreshGatewayState()\n  }\n\n  return () => {\n    gatewayPollingSubscribers = Math.max(0, gatewayPollingSubscribers - 1)\n    if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {\n      clearTimeout(gatewayPollingTimer)\n      gatewayPollingTimer = null\n    }\n  }\n}\n"
  },
  {
    "path": "web/frontend/src/store/index.ts",
    "content": "export * from \"./gateway\"\nexport * from \"./chat\"\n"
  },
  {
    "path": "web/frontend/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "web/frontend/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "web/frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "web/frontend/vite.config.ts",
    "content": "import path from \"path\"\n\nimport tailwindcss from \"@tailwindcss/vite\"\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\"\nimport react from \"@vitejs/plugin-react\"\nimport { defineConfig } from \"vite\"\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [\n    tanstackRouter({\n      target: \"react\",\n      autoCodeSplitting: true,\n    }),\n    react(),\n    tailwindcss(),\n  ],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  build: {\n    chunkSizeWarningLimit: 2048,\n  },\n  server: {\n    proxy: {\n      \"/api\": {\n        target: \"http://localhost:18800\",\n        changeOrigin: true,\n      },\n      \"/ws\": {\n        target: \"ws://localhost:18800\",\n        ws: true,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "web/picoclaw-launcher.desktop",
    "content": "[Desktop Entry]\nName=PicoClaw Launcher\nComment=Web-based configuration and management UI for PicoClaw\nExec=picoclaw-launcher\nIcon=picoclaw-launcher\nTerminal=true\nType=Application\nCategories=Utility;Network;\nKeywords=picoclaw;ai;agent;bot;\n"
  },
  {
    "path": "workspace/AGENTS.md",
    "content": "# Agent Instructions\n\nYou are a helpful AI assistant. Be concise, accurate, and friendly.\n\n## Guidelines\n\n- Always explain what you're doing before taking actions\n- Ask for clarification when request is ambiguous\n- Use tools to help accomplish tasks\n- Remember important information in your memory files\n- Be proactive and helpful\n- Learn from user feedback"
  },
  {
    "path": "workspace/IDENTITY.md",
    "content": "# Identity\n\n## Name\nPicoClaw 🦞\n\n## Description\nUltra-lightweight personal AI assistant written in Go, inspired by nanobot.\n\n## Purpose\n- Provide intelligent AI assistance with minimal resource usage\n- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)\n- Enable easy customization through skills system\n- Run on minimal hardware ($10 boards, <10MB RAM)\n\n## Capabilities\n\n- Web search and content fetching\n- File system operations (read, write, edit)\n- Shell command execution\n- Multi-channel messaging (Telegram, WhatsApp, Feishu)\n- Skill-based extensibility\n- Memory and context management\n\n## Philosophy\n\n- Simplicity over complexity\n- Performance over features\n- User control and privacy\n- Transparent operation\n- Community-driven development\n\n## Goals\n\n- Provide a fast, lightweight AI assistant\n- Support offline-first operation where possible\n- Enable easy customization and extension\n- Maintain high quality responses\n- Run efficiently on constrained hardware\n\n## License\nMIT License - Free and open source\n\n## Repository\nhttps://github.com/sipeed/picoclaw\n\n## Contact\nIssues: https://github.com/sipeed/picoclaw/issues\nDiscussions: https://github.com/sipeed/picoclaw/discussions\n\n---\n\n\"Every bit helps, every bit matters.\"\n- Picoclaw"
  },
  {
    "path": "workspace/SOUL.md",
    "content": "# Soul\n\nI am picoclaw, a lightweight AI assistant powered by AI.\n\n## Personality\n\n- Helpful and friendly\n- Concise and to the point\n- Curious and eager to learn\n- Honest and transparent\n\n## Values\n\n- Accuracy over speed\n- User privacy and safety\n- Transparency in actions\n- Continuous improvement"
  },
  {
    "path": "workspace/USER.md",
    "content": "# User\n\nInformation about user goes here.\n\n## Preferences\n\n- Communication style: (casual/formal)\n- Timezone: (your timezone)\n- Language: (your preferred language)\n\n## Personal Information\n\n- Name: (optional)\n- Location: (optional)\n- Occupation: (optional)\n\n## Learning Goals\n\n- What the user wants to learn from AI\n- Preferred interaction style\n- Areas of interest"
  },
  {
    "path": "workspace/memory/MEMORY.md",
    "content": "# Long-term Memory\n\nThis file stores important information that should persist across sessions.\n\n## User Information\n\n(Important facts about user)\n\n## Preferences\n\n(User preferences learned over time)\n\n## Important Notes\n\n(Things to remember)\n\n## Configuration\n\n- Model preferences\n- Channel settings\n- Skills enabled"
  },
  {
    "path": "workspace/skills/github/SKILL.md",
    "content": "---\nname: github\ndescription: \"Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.\"\nmetadata: {\"nanobot\":{\"emoji\":\"🐙\",\"requires\":{\"bins\":[\"gh\"]},\"install\":[{\"id\":\"brew\",\"kind\":\"brew\",\"formula\":\"gh\",\"bins\":[\"gh\"],\"label\":\"Install GitHub CLI (brew)\"},{\"id\":\"apt\",\"kind\":\"apt\",\"package\":\"gh\",\"bins\":[\"gh\"],\"label\":\"Install GitHub CLI (apt)\"}]}}\n---\n\n# GitHub Skill\n\nUse the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly.\n\n## Pull Requests\n\nCheck CI status on a PR:\n```bash\ngh pr checks 55 --repo owner/repo\n```\n\nList recent workflow runs:\n```bash\ngh run list --repo owner/repo --limit 10\n```\n\nView a run and see which steps failed:\n```bash\ngh run view <run-id> --repo owner/repo\n```\n\nView logs for failed steps only:\n```bash\ngh run view <run-id> --repo owner/repo --log-failed\n```\n\n## API for Advanced Queries\n\nThe `gh api` command is useful for accessing data not available through other subcommands.\n\nGet PR with specific fields:\n```bash\ngh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login'\n```\n\n## JSON Output\n\nMost commands support `--json` for structured output.  You can use `--jq` to filter:\n\n```bash\ngh issue list --repo owner/repo --json number,title --jq '.[] | \"\\(.number): \\(.title)\"'\n```\n"
  },
  {
    "path": "workspace/skills/hardware/SKILL.md",
    "content": "---\nname: hardware\ndescription: Read and control I2C and SPI peripherals on Sipeed boards (LicheeRV Nano, MaixCAM, NanoKVM).\nhomepage: https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html\nmetadata: {\"nanobot\":{\"emoji\":\"🔧\",\"requires\":{\"tools\":[\"i2c\",\"spi\"]}}}\n---\n\n# Hardware (I2C / SPI)\n\nUse the `i2c` and `spi` tools to interact with sensors, displays, and other peripherals connected to the board.\n\n## Quick Start\n\n```\n# 1. Find available buses\ni2c detect\n\n# 2. Scan for connected devices\ni2c scan  (bus: \"1\")\n\n# 3. Read from a sensor (e.g. AHT20 temperature/humidity)\ni2c read  (bus: \"1\", address: 0x38, register: 0xAC, length: 6)\n\n# 4. SPI devices\nspi list\nspi read  (device: \"2.0\", length: 4)\n```\n\n## Before You Start — Pinmux Setup\n\nMost I2C/SPI pins are shared with WiFi on Sipeed boards. You must configure pinmux before use.\n\nSee `references/board-pinout.md` for board-specific commands.\n\n**Common steps:**\n1. Stop WiFi if using shared pins: `/etc/init.d/S30wifi stop`\n2. Load i2c-dev module: `modprobe i2c-dev`\n3. Configure pinmux with `devmem` (board-specific)\n4. Verify with `i2c detect` and `i2c scan`\n\n## Safety\n\n- **Write operations** require `confirm: true` — always confirm with the user first\n- I2C addresses are validated to 7-bit range (0x03-0x77)\n- SPI modes are validated (0-3 only)\n- Maximum per-transaction: 256 bytes (I2C), 4096 bytes (SPI)\n\n## Common Devices\n\nSee `references/common-devices.md` for register maps and usage of popular sensors:\nAHT20, BME280, SSD1306 OLED, MPU6050 IMU, DS3231 RTC, INA219 power monitor, PCA9685 PWM, and more.\n\n## Troubleshooting\n\n| Problem | Solution |\n|---------|----------|\n| No I2C buses found | `modprobe i2c-dev` and check device tree |\n| Permission denied | Run as root or add user to `i2c` group |\n| No devices on scan | Check wiring, pull-up resistors (4.7k typical), and pinmux |\n| Bus number changed | I2C adapter numbers can shift between boots; use `i2c detect` to find current assignment |\n| WiFi stopped working | I2C-1/SPI-2 share pins with WiFi SDIO; can't use both simultaneously |\n| `devmem` not found | Download separately or use `busybox devmem` |\n| SPI transfer returns all zeros | Check MISO wiring and device power |\n| SPI transfer returns all 0xFF | Device not responding; check CS pin and clock polarity (mode) |\n"
  },
  {
    "path": "workspace/skills/hardware/references/board-pinout.md",
    "content": "# Board Pinout & Pinmux Reference\n\n## LicheeRV Nano (SG2002)\n\n### I2C Buses\n\n| Bus | Pins | Notes |\n|-----|------|-------|\n| I2C-1 | P18 (SCL), P21 (SDA) | **Shared with WiFi SDIO** — must stop WiFi first |\n| I2C-3 | Available on header | Check device tree for pin assignment |\n| I2C-5 | Software (BitBang) | Slower but no pin conflicts |\n\n### SPI Buses\n\n| Bus | Pins | Notes |\n|-----|------|-------|\n| SPI-2 | P18 (CS), P21 (MISO), P22 (MOSI), P23 (SCK) | **Shared with WiFi** — must stop WiFi first |\n| SPI-4 | Software (BitBang) | Slower but no pin conflicts |\n\n### Setup Steps for I2C-1\n\n```bash\n# 1. Stop WiFi (shares pins with I2C-1)\n/etc/init.d/S30wifi stop\n\n# 2. Configure pinmux for I2C-1\ndevmem 0x030010D0 b 0x2   # P18 → I2C1_SCL\ndevmem 0x030010DC b 0x2   # P21 → I2C1_SDA\n\n# 3. Load i2c-dev module\nmodprobe i2c-dev\n\n# 4. Verify\nls /dev/i2c-*\n```\n\n### Setup Steps for SPI-2\n\n```bash\n# 1. Stop WiFi (shares pins with SPI-2)\n/etc/init.d/S30wifi stop\n\n# 2. Configure pinmux for SPI-2\ndevmem 0x030010D0 b 0x1   # P18 → SPI2_CS\ndevmem 0x030010DC b 0x1   # P21 → SPI2_MISO\ndevmem 0x030010E0 b 0x1   # P22 → SPI2_MOSI\ndevmem 0x030010E4 b 0x1   # P23 → SPI2_SCK\n\n# 3. Verify\nls /dev/spidev*\n```\n\n### Max Tested SPI Speed\n- SPI-2 hardware: tested up to **93 MHz**\n- `spidev_test` is pre-installed on the official image for loopback testing\n\n---\n\n## MaixCAM\n\n### I2C Buses\n\n| Bus | Pins | Notes |\n|-----|------|-------|\n| I2C-1 | Overlaps with WiFi | Not recommended |\n| I2C-3 | Overlaps with WiFi | Not recommended |\n| I2C-5 | A15 (SCL), A27 (SDA) | **Recommended** — software I2C, no conflicts |\n\n### Setup Steps for I2C-5\n\n```bash\n# Configure pins using pinmap utility\n# (MaixCAM uses a pinmap tool instead of devmem)\n# Refer to: https://wiki.sipeed.com/hardware/en/maixcam/gpio.html\n\n# Load i2c-dev\nmodprobe i2c-dev\n\n# Verify\nls /dev/i2c-*\n```\n\n---\n\n## MaixCAM2\n\n### I2C Buses\n\n| Bus | Pins | Notes |\n|-----|------|-------|\n| I2C-6 | A1 (SCL), A0 (SDA) | Available on header |\n| I2C-7 | Available | Check device tree |\n\n### Setup Steps\n\n```bash\n# Configure pinmap for I2C-6\n# A1 → I2C6_SCL, A0 → I2C6_SDA\n# Refer to MaixCAM2 documentation for pinmap commands\n\nmodprobe i2c-dev\nls /dev/i2c-*\n```\n\n---\n\n## NanoKVM\n\nUses the same SG2002 SoC as LicheeRV Nano. GPIO and I2C access follows the same pinmux procedure. Refer to the LicheeRV Nano section above.\n\nCheck NanoKVM-specific pin headers for available I2C/SPI lines:\n- https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html\n\n---\n\n## Common Issues\n\n### devmem not found\nThe `devmem` utility may not be in the default image. Options:\n- Use `busybox devmem` if busybox is installed\n- Download devmem from the Sipeed package repository\n- Cross-compile from source (single C file)\n\n### Dynamic bus numbering\nI2C adapter numbers can change between boots depending on driver load order. Always use `i2c detect` to find current bus assignments rather than hardcoding bus numbers.\n\n### Permissions\n`/dev/i2c-*` and `/dev/spidev*` typically require root access. Options:\n- Run picoclaw as root\n- Add user to `i2c` and `spi` groups\n- Create udev rules: `SUBSYSTEM==\"i2c-dev\", MODE=\"0666\"`\n"
  },
  {
    "path": "workspace/skills/hardware/references/common-devices.md",
    "content": "# Common I2C/SPI Device Reference\n\n## I2C Devices\n\n### AHT20 — Temperature & Humidity\n- **Address:** 0x38\n- **Init:** Write `[0xBE, 0x08, 0x00]` then wait 10ms\n- **Measure:** Write `[0xAC, 0x33, 0x00]`, wait 80ms, read 6 bytes\n- **Parse:** Status=byte[0], Humidity=(byte[1]<<12|byte[2]<<4|byte[3]>>4)/2^20*100, Temp=(byte[3]&0x0F<<16|byte[4]<<8|byte[5])/2^20*200-50\n- **Notes:** No register addressing — write command bytes directly (omit `register` param)\n\n### BME280 / BMP280 — Temperature, Humidity, Pressure\n- **Address:** 0x76 or 0x77 (SDO pin selects)\n- **Chip ID register:** 0xD0 → BMP280=0x58, BME280=0x60\n- **Data registers:** 0xF7-0xFE (pressure, temperature, humidity)\n- **Config:** Write 0xF2 (humidity oversampling), 0xF4 (temp/press oversampling + mode), 0xF5 (standby, filter)\n- **Forced measurement:** Write `[0x25]` to register 0xF4, wait 40ms, read 8 bytes from 0xF7\n- **Calibration:** Read 26 bytes from 0x88 and 7 bytes from 0xE1 for compensation formulas\n- **Also available via SPI** (mode 0 or 3)\n\n### SSD1306 — 128x64 OLED Display\n- **Address:** 0x3C (or 0x3D if SA0 high)\n- **Command prefix:** 0x00 (write to register 0x00)\n- **Data prefix:** 0x40 (write to register 0x40)\n- **Init sequence:** `[0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF]`\n- **Display on:** 0xAF, **Display off:** 0xAE\n- **Also available via SPI** (faster, recommended for animations)\n\n### MPU6050 — 6-axis Accelerometer + Gyroscope\n- **Address:** 0x68 (or 0x69 if AD0 high)\n- **WHO_AM_I:** Register 0x75 → should return 0x68\n- **Wake up:** Write `[0x00]` to register 0x6B (clear sleep bit)\n- **Read accel:** 6 bytes from register 0x3B (XH,XL,YH,YL,ZH,ZL) — signed 16-bit, default ±2g\n- **Read gyro:** 6 bytes from register 0x43 — signed 16-bit, default ±250°/s\n- **Read temp:** 2 bytes from register 0x41 — Temp°C = value/340 + 36.53\n\n### DS3231 — Real-Time Clock\n- **Address:** 0x68\n- **Read time:** 7 bytes from register 0x00 (seconds, minutes, hours, day, date, month, year) — BCD encoded\n- **Set time:** Write 7 BCD bytes to register 0x00\n- **Temperature:** 2 bytes from register 0x11 (signed, 0.25°C resolution)\n- **Status:** Register 0x0F — bit 2 = busy, bit 0 = alarm 1 flag\n\n### INA219 — Current & Power Monitor\n- **Address:** 0x40-0x4F (A0,A1 pin selectable)\n- **Config:** Register 0x00 — set voltage range, gain, ADC resolution\n- **Shunt voltage:** Register 0x01 (signed 16-bit, LSB=10µV)\n- **Bus voltage:** Register 0x02 (bits 15:3, LSB=4mV)\n- **Power:** Register 0x03 (after calibration)\n- **Current:** Register 0x04 (after calibration)\n- **Calibration:** Register 0x05 — set based on shunt resistor value\n\n### PCA9685 — 16-Channel PWM / Servo Controller\n- **Address:** 0x40-0x7F (A0-A5 selectable, default 0x40)\n- **Mode 1:** Register 0x00 — bit 4=sleep, bit 5=auto-increment\n- **Set PWM freq:** Sleep → write prescale to 0xFE → wake. Prescale = round(25MHz / (4096 × freq)) - 1\n- **Channel N on/off:** Registers 0x06+4*N to 0x09+4*N (ON_L, ON_H, OFF_L, OFF_H)\n- **Servo 0°-180°:** ON=0, OFF=150-600 (at 50Hz). Typical: 0°=150, 90°=375, 180°=600\n\n### AT24C256 — 256Kbit EEPROM\n- **Address:** 0x50-0x57 (A0-A2 selectable)\n- **Read:** Write 2-byte address (high, low), then read N bytes\n- **Write:** Write 2-byte address + up to 64 bytes (page write), wait 5ms for write cycle\n- **Page size:** 64 bytes. Writes that cross page boundary wrap around.\n\n## SPI Devices\n\n### MCP3008 — 8-Channel 10-bit ADC\n- **Interface:** SPI mode 0, max 3.6 MHz @ 5V\n- **Read channel N:** Send `[0x01, (0x80 | N<<4), 0x00]`, result in last 10 bits of bytes 1-2\n- **Formula:** value = ((byte[1] & 0x03) << 8) | byte[2]\n- **Voltage:** value × Vref / 1024\n\n### W25Q128 — 128Mbit SPI Flash\n- **Interface:** SPI mode 0 or 3, up to 104 MHz\n- **Read ID:** Send `[0x9F, 0, 0, 0]` → manufacturer + device ID\n- **Read data:** Send `[0x03, addr_high, addr_mid, addr_low]` + N zero bytes\n- **Status:** Send `[0x05, 0]` → bit 0 = BUSY\n"
  },
  {
    "path": "workspace/skills/skill-creator/SKILL.md",
    "content": "---\nname: skill-creator\ndescription: Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.\n---\n\n# Skill Creator\n\nThis skill provides guidance for creating effective skills.\n\n## About Skills\n\nSkills are modular, self-contained packages that extend the agent's capabilities by providing\nspecialized knowledge, workflows, and tools. Think of them as \"onboarding guides\" for specific\ndomains or tasks—they transform the agent from a general-purpose agent into a specialized agent\nequipped with procedural knowledge that no model can fully possess.\n\n### What Skills Provide\n\n1. Specialized workflows - Multi-step procedures for specific domains\n2. Tool integrations - Instructions for working with specific file formats or APIs\n3. Domain expertise - Company-specific knowledge, schemas, business logic\n4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks\n\n## Core Principles\n\n### Concise is Key\n\nThe context window is a public good. Skills share the context window with everything else the agent needs: system prompt, conversation history, other Skills' metadata, and the actual user request.\n\n**Default assumption: the agent is already very smart.** Only add context the agent doesn't already have. Challenge each piece of information: \"Does the agent really need this explanation?\" and \"Does this paragraph justify its token cost?\"\n\nPrefer concise examples over verbose explanations.\n\n### Set Appropriate Degrees of Freedom\n\nMatch the level of specificity to the task's fragility and variability:\n\n**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.\n\n**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.\n\n**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.\n\nThink of the agent as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).\n\n### Anatomy of a Skill\n\nEvery skill consists of a required SKILL.md file and optional bundled resources:\n\n```\nskill-name/\n├── SKILL.md (required)\n│   ├── YAML frontmatter metadata (required)\n│   │   ├── name: (required)\n│   │   └── description: (required)\n│   └── Markdown instructions (required)\n└── Bundled Resources (optional)\n    ├── scripts/          - Executable code (Python/Bash/etc.)\n    ├── references/       - Documentation intended to be loaded into context as needed\n    └── assets/           - Files used in output (templates, icons, fonts, etc.)\n```\n\n#### SKILL.md (required)\n\nEvery SKILL.md consists of:\n\n- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that the agent reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.\n- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).\n\n#### Bundled Resources (optional)\n\n##### Scripts (`scripts/`)\n\nExecutable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.\n\n- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed\n- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks\n- **Benefits**: Token efficient, deterministic, may be executed without loading into context\n- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments\n\n##### References (`references/`)\n\nDocumentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking.\n\n- **When to include**: For documentation that the agent should reference while working\n- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications\n- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides\n- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed\n- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md\n- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.\n\n##### Assets (`assets/`)\n\nFiles not intended to be loaded into context, but rather used within the output the agent produces.\n\n- **When to include**: When the skill needs files that will be used in the final output\n- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography\n- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified\n- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context\n\n#### What to Not Include in a Skill\n\nA skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:\n\n- README.md\n- INSTALLATION_GUIDE.md\n- QUICK_REFERENCE.md\n- CHANGELOG.md\n- etc.\n\nThe skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.\n\n### Progressive Disclosure Design Principle\n\nSkills use a three-level loading system to manage context efficiently:\n\n1. **Metadata (name + description)** - Always in context (~100 words)\n2. **SKILL.md body** - When skill triggers (<5k words)\n3. **Bundled resources** - As needed by the agent (Unlimited because scripts can be executed without reading into context window)\n\n#### Progressive Disclosure Patterns\n\nKeep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.\n\n**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.\n\n**Pattern 1: High-level guide with references**\n\n```markdown\n# PDF Processing\n\n## Quick start\n\nExtract text with pdfplumber:\n[code example]\n\n## Advanced features\n\n- **Form filling**: See [FORMS.md](FORMS.md) for complete guide\n- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods\n- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns\n```\n\nthe agent loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.\n\n**Pattern 2: Domain-specific organization**\n\nFor Skills with multiple domains, organize content by domain to avoid loading irrelevant context:\n\n```\nbigquery-skill/\n├── SKILL.md (overview and navigation)\n└── reference/\n    ├── finance.md (revenue, billing metrics)\n    ├── sales.md (opportunities, pipeline)\n    ├── product.md (API usage, features)\n    └── marketing.md (campaigns, attribution)\n```\n\nWhen a user asks about sales metrics, the agent only reads sales.md.\n\nSimilarly, for skills supporting multiple frameworks or variants, organize by variant:\n\n```\ncloud-deploy/\n├── SKILL.md (workflow + provider selection)\n└── references/\n    ├── aws.md (AWS deployment patterns)\n    ├── gcp.md (GCP deployment patterns)\n    └── azure.md (Azure deployment patterns)\n```\n\nWhen the user chooses AWS, the agent only reads aws.md.\n\n**Pattern 3: Conditional details**\n\nShow basic content, link to advanced content:\n\n```markdown\n# DOCX Processing\n\n## Creating documents\n\nUse docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).\n\n## Editing documents\n\nFor simple edits, modify the XML directly.\n\n**For tracked changes**: See [REDLINING.md](REDLINING.md)\n**For OOXML details**: See [OOXML.md](OOXML.md)\n```\n\nthe agent reads REDLINING.md or OOXML.md only when the user needs those features.\n\n**Important guidelines:**\n\n- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.\n- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so the agent can see the full scope when previewing.\n\n## Skill Creation Process\n\nSkill creation involves these steps:\n\n1. Understand the skill with concrete examples\n2. Plan reusable skill contents (scripts, references, assets)\n3. Initialize the skill (run init_skill.py)\n4. Edit the skill (implement resources and write SKILL.md)\n5. Package the skill (run package_skill.py)\n6. Iterate based on real usage\n\nFollow these steps in order, skipping only if there is a clear reason why they are not applicable.\n\n### Skill Naming\n\n- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., \"Plan Mode\" -> `plan-mode`).\n- When generating names, generate a name under 64 characters (letters, digits, hyphens).\n- Prefer short, verb-led phrases that describe the action.\n- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).\n- Name the skill folder exactly after the skill name.\n\n### Step 1: Understanding the Skill with Concrete Examples\n\nSkip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.\n\nTo create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.\n\nFor example, when building an image-editor skill, relevant questions include:\n\n- \"What functionality should the image-editor skill support? Editing, rotating, anything else?\"\n- \"Can you give some examples of how this skill would be used?\"\n- \"I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?\"\n- \"What would a user say that should trigger this skill?\"\n\nTo avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.\n\nConclude this step when there is a clear sense of the functionality the skill should support.\n\n### Step 2: Planning the Reusable Skill Contents\n\nTo turn concrete examples into an effective skill, analyze each example by:\n\n1. Considering how to execute on the example from scratch\n2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly\n\nExample: When building a `pdf-editor` skill to handle queries like \"Help me rotate this PDF,\" the analysis shows:\n\n1. Rotating a PDF requires re-writing the same code each time\n2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill\n\nExample: When designing a `frontend-webapp-builder` skill for queries like \"Build me a todo app\" or \"Build me a dashboard to track my steps,\" the analysis shows:\n\n1. Writing a frontend webapp requires the same boilerplate HTML/React each time\n2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill\n\nExample: When building a `big-query` skill to handle queries like \"How many users have logged in today?\" the analysis shows:\n\n1. Querying BigQuery requires re-discovering the table schemas and relationships each time\n2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill\n\nTo establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.\n\n### Step 3: Initializing the Skill\n\nAt this point, it is time to actually create the skill.\n\nSkip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.\n\nWhen creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.\n\nUsage:\n\n```bash\nscripts/init_skill.py <skill-name> --path <output-directory> [--resources scripts,references,assets] [--examples]\n```\n\nExamples:\n\n```bash\nscripts/init_skill.py my-skill --path skills/public\nscripts/init_skill.py my-skill --path skills/public --resources scripts,references\nscripts/init_skill.py my-skill --path skills/public --resources scripts --examples\n```\n\nThe script:\n\n- Creates the skill directory at the specified path\n- Generates a SKILL.md template with proper frontmatter and TODO placeholders\n- Optionally creates resource directories based on `--resources`\n- Optionally adds example files when `--examples` is set\n\nAfter initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.\n\n### Step 4: Edit the Skill\n\nWhen editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively.\n\n#### Learn Proven Design Patterns\n\nConsult these helpful guides based on your skill's needs:\n\n- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic\n- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns\n\nThese files contain established best practices for effective skill design.\n\n#### Start with Reusable Skill Contents\n\nTo begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.\n\nAdded scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.\n\nIf you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required.\n\n#### Update SKILL.md\n\n**Writing Guidelines:** Always use imperative/infinitive form.\n\n##### Frontmatter\n\nWrite the YAML frontmatter with `name` and `description`:\n\n- `name`: The skill name\n- `description`: This is the primary triggering mechanism for your skill, and helps the agent understand when to use the skill.\n  - Include both what the Skill does and specific triggers/contexts for when to use it.\n  - Include all \"when to use\" information here - Not in the body. The body is only loaded after triggering, so \"When to Use This Skill\" sections in the body are not helpful to the agent.\n  - Example description for a `docx` skill: \"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks\"\n\nDo not include any other fields in YAML frontmatter.\n\n##### Body\n\nWrite instructions for using the skill and its bundled resources.\n\n### Step 5: Packaging a Skill\n\nOnce development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:\n\n```bash\nscripts/package_skill.py <path/to/skill-folder>\n```\n\nOptional output directory specification:\n\n```bash\nscripts/package_skill.py <path/to/skill-folder> ./dist\n```\n\nThe packaging script will:\n\n1. **Validate** the skill automatically, checking:\n\n   - YAML frontmatter format and required fields\n   - Skill naming conventions and directory structure\n   - Description completeness and quality\n   - File organization and resource references\n\n2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.\n\nIf validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.\n\n### Step 6: Iterate\n\nAfter testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.\n\n**Iteration workflow:**\n\n1. Use the skill on real tasks\n2. Notice struggles or inefficiencies\n3. Identify how SKILL.md or bundled resources should be updated\n4. Implement changes and test again\n"
  },
  {
    "path": "workspace/skills/summarize/SKILL.md",
    "content": "---\nname: summarize\ndescription: Summarize or extract text/transcripts from URLs, podcasts, and local files (great fallback for “transcribe this YouTube/video”).\nhomepage: https://summarize.sh\nmetadata: {\"nanobot\":{\"emoji\":\"🧾\",\"requires\":{\"bins\":[\"summarize\"]},\"install\":[{\"id\":\"brew\",\"kind\":\"brew\",\"formula\":\"steipete/tap/summarize\",\"bins\":[\"summarize\"],\"label\":\"Install summarize (brew)\"}]}}\n---\n\n# Summarize\n\nFast CLI to summarize URLs, local files, and YouTube links.\n\n## When to use (trigger phrases)\n\nUse this skill immediately when the user asks any of:\n- “use summarize.sh”\n- “what’s this link/video about?”\n- “summarize this URL/article”\n- “transcribe this YouTube/video” (best-effort transcript extraction; no `yt-dlp` needed)\n\n## Quick start\n\n```bash\nsummarize \"https://example.com\" --model google/gemini-3-flash-preview\nsummarize \"/path/to/file.pdf\" --model google/gemini-3-flash-preview\nsummarize \"https://youtu.be/dQw4w9WgXcQ\" --youtube auto\n```\n\n## YouTube: summary vs transcript\n\nBest-effort transcript (URLs only):\n\n```bash\nsummarize \"https://youtu.be/dQw4w9WgXcQ\" --youtube auto --extract-only\n```\n\nIf the user asked for a transcript but it’s huge, return a tight summary first, then ask which section/time range to expand.\n\n## Model + keys\n\nSet the API key for your chosen provider:\n- OpenAI: `OPENAI_API_KEY`\n- Anthropic: `ANTHROPIC_API_KEY`\n- xAI: `XAI_API_KEY`\n- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)\n\nDefault model is `google/gemini-3-flash-preview` if none is set.\n\n## Useful flags\n\n- `--length short|medium|long|xl|xxl|<chars>`\n- `--max-output-tokens <count>`\n- `--extract-only` (URLs only)\n- `--json` (machine readable)\n- `--firecrawl auto|off|always` (fallback extraction)\n- `--youtube auto` (Apify fallback if `APIFY_API_TOKEN` set)\n\n## Config\n\nOptional config file: `~/.summarize/config.json`\n\n```json\n{ \"model\": \"openai/gpt-5.4\" }\n```\n\nOptional services:\n- `FIRECRAWL_API_KEY` for blocked sites\n- `APIFY_API_TOKEN` for YouTube fallback\n"
  },
  {
    "path": "workspace/skills/tmux/SKILL.md",
    "content": "---\nname: tmux\ndescription: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.\nmetadata: {\"nanobot\":{\"emoji\":\"🧵\",\"os\":[\"darwin\",\"linux\"],\"requires\":{\"bins\":[\"tmux\"]}}}\n---\n\n# tmux Skill\n\nUse tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks.\n\n## Quickstart (isolated socket, exec tool)\n\n```bash\nSOCKET_DIR=\"${NANOBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/nanobot-tmux-sockets}\"\nmkdir -p \"$SOCKET_DIR\"\nSOCKET=\"$SOCKET_DIR/nanobot.sock\"\nSESSION=nanobot-python\n\ntmux -S \"$SOCKET\" new -d -s \"$SESSION\" -n shell\ntmux -S \"$SOCKET\" send-keys -t \"$SESSION\":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter\ntmux -S \"$SOCKET\" capture-pane -p -J -t \"$SESSION\":0.0 -S -200\n```\n\nAfter starting a session, always print monitor commands:\n\n```\nTo monitor:\n  tmux -S \"$SOCKET\" attach -t \"$SESSION\"\n  tmux -S \"$SOCKET\" capture-pane -p -J -t \"$SESSION\":0.0 -S -200\n```\n\n## Socket convention\n\n- Use `NANOBOT_TMUX_SOCKET_DIR` environment variable.\n- Default socket path: `\"$NANOBOT_TMUX_SOCKET_DIR/nanobot.sock\"`.\n\n## Targeting panes and naming\n\n- Target format: `session:window.pane` (defaults to `:0.0`).\n- Keep names short; avoid spaces.\n- Inspect: `tmux -S \"$SOCKET\" list-sessions`, `tmux -S \"$SOCKET\" list-panes -a`.\n\n## Finding sessions\n\n- List sessions on your socket: `{baseDir}/scripts/find-sessions.sh -S \"$SOCKET\"`.\n- Scan all sockets: `{baseDir}/scripts/find-sessions.sh --all` (uses `NANOBOT_TMUX_SOCKET_DIR`).\n\n## Sending input safely\n\n- Prefer literal sends: `tmux -S \"$SOCKET\" send-keys -t target -l -- \"$cmd\"`.\n- Control keys: `tmux -S \"$SOCKET\" send-keys -t target C-c`.\n\n## Watching output\n\n- Capture recent history: `tmux -S \"$SOCKET\" capture-pane -p -J -t target -S -200`.\n- Wait for prompts: `{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern'`.\n- Attaching is OK; detach with `Ctrl+b d`.\n\n## Spawning processes\n\n- For python REPLs, set `PYTHON_BASIC_REPL=1` (non-basic REPL breaks send-keys flows).\n\n## Windows / WSL\n\n- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL.\n- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH.\n\n## Orchestrating Coding Agents (Codex, Claude Code)\n\ntmux excels at running multiple coding agents in parallel:\n\n```bash\nSOCKET=\"${TMPDIR:-/tmp}/codex-army.sock\"\n\n# Create multiple sessions\nfor i in 1 2 3 4 5; do\n  tmux -S \"$SOCKET\" new-session -d -s \"agent-$i\"\ndone\n\n# Launch agents in different workdirs\ntmux -S \"$SOCKET\" send-keys -t agent-1 \"cd /tmp/project1 && codex --yolo 'Fix bug X'\" Enter\ntmux -S \"$SOCKET\" send-keys -t agent-2 \"cd /tmp/project2 && codex --yolo 'Fix bug Y'\" Enter\n\n# Poll for completion (check if prompt returned)\nfor sess in agent-1 agent-2; do\n  if tmux -S \"$SOCKET\" capture-pane -p -t \"$sess\" -S -3 | grep -q \"❯\"; then\n    echo \"$sess: DONE\"\n  else\n    echo \"$sess: Running...\"\n  fi\ndone\n\n# Get full output from completed session\ntmux -S \"$SOCKET\" capture-pane -p -t agent-1 -S -500\n```\n\n**Tips:**\n- Use separate git worktrees for parallel fixes (no branch conflicts)\n- `pnpm install` first before running codex in fresh clones\n- Check for shell prompt (`❯` or `$`) to detect completion\n- Codex needs `--yolo` or `--full-auto` for non-interactive fixes\n\n## Cleanup\n\n- Kill a session: `tmux -S \"$SOCKET\" kill-session -t \"$SESSION\"`.\n- Kill all sessions on a socket: `tmux -S \"$SOCKET\" list-sessions -F '#{session_name}' | xargs -r -n1 tmux -S \"$SOCKET\" kill-session -t`.\n- Remove everything on the private socket: `tmux -S \"$SOCKET\" kill-server`.\n\n## Helper: wait-for-text.sh\n\n`{baseDir}/scripts/wait-for-text.sh` polls a pane for a regex (or fixed string) with a timeout.\n\n```bash\n{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern' [-F] [-T 20] [-i 0.5] [-l 2000]\n```\n\n- `-t`/`--target` pane target (required)\n- `-p`/`--pattern` regex to match (required); add `-F` for fixed string\n- `-T` timeout seconds (integer, default 15)\n- `-i` poll interval seconds (default 0.5)\n- `-l` history lines to search (integer, default 1000)\n"
  },
  {
    "path": "workspace/skills/tmux/scripts/find-sessions.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n  cat <<'USAGE'\nUsage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern]\n\nList tmux sessions on a socket (default tmux socket if none provided).\n\nOptions:\n  -L, --socket       tmux socket name (passed to tmux -L)\n  -S, --socket-path  tmux socket path (passed to tmux -S)\n  -A, --all          scan all sockets under NANOBOT_TMUX_SOCKET_DIR\n  -q, --query        case-insensitive substring to filter session names\n  -h, --help         show this help\nUSAGE\n}\n\nsocket_name=\"\"\nsocket_path=\"\"\nquery=\"\"\nscan_all=false\nsocket_dir=\"${NANOBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/nanobot-tmux-sockets}\"\n\nwhile [[ $# -gt 0 ]]; do\n  case \"$1\" in\n    -L|--socket)      socket_name=\"${2-}\"; shift 2 ;;\n    -S|--socket-path) socket_path=\"${2-}\"; shift 2 ;;\n    -A|--all)         scan_all=true; shift ;;\n    -q|--query)       query=\"${2-}\"; shift 2 ;;\n    -h|--help)        usage; exit 0 ;;\n    *) echo \"Unknown option: $1\" >&2; usage; exit 1 ;;\n  esac\ndone\n\nif [[ \"$scan_all\" == true && ( -n \"$socket_name\" || -n \"$socket_path\" ) ]]; then\n  echo \"Cannot combine --all with -L or -S\" >&2\n  exit 1\nfi\n\nif [[ -n \"$socket_name\" && -n \"$socket_path\" ]]; then\n  echo \"Use either -L or -S, not both\" >&2\n  exit 1\nfi\n\nif ! command -v tmux >/dev/null 2>&1; then\n  echo \"tmux not found in PATH\" >&2\n  exit 1\nfi\n\nlist_sessions() {\n  local label=\"$1\"; shift\n  local tmux_cmd=(tmux \"$@\")\n\n  if ! sessions=\"$(\"${tmux_cmd[@]}\" list-sessions -F '#{session_name}\\t#{session_attached}\\t#{session_created_string}' 2>/dev/null)\"; then\n    echo \"No tmux server found on $label\" >&2\n    return 1\n  fi\n\n  if [[ -n \"$query\" ]]; then\n    sessions=\"$(printf '%s\\n' \"$sessions\" | grep -i -- \"$query\" || true)\"\n  fi\n\n  if [[ -z \"$sessions\" ]]; then\n    echo \"No sessions found on $label\"\n    return 0\n  fi\n\n  echo \"Sessions on $label:\"\n  printf '%s\\n' \"$sessions\" | while IFS=$'\\t' read -r name attached created; do\n    attached_label=$([[ \"$attached\" == \"1\" ]] && echo \"attached\" || echo \"detached\")\n    printf '  - %s (%s, started %s)\\n' \"$name\" \"$attached_label\" \"$created\"\n  done\n}\n\nif [[ \"$scan_all\" == true ]]; then\n  if [[ ! -d \"$socket_dir\" ]]; then\n    echo \"Socket directory not found: $socket_dir\" >&2\n    exit 1\n  fi\n\n  shopt -s nullglob\n  sockets=(\"$socket_dir\"/*)\n  shopt -u nullglob\n\n  if [[ \"${#sockets[@]}\" -eq 0 ]]; then\n    echo \"No sockets found under $socket_dir\" >&2\n    exit 1\n  fi\n\n  exit_code=0\n  for sock in \"${sockets[@]}\"; do\n    if [[ ! -S \"$sock\" ]]; then\n      continue\n    fi\n    list_sessions \"socket path '$sock'\" -S \"$sock\" || exit_code=$?\n  done\n  exit \"$exit_code\"\nfi\n\ntmux_cmd=(tmux)\nsocket_label=\"default socket\"\n\nif [[ -n \"$socket_name\" ]]; then\n  tmux_cmd+=(-L \"$socket_name\")\n  socket_label=\"socket name '$socket_name'\"\nelif [[ -n \"$socket_path\" ]]; then\n  tmux_cmd+=(-S \"$socket_path\")\n  socket_label=\"socket path '$socket_path'\"\nfi\n\nlist_sessions \"$socket_label\" \"${tmux_cmd[@]:1}\"\n"
  },
  {
    "path": "workspace/skills/tmux/scripts/wait-for-text.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nusage() {\n  cat <<'USAGE'\nUsage: wait-for-text.sh -t target -p pattern [options]\n\nPoll a tmux pane for text and exit when found.\n\nOptions:\n  -t, --target    tmux target (session:window.pane), required\n  -p, --pattern   regex pattern to look for, required\n  -F, --fixed     treat pattern as a fixed string (grep -F)\n  -T, --timeout   seconds to wait (integer, default: 15)\n  -i, --interval  poll interval in seconds (default: 0.5)\n  -l, --lines     number of history lines to inspect (integer, default: 1000)\n  -h, --help      show this help\nUSAGE\n}\n\ntarget=\"\"\npattern=\"\"\ngrep_flag=\"-E\"\ntimeout=15\ninterval=0.5\nlines=1000\n\nwhile [[ $# -gt 0 ]]; do\n  case \"$1\" in\n    -t|--target)   target=\"${2-}\"; shift 2 ;;\n    -p|--pattern)  pattern=\"${2-}\"; shift 2 ;;\n    -F|--fixed)    grep_flag=\"-F\"; shift ;;\n    -T|--timeout)  timeout=\"${2-}\"; shift 2 ;;\n    -i|--interval) interval=\"${2-}\"; shift 2 ;;\n    -l|--lines)    lines=\"${2-}\"; shift 2 ;;\n    -h|--help)     usage; exit 0 ;;\n    *) echo \"Unknown option: $1\" >&2; usage; exit 1 ;;\n  esac\ndone\n\nif [[ -z \"$target\" || -z \"$pattern\" ]]; then\n  echo \"target and pattern are required\" >&2\n  usage\n  exit 1\nfi\n\nif ! [[ \"$timeout\" =~ ^[0-9]+$ ]]; then\n  echo \"timeout must be an integer number of seconds\" >&2\n  exit 1\nfi\n\nif ! [[ \"$lines\" =~ ^[0-9]+$ ]]; then\n  echo \"lines must be an integer\" >&2\n  exit 1\nfi\n\nif ! command -v tmux >/dev/null 2>&1; then\n  echo \"tmux not found in PATH\" >&2\n  exit 1\nfi\n\n# End time in epoch seconds (integer, good enough for polling)\nstart_epoch=$(date +%s)\ndeadline=$((start_epoch + timeout))\n\nwhile true; do\n  # -J joins wrapped lines, -S uses negative index to read last N lines\n  pane_text=\"$(tmux capture-pane -p -J -t \"$target\" -S \"-${lines}\" 2>/dev/null || true)\"\n\n  if printf '%s\\n' \"$pane_text\" | grep $grep_flag -- \"$pattern\" >/dev/null 2>&1; then\n    exit 0\n  fi\n\n  now=$(date +%s)\n  if (( now >= deadline )); then\n    echo \"Timed out after ${timeout}s waiting for pattern: $pattern\" >&2\n    echo \"Last ${lines} lines from $target:\" >&2\n    printf '%s\\n' \"$pane_text\" >&2\n    exit 1\n  fi\n\n  sleep \"$interval\"\ndone\n"
  },
  {
    "path": "workspace/skills/weather/SKILL.md",
    "content": "---\nname: weather\ndescription: Get current weather and forecasts with verified location matching (no API key required).\nhomepage: https://wttr.in/:help\nmetadata: {\"nanobot\":{\"emoji\":\"🌤️\",\"requires\":{\"bins\":[\"curl\"]}}}\n---\n\n# Weather\n\nUse the most reliable location match first. For Chinese city names or other non-Latin input, prefer `wttr.in` with the original query because it resolves native names directly. Use Open-Meteo for structured current conditions and forecasts only after you have confirmed the exact city.\n\n## Accuracy Rules\n\n- Always restate the matched location, region/country, and observation time in the final answer.\n- Do not trust the first geocoding hit blindly. Check `country`, `admin1`, `admin2`, and `population`.\n- For Chinese city queries, do not send Hanzi directly to Open-Meteo geocoding unless the top result is obviously correct. Prefer `wttr.in` with the original Chinese name, or geocode the English/pinyin city name instead.\n- If multiple plausible matches remain, ask a follow-up question or state the assumption clearly.\n- Use `timezone=auto` when calling Open-Meteo so the reported time matches the location.\n\n## wttr.in (best for direct city-name queries)\n\nQuick current conditions:\n```bash\ncurl -s \"https://wttr.in/London?format=%l:+%c+%t+%h+%w\"\n```\n\nChinese city example:\n```bash\ncurl -s \"https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w\"\ncurl -s \"https://wttr.in/%E4%B8%8A%E6%B5%B7?format=%l:+%c+%t+%h+%w\"\n```\n\nJSON output if you need more detail:\n```bash\ncurl -s \"https://wttr.in/Chengdu?format=j1\"\n```\n\nTips:\n- URL-encode spaces: `New York` -> `New+York`\n- URL-encode non-ASCII text before sending the request\n- Use `?m` for metric units and `?u` for US units\n\n## Open-Meteo (best for structured forecasts)\n\n1. Geocode the city and verify the returned location metadata:\n```bash\ncurl -s \"https://geocoding-api.open-meteo.com/v1/search?name=Chengdu&count=3&language=en&format=json\"\n```\n\n2. Query current weather and today's forecast with the verified coordinates:\n```bash\ncurl -s \"https://api.open-meteo.com/v1/forecast?latitude=30.66667&longitude=104.06667&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=1&timezone=auto\"\n```\n\nImportant:\n- For Chinese inputs like `成都`, geocoding `name=%E6%88%90%E9%83%BD` may return smaller homonym locations first. Prefer `Chengdu` after verifying it matches Sichuan, China.\n- If geocoding looks suspicious, fall back to `wttr.in` for the original city name instead of presenting a likely wrong result.\n\nDocs: https://open-meteo.com/en/docs\n"
  }
]